diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index 40ddfc918ff7..b374e5293e8d 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -90,8 +90,8 @@ function TextInput({ const noResultsFoundText = translate('common.noResultsFound'); const isNoResultsFoundMessage = headerMessage === noResultsFoundText; const isScreenReaderEnabled = Accessibility.useScreenReaderStatus(); - const noData = dataLength === 0 && !shouldShowLoadingPlaceholder; - const shouldShowHeaderMessage = !!shouldShowTextInput && !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || noData); + const hasNoData = dataLength === 0 && !shouldShowLoadingPlaceholder; + const shouldShowHeaderMessage = !!shouldShowTextInput && !!headerMessage && (!isLoadingNewOptions || !isNoResultsFoundMessage || hasNoData); const trimmedSearchValue = value?.trim() ?? ''; const suggestionsCount = dataLength ?? 0; const suggestionsAnnouncement = diff --git a/src/hooks/useFilteredOptions.ts b/src/hooks/useFilteredOptions.ts index a8d30765cfbe..2b4973b3d16c 100644 --- a/src/hooks/useFilteredOptions.ts +++ b/src/hooks/useFilteredOptions.ts @@ -93,7 +93,8 @@ function useFilteredOptions(config: UseFilteredOptionsConfig = {}): UseFilteredO [enabled, allReports, allPersonalDetails, reportAttributesDerived, privateIsArchivedMap, allPolicies, reportsLimit, includeP2P, isSearching, betas], ); - const hasMore = options ? reportsLimit < totalReports : false; + // When isSearching is set to true, the createFilteredOptionList returns all reports + const hasMore = !isSearching && options ? reportsLimit < totalReports : false; const loadMore = () => { if (!hasMore) { diff --git a/src/hooks/usePaginatedData.ts b/src/hooks/usePaginatedData.ts new file mode 100644 index 000000000000..b7a408d85a6e --- /dev/null +++ b/src/hooks/usePaginatedData.ts @@ -0,0 +1,57 @@ +import {useState} from 'react'; + +type UsePaginatedDataConfig = { + /** When this value changes, pagination resets to the first page. */ + resetKey?: string; + + /** When true, returns `data` as-is with `hasMore: false` and a no-op `loadMore`. */ + skipPagination?: boolean; +}; + +/** + * Client-side bounded-slice pagination. + * + * Returns the first `pageSize * currentPage` items of `data`, plus `loadMore` to advance a page and + * `hasMore` to indicate whether more items are available. `currentPage` is reset to 1 whenever + * `resetKey` changes, so callers can tie the page back to a logical context (e.g. a search query). + */ +function usePaginatedData( + data: T[], + pageSize: number, + {resetKey = '', skipPagination = false}: UsePaginatedDataConfig = {}, +): { + paginatedData: T[]; + loadMore: () => void; + hasMore: boolean; +} { + const [prevResetKey, setPrevResetKey] = useState(resetKey); + const [currentPage, setCurrentPage] = useState(1); + + if (resetKey !== prevResetKey) { + setPrevResetKey(resetKey); + setCurrentPage(1); + } + + if (skipPagination) { + return {paginatedData: data, loadMore: () => {}, hasMore: false}; + } + + if (pageSize < 1) { + return {paginatedData: [], loadMore: () => {}, hasMore: false}; + } + + const limit = pageSize * currentPage; + const paginatedData = data.slice(0, limit); + const hasMore = data.length > limit; + + const loadMore = () => { + if (!hasMore) { + return; + } + setCurrentPage((prev) => prev + 1); + }; + + return {paginatedData, loadMore, hasMore}; +} + +export default usePaginatedData; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 4d5d42cb1aba..495dcb2f0dda 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -1587,11 +1587,15 @@ const reportSortComparator = (report: Report, privateIsArchivedMap: PrivateIsArc * * Performance optimization approach: * 1. Pre-filters reports using shouldReportBeInOptionList with correct parameters (betas, etc.) - * 2. Sorts by lastVisibleActionCreated (most recent first) - * 3. Limits to top N reports - * 4. Processes only those N reports + * 2. Default (`options.isSearching` false): sorts by lastVisibleActionCreated (most recent first), limits to + * the top N reports (`maxRecentReports`), then processes only those reports. This avoids processing + * thousands of reports while ensuring correct filtering. + * 3. Search mode (`options.isSearching` true): uses the full pre-filtered report list with no recency sort and + * no `maxRecentReports` cap, so search can include all eligible reports. * - * This avoids processing thousands of reports while ensuring correct filtering. + * @param options.isSearching - When true, skips the sort and top-N limit in step 2; when false, applies them. + * + * @remarks In search mode, sorting by last visible action is skipped because the UI needs the full eligible set. * * Use this for screens that need recent reports (NewChatPage, WorkspaceInvitePage, etc.) */ @@ -1728,9 +1732,11 @@ function createOptionFromReport( }; } -function orderPersonalDetailsOptions(options: SearchOptionData[]) { +function orderPersonalDetailsOptions(options: T[]): T[] { // PersonalDetails should be ordered Alphabetically by default - https://github.com/Expensify/App/issues/8220#issuecomment-1104009435 - return lodashOrderBy(options, [(personalDetail) => personalDetail.text?.toLowerCase()], 'asc'); + // Keep this aligned with `getValidOptions` ordering (`optionsOrderBy(..., personalDetailsComparator, ...)`) + // so upstream and downstream sorting use the same key (text -> alternateText -> login). + return lodashOrderBy(options, [personalDetailsComparator], 'asc'); } /** @@ -1744,10 +1750,10 @@ function orderReportOptions(options: SearchOptionData[]) { /** * Sort personal details by displayName or login in alphabetical order */ -const personalDetailsComparator = (personalDetail: SearchOptionData | PersonalDetailOptionData) => { +function personalDetailsComparator(personalDetail: SearchOptionData | PersonalDetailOptionData) { const name = personalDetail.text ?? personalDetail.alternateText ?? personalDetail.login ?? ''; return name.toLowerCase(); -}; +} /** * Sort reports by archived status and last visible action @@ -3405,6 +3411,7 @@ export { isSearchStringMatchUserDetails, optionsOrderBy, orderOptions, + orderPersonalDetailsOptions, orderWorkspaceOptions, processReport, recentReportComparator, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index be2521f7d595..0ef71920d390 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -816,75 +816,75 @@ type CustomIcon = { color?: string; }; -type OptionData = { - text?: string; - alternateText?: string; - allReportErrors?: Errors; - brickRoadIndicator?: ValueOf | '' | null; - actionBadge?: ValueOf; - actionTargetReportActionID?: string; - tooltipText?: string | null; - alternateTextMaxLines?: number; - boldStyle?: boolean; - customIcon?: CustomIcon; - subtitle?: string; - login?: string; - accountID?: number; - pronouns?: string; - status?: Status | null; - phoneNumber?: string; - isUnread?: boolean | null; - isUnreadWithMention?: boolean | null; - hasDraftComment?: boolean | null; - keyForList: string; - searchText?: string; - isIOUReportOwner?: boolean | null; - shouldShowSubscript?: boolean | null; - isPolicyExpenseChat?: boolean; - isMoneyRequestReport?: boolean | null; - isInvoiceReport?: boolean; - isExpenseRequest?: boolean | null; - isAllowedToComment?: boolean | null; - isThread?: boolean | null; - isTaskReport?: boolean | null; - parentReportAction?: OnyxEntry; - displayNamesWithTooltips?: DisplayNameWithTooltips | null; - isDefaultRoom?: boolean; - isInvoiceRoom?: boolean; - isExpenseReport?: boolean; - isDM?: boolean; - isOptimisticPersonalDetail?: boolean; - selected?: boolean; - isOptimisticAccount?: boolean; - isSelected?: boolean; - descriptiveText?: string; - notificationPreference?: NotificationPreference | null; - isDisabled?: boolean | null; - name?: string | null; - isSelfDM?: boolean; - isOneOnOneChat?: boolean; - reportID?: string; - enabled?: boolean; - code?: string; - transactionThreadReportID?: string | null; - shouldShowAmountInput?: boolean; - amountInputProps?: MoneyRequestAmountInputProps; - tabIndex?: 0 | -1; - isConciergeChat?: boolean; - isBold?: boolean; - lastIOUCreationDate?: string; - isChatRoom?: boolean; - participantsList?: PersonalDetails[]; - icons?: Icon[]; - iouReportAmount?: number; - displayName?: string; - firstName?: string; - lastName?: string; - avatar?: AvatarSource; - timezone?: Timezone; -} & Report & +type OptionData = Report & Omit & { private_isArchived?: boolean; + } & { + text?: string; + alternateText?: string; + allReportErrors?: Errors; + brickRoadIndicator?: ValueOf | '' | null; + actionBadge?: ValueOf; + actionTargetReportActionID?: string; + tooltipText?: string | null; + alternateTextMaxLines?: number; + boldStyle?: boolean; + customIcon?: CustomIcon; + subtitle?: string; + login?: string; + accountID?: number; + pronouns?: string; + status?: Status | null; + phoneNumber?: string; + isUnread?: boolean | null; + isUnreadWithMention?: boolean | null; + hasDraftComment?: boolean | null; + keyForList: string; + searchText?: string; + isIOUReportOwner?: boolean | null; + shouldShowSubscript?: boolean | null; + isPolicyExpenseChat?: boolean; + isMoneyRequestReport?: boolean | null; + isInvoiceReport?: boolean; + isExpenseRequest?: boolean | null; + isAllowedToComment?: boolean | null; + isThread?: boolean | null; + isTaskReport?: boolean | null; + parentReportAction?: OnyxEntry; + displayNamesWithTooltips?: DisplayNameWithTooltips | null; + isDefaultRoom?: boolean; + isInvoiceRoom?: boolean; + isExpenseReport?: boolean; + isDM?: boolean; + isOptimisticPersonalDetail?: boolean; + selected?: boolean; + isOptimisticAccount?: boolean; + isSelected?: boolean; + descriptiveText?: string; + notificationPreference?: NotificationPreference | null; + isDisabled?: boolean | null; + name?: string | null; + isSelfDM?: boolean; + isOneOnOneChat?: boolean; + reportID?: string; + enabled?: boolean; + code?: string; + transactionThreadReportID?: string | null; + shouldShowAmountInput?: boolean; + amountInputProps?: MoneyRequestAmountInputProps; + tabIndex?: 0 | -1; + isConciergeChat?: boolean; + isBold?: boolean; + lastIOUCreationDate?: string; + isChatRoom?: boolean; + participantsList?: PersonalDetails[]; + icons?: Icon[]; + iouReportAmount?: number; + displayName?: string; + firstName?: string; + lastName?: string; + avatar?: AvatarSource; + timezone?: Timezone; }; type OnyxDataTaskAssigneeChat = { diff --git a/src/pages/NewChatPage/index.tsx b/src/pages/NewChatPage/index.tsx index 952cca8c149c..0fce071cb89d 100755 --- a/src/pages/NewChatPage/index.tsx +++ b/src/pages/NewChatPage/index.tsx @@ -23,6 +23,7 @@ import useIsFocusedRef from '@hooks/useIsFocusedRef'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import usePaginatedData from '@hooks/usePaginatedData'; import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; import useSingleExecution from '@hooks/useSingleExecution'; import useSortedActions from '@hooks/useSortedActions'; @@ -44,10 +45,12 @@ import type {ReportAttributesDerivedValue} from '@src/types/onyx/DerivedValues'; import type {SelectedParticipant} from '@src/types/onyx/NewGroupChatDraft'; import getEmptyArray from '@src/types/utils/getEmptyArray'; import KeyboardUtils from '@src/utils/keyboard'; +import mergeAndSortPersonalDetailsWithContacts from './mergeAndSortPersonalDetailsWithContacts'; import type SelectedOption from './types'; -import useGroupChatDraftParticipantSync from './useGroupDraftRestore'; +import useGroupChatDraftParticipantSync from './useGroupChatDraftParticipantSync'; const excludedGroupEmails = new Set(CONST.EXPENSIFY_EMAILS.filter((value) => value !== CONST.EMAIL.CONCIERGE)); +const PAGINATION_SIZE = CONST.MAX_SELECTION_LIST_PAGE_LENGTH; function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['reports'] | undefined) { const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); @@ -68,31 +71,53 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS, {selector: passthroughPolicyTagListSelector}); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const isSearching = !!debouncedSearchTerm.trim(); + const { options: listOptions, isLoading, - loadMore, - hasMore, + loadMore: loadMoreReports, + hasMore: hasMoreFilteredOptions, } = useFilteredOptions({ maxRecentReports: 500, enabled: didScreenTransitionEnd, includeP2P: true, batchSize: 100, enablePagination: true, - isSearching: !!debouncedSearchTerm.trim(), + isSearching, betas, }); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const reports = listOptions?.reports ?? []; - const personalDetails = listOptions?.personalDetails ?? []; - useGroupChatDraftParticipantSync(personalDetails, !isLoading, allPersonalDetails, loginList, currentUserEmail, currentUserAccountID, selectedOptions, setSelectedOptions); + + const allPersonalDetailOptions = listOptions?.personalDetails ?? []; + + // Dedupe and sort the union of Onyx personal details and imported contacts so pagination uses an alphabetical prefix. + const sortedPersonalDetailOptionsWithContacts = mergeAndSortPersonalDetailsWithContacts(allPersonalDetailOptions, contacts); + + // usePaginatedData resets to page 1 whenever resetKey changes. Encode browse and search as "false" and "true", respectively, + // so that we only reset when the user enters or leaves search, not on every debounced keystroke. + const browsePaginationResetKey = String(isSearching); + + // Limits raw personal details entering getValidOptions to reduce processing cost on initial load. + // Bypassed during search to avoid repeatedly calling loadMore and prevent FlashList onEndReached infinite loop. + const { + paginatedData: personalDetails, + loadMore: loadMorePersonalDetails, + hasMore: hasMorePersonalDetails, + } = usePaginatedData(sortedPersonalDetailOptionsWithContacts, PAGINATION_SIZE, { + resetKey: browsePaginationResetKey, + skipPagination: isSearching, + }); + + useGroupChatDraftParticipantSync(allPersonalDetailOptions, !isLoading, allPersonalDetails, loginList, currentUserEmail, currentUserAccountID, selectedOptions, setSelectedOptions); const {options: defaultOptions} = getValidOptions( { reports, - personalDetails: personalDetails.concat(contacts), + personalDetails, }, allPolicies, draftComments, @@ -112,6 +137,10 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor }, ); + if (selectedOptions.length) { + defaultOptions.recentReports = defaultOptions.recentReports.filter((option) => !option.isSelfDM); + } + const unselectedOptions = filterSelectedOptions(defaultOptions, new Set(selectedOptions.map(({accountID}) => accountID))); const areOptionsInitialized = !isLoading; @@ -123,6 +152,13 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor const cleanSearchTerm = debouncedSearchTerm.trim().toLowerCase(); + // Visual pagination — limits how many filtered personal details are passed to FlashList at once. + const { + paginatedData: paginatedFilteredPersonalDetails, + loadMore: loadMoreFilteredPersonalDetails, + hasMore: hasMoreFilteredPersonalDetails, + } = usePaginatedData(options.personalDetails, PAGINATION_SIZE, {resetKey: cleanSearchTerm, skipPagination: !isSearching}); + const headerMessage = getHeaderMessage( options.personalDetails.length + options.recentReports.length !== 0, !!options.userToInvite, @@ -148,14 +184,27 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor }, [debouncedSearchTerm]); const handleEndReached = () => { - if (!hasMore || !areOptionsInitialized || !isScreenFocusedRef.current) { + const hasNoDataToLoad = !hasMoreFilteredPersonalDetails && !hasMorePersonalDetails && !hasMoreFilteredOptions; + if (hasNoDataToLoad || !areOptionsInitialized || !isScreenFocusedRef.current) { return; } - loadMore(); + + if (hasMoreFilteredPersonalDetails) { + loadMoreFilteredPersonalDetails(); + } + + if (hasMorePersonalDetails) { + loadMorePersonalDetails(); + } + + if (options.recentReports.length < CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW && hasMoreFilteredOptions) { + loadMoreReports(); + } }; return { ...options, + personalDetails: paginatedFilteredPersonalDetails, searchTerm, debouncedSearchTerm, setSearchTerm, @@ -226,7 +275,7 @@ function NewChatPage({ref}: NewChatPageProps) { sections.push({ title: translate('common.recents'), - data: selectedOptions.length ? recentReports.filter((option) => !option.isSelfDM) : recentReports, + data: recentReports, sectionIndex: 1, }); diff --git a/src/pages/NewChatPage/mergeAndSortPersonalDetailsWithContacts.ts b/src/pages/NewChatPage/mergeAndSortPersonalDetailsWithContacts.ts new file mode 100644 index 000000000000..2162eeb92c8b --- /dev/null +++ b/src/pages/NewChatPage/mergeAndSortPersonalDetailsWithContacts.ts @@ -0,0 +1,20 @@ +import lodashUniqBy from 'lodash/uniqBy'; +import {orderPersonalDetailsOptions} from '@libs/OptionsListUtils'; +import type {SearchOptionData} from '@libs/OptionsListUtils'; +import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; + +/** + * Merges Onyx personal details with imported contacts, removes entries without login, + * de-dupes by normalized login (case-insensitive, with SMS-domain normalization), + * and returns the union sorted alphabetically. + * + * Onyx options are placed first in the merge order, so when logins collide + * the Onyx entry is preserved over the imported contact entry. + */ +function mergeAndSortPersonalDetailsWithContacts(allPersonalDetailOptions: T[], contacts: T[]): T[] { + const merged = [...allPersonalDetailOptions, ...contacts].filter((option) => !!option.login); + const deduped = lodashUniqBy(merged, (option) => addSMSDomainIfPhoneNumber(option.login).toLowerCase()); + return orderPersonalDetailsOptions(deduped); +} + +export default mergeAndSortPersonalDetailsWithContacts; diff --git a/src/pages/NewChatPage/useGroupDraftRestore.ts b/src/pages/NewChatPage/useGroupChatDraftParticipantSync.ts similarity index 88% rename from src/pages/NewChatPage/useGroupDraftRestore.ts rename to src/pages/NewChatPage/useGroupChatDraftParticipantSync.ts index 5b740147fb81..13b048fc968c 100644 --- a/src/pages/NewChatPage/useGroupDraftRestore.ts +++ b/src/pages/NewChatPage/useGroupChatDraftParticipantSync.ts @@ -1,6 +1,6 @@ -import {useFocusEffect} from '@react-navigation/native'; -import {useCallback, useEffect, useEffectEvent, useRef} from 'react'; +import {useEffect, useEffectEvent, useRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; +import useIsFocusedRef from '@hooks/useIsFocusedRef'; import useOnyx from '@hooks/useOnyx'; import {getUserToInviteOption} from '@libs/OptionsListUtils'; import type {SearchOption} from '@libs/OptionsListUtils'; @@ -30,10 +30,10 @@ function useGroupChatDraftParticipantSync( setSelectedOptions: (options: SelectedOption[]) => void, ) { const shouldRestoreSelectedOptionsRef = useRef(true); - const isScreenInBackgroundRef = useRef(false); + const isScreenFocusedRef = useIsFocusedRef(); const draftParticipantsSelector = (draft: NewGroupChatDraft | undefined) => { - const isSubscriptionActive = shouldRestoreSelectedOptionsRef.current || isScreenInBackgroundRef.current; + const isSubscriptionActive = shouldRestoreSelectedOptionsRef.current || !isScreenFocusedRef.current; return isSubscriptionActive ? draft?.participants : undefined; }; @@ -81,23 +81,13 @@ function useGroupChatDraftParticipantSync( setSelectedOptions(filteredSelectionOptions); }); - useFocusEffect( - useCallback(() => { - isScreenInBackgroundRef.current = false; - - return () => { - isScreenInBackgroundRef.current = true; - }; - }, []), - ); - // Handle removing participants on other pages (e.g. NewChatConfirmPage) useEffect(() => { - if (!isScreenInBackgroundRef.current) { + if (isScreenFocusedRef.current) { return; } syncSelectedOptionsWithDraft(); - }, [draftParticipants]); + }, [draftParticipants, isScreenFocusedRef]); const areRestoreInputsReady = areAllPersonalDetailOptionsLoaded && !isLoadingOnyxValue(draftParticipantsMetadata); diff --git a/tests/unit/NewChatPage/mergeAndSortPersonalDetailsWithContactsTest.ts b/tests/unit/NewChatPage/mergeAndSortPersonalDetailsWithContactsTest.ts new file mode 100644 index 000000000000..adc165ed72c4 --- /dev/null +++ b/tests/unit/NewChatPage/mergeAndSortPersonalDetailsWithContactsTest.ts @@ -0,0 +1,56 @@ +import type {SearchOptionData} from '@libs/OptionsListUtils'; +import mergeAndSortPersonalDetailsWithContacts from '@pages/NewChatPage/mergeAndSortPersonalDetailsWithContacts'; + +describe('mergeAndSortPersonalDetailsWithContacts', () => { + it('sorts the merged set alphabetically by the personalDetailsComparator key', () => { + const onyx: SearchOptionData[] = [ + {login: 'zara@x.com', accountID: 1, text: 'Zara', keyForList: '1', reportID: ''}, + {login: 'bob@x.com', accountID: 2, text: 'Bob', keyForList: '2', reportID: ''}, + ]; + const contacts: SearchOptionData[] = [{login: 'aaron@x.com', accountID: 999, text: 'Aaron', keyForList: '999', reportID: ''}]; + + const result = mergeAndSortPersonalDetailsWithContacts(onyx, contacts); + + expect(result.map((option) => option.text)).toEqual(['Aaron', 'Bob', 'Zara']); + }); + + it('dedupes by login case-insensitively', () => { + const onyx: SearchOptionData[] = [{login: 'john@x.com', accountID: 1, text: 'John Onyx', keyForList: '1', reportID: ''}]; + const contacts: SearchOptionData[] = [{login: 'JOHN@x.com', accountID: 999, text: 'John Contact', keyForList: '999', reportID: ''}]; + + const result = mergeAndSortPersonalDetailsWithContacts(onyx, contacts); + + expect(result).toHaveLength(1); + }); + + it('prefers the Onyx personal-detail copy over the contact copy on a login collision', () => { + const onyx: SearchOptionData[] = [{login: 'john@x.com', accountID: 12345, text: 'John Onyx', keyForList: '12345', reportID: ''}]; + const contacts: SearchOptionData[] = [{login: 'john@x.com', accountID: 99999, text: 'John Contact', keyForList: '99999', reportID: ''}]; + + const result = mergeAndSortPersonalDetailsWithContacts(onyx, contacts); + + expect(result.at(0)?.accountID).toBe(12345); + expect(result.at(0)?.text).toBe('John Onyx'); + }); + + it('dedupes phone-number logins regardless of SMS-domain suffix', () => { + const onyx: SearchOptionData[] = [{login: '+12025550100@expensify.sms', accountID: 1, text: 'Phone Onyx', keyForList: '1', reportID: ''}]; + const contacts: SearchOptionData[] = [{login: '+12025550100', accountID: 999, text: 'Phone Contact', keyForList: '999', reportID: ''}]; + + const result = mergeAndSortPersonalDetailsWithContacts(onyx, contacts); + + expect(result).toHaveLength(1); + expect(result.at(0)?.accountID).toBe(1); + }); + + it('drops entries with empty login', () => { + const onyx: SearchOptionData[] = [ + {login: '', accountID: 1, text: 'Anon', keyForList: '1', reportID: ''}, + {login: 'a@x.com', accountID: 2, text: 'Alice', keyForList: '2', reportID: ''}, + ]; + + const result = mergeAndSortPersonalDetailsWithContacts(onyx, []); + + expect(result.map((option) => option.login)).toEqual(['a@x.com']); + }); +}); diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index bf2f3ac60865..07d3d10cd083 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -38,6 +38,7 @@ import { getValidOptions, optionsOrderBy, orderOptions, + orderPersonalDetailsOptions, orderWorkspaceOptions, recentReportComparator, shouldShowLastActorDisplayName, @@ -3760,8 +3761,8 @@ describe('OptionsListUtils', () => { it('handles negative limit with large absolute value', () => { const options: OptionData[] = [ - {reportID: '1', lastVisibleActionCreated: '2022-01-01T10:00:00Z'} as OptionData, - {reportID: '2', lastVisibleActionCreated: '2022-01-01T12:00:00Z'} as OptionData, + {reportID: '1', lastVisibleActionCreated: '2022-01-01T10:00:00Z', keyForList: '1'}, + {reportID: '2', lastVisibleActionCreated: '2022-01-01T12:00:00Z', keyForList: '2'}, ]; const comparator = (option: OptionData) => option.lastVisibleActionCreated ?? ''; const result = optionsOrderBy(options, comparator, -100).options; @@ -8293,6 +8294,32 @@ describe('OptionsListUtils', () => { }); }); + describe('orderPersonalDetailsOptions()', () => { + it('sorts options alphabetically using text values', () => { + const options = [ + {accountID: 1, text: 'Charlie', login: 'c@example.com'}, + {accountID: 2, text: 'aaron', login: 'a@example.com'}, + {accountID: 3, text: 'Bob', login: 'b@example.com'}, + ] as SearchOptionData[]; + + const sorted = orderPersonalDetailsOptions(options); + + expect(sorted.map((option) => option.text)).toEqual(['aaron', 'Bob', 'Charlie']); + }); + + it('falls back to alternateText and login when text is missing', () => { + const options = [ + {accountID: 1, text: undefined, alternateText: 'mango', login: 'm@example.com'}, + {accountID: 2, text: 'apple', login: 'a@example.com'}, + {accountID: 3, text: undefined, alternateText: undefined, login: 'banana@example.com'}, + ] as SearchOptionData[]; + + const sorted = orderPersonalDetailsOptions(options); + + expect(sorted.map((option) => option.accountID)).toEqual([2, 3, 1]); + }); + }); + describe('getValidOptions with sortedActions', () => { it('returns lastIOUCreationDate from the latest IOU action linked via REPORT_PREVIEW', async () => { const reportID = 'gvo-sorted-1'; diff --git a/tests/unit/useGroupDraftRestoreTest.ts b/tests/unit/useGroupChatDraftParticipantSyncTest.ts similarity index 87% rename from tests/unit/useGroupDraftRestoreTest.ts rename to tests/unit/useGroupChatDraftParticipantSyncTest.ts index 57fd312a6f1d..86a730e8592e 100644 --- a/tests/unit/useGroupDraftRestoreTest.ts +++ b/tests/unit/useGroupChatDraftParticipantSyncTest.ts @@ -6,10 +6,11 @@ import type {SearchOption} from '@libs/OptionsListUtils'; import type {OptionData} from '@libs/ReportUtils'; import type {PersonalDetails} from '@src/types/onyx'; import type {SelectedParticipant} from '@src/types/onyx/NewGroupChatDraft'; -import useGroupChatDraftParticipantSync from '../../src/pages/NewChatPage/useGroupDraftRestore'; +import type SelectedOption from '../../src/pages/NewChatPage/types'; +import useGroupChatDraftParticipantSync from '../../src/pages/NewChatPage/useGroupChatDraftParticipantSync'; -const mockUseOnyx = useOnyx as jest.MockedFunction; -const mockGetUserToInviteOption = OptionsListUtilsModule.getUserToInviteOption as jest.MockedFunction; +const mockUseOnyx: jest.Mock = jest.mocked(useOnyx); +const mockGetUserToInviteOption = jest.mocked(OptionsListUtilsModule.getUserToInviteOption); jest.mock('@hooks/useOnyx', () => jest.fn()); jest.mock('@libs/OptionsListUtils', () => { @@ -34,30 +35,36 @@ jest.mock('@react-navigation/native', () => { mockFocusState.cleanup = cleanup; } }, + useNavigation: () => ({ + isFocused: () => mockFocusState.isScreenFocused, + addListener: () => () => {}, + }), }; }); const CURRENT_USER_ACCOUNT_ID = 1; const CURRENT_USER_EMAIL = 'current@test.com'; -function makePersonalDetailOption(accountID: number, login: string) { +function makePersonalDetailOption(accountID: number, login: string): SearchOption { return { accountID, login, text: login, keyForList: String(accountID), - item: {accountID, login, displayName: login} as PersonalDetails, - } as SearchOption; + reportID: '', + item: {accountID, login, displayName: login}, + }; } -function makeSelectedOption(accountID: number, login: string) { +function makeSelectedOption(accountID: number, login: string): OptionData { return { accountID, login, text: login, keyForList: String(accountID), + reportID: '', isSelected: true, - } as OptionData; + }; } const PARTICIPANT_A: SelectedParticipant = {accountID: 10, login: 'alice@test.com'}; @@ -80,14 +87,14 @@ describe('useGroupDraftRestore', () => { }); function setupUseOnyx(draftParticipants: SelectedParticipant[] | undefined, draftStatus: 'loading' | 'loaded' = 'loaded') { - mockUseOnyx.mockImplementation(((key: string, options?: {selector?: (draft: unknown) => unknown}) => { + mockUseOnyx.mockImplementation((key: string, options?: {selector?: (draft: unknown) => unknown}) => { if (key === 'newGroupChatDraft') { const draft = draftParticipants ? {participants: draftParticipants} : undefined; const value = options?.selector ? options.selector(draft) : draft; return [value, {status: draftStatus}]; } return [undefined, {status: 'loaded'}]; - }) as typeof useOnyx); + }); } function renderRestoreHook(overrides?: { @@ -97,7 +104,7 @@ describe('useGroupDraftRestore', () => { draftParticipants?: SelectedParticipant[] | undefined; draftStatus?: 'loading' | 'loaded'; }) { - const setSelectedOptions = jest.fn(); + const setSelectedOptions = jest.fn(); const { allPersonalDetailOptions = ALL_PERSONAL_DETAIL_OPTIONS, areAllPersonalDetailOptionsLoaded = true, @@ -130,7 +137,7 @@ describe('useGroupDraftRestore', () => { const {setSelectedOptions} = renderRestoreHook({draftParticipants}); expect(setSelectedOptions).toHaveBeenCalledTimes(1); - const restored = (setSelectedOptions.mock.calls as OptionData[][][]).at(0)?.at(0) ?? []; + const restored = setSelectedOptions.mock.calls.at(0)?.at(0) ?? []; expect(restored).toHaveLength(2); expect(restored.at(0)?.accountID).toBe(PARTICIPANT_A.accountID); expect(restored.at(1)?.accountID).toBe(PARTICIPANT_B.accountID); @@ -141,7 +148,7 @@ describe('useGroupDraftRestore', () => { const draftParticipants = [CURRENT_USER_PARTICIPANT, PARTICIPANT_A]; const {setSelectedOptions} = renderRestoreHook({draftParticipants}); - const restored = (setSelectedOptions.mock.calls as OptionData[][][]).at(0)?.at(0) ?? []; + const restored = setSelectedOptions.mock.calls.at(0)?.at(0) ?? []; expect(restored).toHaveLength(1); expect(restored.at(0)?.accountID).toBe(PARTICIPANT_A.accountID); }); @@ -166,7 +173,7 @@ describe('useGroupDraftRestore', () => { const draftParticipants = [PARTICIPANT_A]; setupUseOnyx(draftParticipants); - const setSelectedOptions = jest.fn(); + const setSelectedOptions = jest.fn(); const {rerender} = renderHook(() => useGroupChatDraftParticipantSync(ALL_PERSONAL_DETAIL_OPTIONS, true, {}, {}, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, [], setSelectedOptions), ); @@ -193,7 +200,7 @@ describe('useGroupDraftRestore', () => { describe('background sync', () => { it('should sync removals when draft changes while screen is in background', () => { const initialDraftParticipants = [CURRENT_USER_PARTICIPANT, PARTICIPANT_A, PARTICIPANT_B, PARTICIPANT_C]; - const setSelectedOptions = jest.fn(); + const setSelectedOptions = jest.fn(); const selectedAfterRestore = [makeSelectedOption(10, 'alice@test.com'), makeSelectedOption(20, 'bob@test.com'), makeSelectedOption(30, 'carol@test.com')]; setupUseOnyx(initialDraftParticipants); @@ -224,7 +231,7 @@ describe('useGroupDraftRestore', () => { rerender({draftParticipants: draftWithRemoval}); expect(setSelectedOptions).toHaveBeenCalled(); - const synced = (setSelectedOptions.mock.calls as OptionData[][][]).at(-1)?.at(0) ?? []; + const synced = setSelectedOptions.mock.calls.at(-1)?.at(0) ?? []; const syncedLogins = synced.map((option) => option.login); expect(syncedLogins).toContain('alice@test.com'); expect(syncedLogins).toContain('carol@test.com'); @@ -238,7 +245,7 @@ describe('useGroupDraftRestore', () => { it('should sync to empty when all participants are removed from draft', () => { const initialDraftParticipants = [CURRENT_USER_PARTICIPANT, PARTICIPANT_A, PARTICIPANT_B]; - const setSelectedOptions = jest.fn(); + const setSelectedOptions = jest.fn(); const selectedAfterRestore = [makeSelectedOption(10, 'alice@test.com'), makeSelectedOption(20, 'bob@test.com')]; setupUseOnyx(initialDraftParticipants); @@ -265,13 +272,13 @@ describe('useGroupDraftRestore', () => { rerender({draftParticipants: [CURRENT_USER_PARTICIPANT]}); expect(setSelectedOptions).toHaveBeenCalled(); - const synced = (setSelectedOptions.mock.calls as OptionData[][][]).at(-1)?.at(0) ?? []; + const synced = setSelectedOptions.mock.calls.at(-1)?.at(0) ?? []; expect(synced).toHaveLength(0); }); it('should not sync when screen is focused (normal operation after restore)', () => { const draftParticipants = [CURRENT_USER_PARTICIPANT, PARTICIPANT_A, PARTICIPANT_B]; - const setSelectedOptions = jest.fn(); + const setSelectedOptions = jest.fn(); const selectedAfterRestore = [makeSelectedOption(10, 'alice@test.com'), makeSelectedOption(20, 'bob@test.com')]; setupUseOnyx(draftParticipants); @@ -300,7 +307,7 @@ describe('useGroupDraftRestore', () => { describe('selector inactivity', () => { it('should not react to draft changes after restore (selector returns undefined)', () => { const draftParticipants = [CURRENT_USER_PARTICIPANT, PARTICIPANT_A]; - const setSelectedOptions = jest.fn(); + const setSelectedOptions = jest.fn(); setupUseOnyx(draftParticipants); @@ -326,7 +333,7 @@ describe('useGroupDraftRestore', () => { describe('fallback and edge cases', () => { it('should use getUserToInviteOption fallback for unknown participants', () => { const unknownParticipant: SelectedParticipant = {accountID: 999, login: 'unknown@test.com'}; - const fallbackOption = {text: 'unknown@test.com', login: 'unknown@test.com', accountID: 999, keyForList: '999'} as OptionData; + const fallbackOption: OptionData = {text: 'unknown@test.com', login: 'unknown@test.com', accountID: 999, keyForList: '999', reportID: ''}; mockGetUserToInviteOption.mockReturnValue(fallbackOption); const {setSelectedOptions} = renderRestoreHook({ @@ -334,7 +341,7 @@ describe('useGroupDraftRestore', () => { }); expect(setSelectedOptions).toHaveBeenCalledTimes(1); - const restored = (setSelectedOptions.mock.calls as OptionData[][][]).at(0)?.at(0) ?? []; + const restored = setSelectedOptions.mock.calls.at(0)?.at(0) ?? []; expect(restored).toHaveLength(1); expect(restored.at(0)?.login).toBe('unknown@test.com'); expect(mockGetUserToInviteOption).toHaveBeenCalled(); @@ -350,7 +357,7 @@ describe('useGroupDraftRestore', () => { it('should restore after personal details load (delayed loading)', () => { const draftParticipants = [PARTICIPANT_A, PARTICIPANT_B]; - const setSelectedOptions = jest.fn(); + const setSelectedOptions = jest.fn(); setupUseOnyx(draftParticipants); @@ -364,13 +371,13 @@ describe('useGroupDraftRestore', () => { rerender({areLoaded: true}); expect(setSelectedOptions).toHaveBeenCalledTimes(1); - const restored = (setSelectedOptions.mock.calls as OptionData[][][]).at(0)?.at(0) ?? []; + const restored = setSelectedOptions.mock.calls.at(0)?.at(0) ?? []; expect(restored).toHaveLength(2); }); it('should restore invited-by-email participant when user has no contacts', () => { const invited: SelectedParticipant = {accountID: 999, login: 'invited@test.com'}; - const inviteStub = {text: 'invited@test.com', login: 'invited@test.com', accountID: 999, keyForList: '999'} as OptionData; + const inviteStub: OptionData = {text: 'invited@test.com', login: 'invited@test.com', accountID: 999, keyForList: '999', reportID: ''}; mockGetUserToInviteOption.mockReturnValue(inviteStub); const {setSelectedOptions} = renderRestoreHook({ @@ -380,7 +387,7 @@ describe('useGroupDraftRestore', () => { }); expect(setSelectedOptions).toHaveBeenCalledTimes(1); - const restored = (setSelectedOptions.mock.calls as OptionData[][][]).at(0)?.at(0) ?? []; + const restored = setSelectedOptions.mock.calls.at(0)?.at(0) ?? []; expect(restored).toHaveLength(1); expect(restored.at(0)?.login).toBe('invited@test.com'); expect(mockGetUserToInviteOption).toHaveBeenCalled(); diff --git a/tests/unit/usePaginatedDataTest.ts b/tests/unit/usePaginatedDataTest.ts new file mode 100644 index 000000000000..d1e0200dd791 --- /dev/null +++ b/tests/unit/usePaginatedDataTest.ts @@ -0,0 +1,231 @@ +import {act, renderHook} from '@testing-library/react-native'; +import usePaginatedData from '@hooks/usePaginatedData'; + +const buildData = (size: number) => Array.from({length: size}, (_, index) => index); + +describe('usePaginatedData', () => { + describe('initial render', () => { + it('returns the first pageSize items on first render', () => { + const data = buildData(50); + const {result} = renderHook(() => usePaginatedData(data, 10)); + + expect(result.current.paginatedData).toEqual(data.slice(0, 10)); + }); + + it('reports hasMore=true when data exceeds the first page', () => { + const {result} = renderHook(() => usePaginatedData(buildData(50), 10)); + + expect(result.current.hasMore).toBe(true); + }); + + it('reports hasMore=false when data length equals pageSize exactly (boundary)', () => { + const {result} = renderHook(() => usePaginatedData(buildData(10), 10)); + + expect(result.current.hasMore).toBe(false); + }); + + it('returns empty paginatedData with hasMore=false when data is empty', () => { + const {result} = renderHook(() => usePaginatedData([], 10)); + + expect(result.current.paginatedData).toEqual([]); + expect(result.current.hasMore).toBe(false); + + act(() => result.current.loadMore()); + + expect(result.current.paginatedData).toEqual([]); + expect(result.current.hasMore).toBe(false); + }); + }); + + describe('loadMore semantics', () => { + it('advances paginatedData by exactly one pageSize', () => { + const data = buildData(50); + const {result} = renderHook(() => usePaginatedData(data, 10)); + + act(() => result.current.loadMore()); + + expect(result.current.paginatedData).toEqual(data.slice(0, 20)); + }); + + it('can be called repeatedly to walk through all pages', () => { + const data = buildData(50); + const {result} = renderHook(() => usePaginatedData(data, 10)); + + act(() => result.current.loadMore()); + act(() => result.current.loadMore()); + act(() => result.current.loadMore()); + act(() => result.current.loadMore()); + + expect(result.current.paginatedData).toEqual(data); + expect(result.current.hasMore).toBe(false); + }); + + it('is a no-op once hasMore is false', () => { + const data = buildData(20); + const {result} = renderHook(() => usePaginatedData(data, 10)); + + act(() => result.current.loadMore()); + expect(result.current.hasMore).toBe(false); + + act(() => result.current.loadMore()); + act(() => result.current.loadMore()); + + expect(result.current.paginatedData).toEqual(data); + expect(result.current.hasMore).toBe(false); + }); + + it('flips hasMore to false on the same render that exactly consumes the data', () => { + const data = buildData(20); + const {result} = renderHook(() => usePaginatedData(data, 10)); + + expect(result.current.hasMore).toBe(true); + + act(() => result.current.loadMore()); + + expect(result.current.paginatedData).toEqual(data); + expect(result.current.hasMore).toBe(false); + }); + }); + + describe('resetKey behavior', () => { + it('preserves currentPage when resetKey is unchanged across re-renders', () => { + const data = buildData(50); + const {result, rerender} = renderHook(({resetKey}) => usePaginatedData(data, 10, {resetKey}), {initialProps: {resetKey: 'a'}}); + + act(() => result.current.loadMore()); + expect(result.current.paginatedData).toEqual(data.slice(0, 20)); + + rerender({resetKey: 'a'}); + + expect(result.current.paginatedData).toEqual(data.slice(0, 20)); + }); + + it('resets paginatedData to the first page when resetKey changes', () => { + const data = buildData(50); + const {result, rerender} = renderHook(({resetKey}) => usePaginatedData(data, 10, {resetKey}), {initialProps: {resetKey: 'a'}}); + + act(() => result.current.loadMore()); + expect(result.current.paginatedData).toEqual(data.slice(0, 20)); + + rerender({resetKey: 'b'}); + + expect(result.current.paginatedData).toEqual(data.slice(0, 10)); + expect(result.current.hasMore).toBe(true); + }); + + it('treats an omitted config as stable across renders (default resetKey is stable)', () => { + const data = buildData(50); + const {result, rerender} = renderHook(() => usePaginatedData(data, 10)); + + act(() => result.current.loadMore()); + expect(result.current.paginatedData).toEqual(data.slice(0, 20)); + + rerender(undefined); + + expect(result.current.paginatedData).toEqual(data.slice(0, 20)); + }); + + it('returns a non-empty page 1 after a resetKey-driven reset', () => { + const data = buildData(50); + const {result, rerender} = renderHook(({resetKey}) => usePaginatedData(data, 10, {resetKey}), {initialProps: {resetKey: 'a'}}); + + act(() => result.current.loadMore()); + + rerender({resetKey: 'b'}); + + expect(result.current.paginatedData).toEqual(data.slice(0, 10)); + expect(result.current.paginatedData.length).toBeGreaterThan(0); + }); + }); + + describe('skipPagination behavior', () => { + it('returns the full data array as paginatedData when skipPagination is true', () => { + const data = buildData(50); + const {result} = renderHook(() => usePaginatedData(data, 10, {skipPagination: true})); + + expect(result.current.paginatedData).toEqual(data); + }); + + it('reports hasMore=false when skipPagination is true', () => { + const {result} = renderHook(() => usePaginatedData(buildData(50), 10, {skipPagination: true})); + + expect(result.current.hasMore).toBe(false); + }); + + it('makes loadMore a no-op when skipPagination is true', () => { + const data = buildData(50); + const {result, rerender} = renderHook(({skipPagination}) => usePaginatedData(data, 10, {skipPagination}), {initialProps: {skipPagination: false}}); + + act(() => result.current.loadMore()); + expect(result.current.paginatedData).toEqual(data.slice(0, 20)); + + rerender({skipPagination: true}); + expect(result.current.paginatedData).toEqual(data); + + act(() => result.current.loadMore()); + + rerender({skipPagination: false}); + expect(result.current.paginatedData).toEqual(data.slice(0, 20)); + }); + + it('preserves currentPage when skipPagination toggles on then off (resetKey unchanged)', () => { + const data = buildData(50); + const {result, rerender} = renderHook(({skipPagination}) => usePaginatedData(data, 10, {skipPagination}), {initialProps: {skipPagination: false}}); + + act(() => result.current.loadMore()); + expect(result.current.paginatedData).toEqual(data.slice(0, 20)); + + rerender({skipPagination: true}); + expect(result.current.paginatedData).toEqual(data); + + rerender({skipPagination: false}); + expect(result.current.paginatedData).toEqual(data.slice(0, 20)); + }); + + it('does not block a new resetKey from registering while skipPagination is true', () => { + const data = buildData(50); + const {result, rerender} = renderHook(({skipPagination, resetKey}) => usePaginatedData(data, 10, {resetKey, skipPagination}), { + initialProps: {skipPagination: false, resetKey: 'a'}, + }); + + act(() => result.current.loadMore()); + expect(result.current.paginatedData).toEqual(data.slice(0, 20)); + + rerender({skipPagination: true, resetKey: 'b'}); + expect(result.current.paginatedData).toEqual(data); + + rerender({skipPagination: false, resetKey: 'a'}); + + expect(result.current.paginatedData).toEqual(data.slice(0, 10)); + expect(result.current.hasMore).toBe(true); + }); + }); + + describe('pageSize edge cases', () => { + it('returns an empty array with hasMore=false and a no-op loadMore when pageSize=0', () => { + const data = buildData(50); + const {result} = renderHook(() => usePaginatedData(data, 0)); + + expect(result.current.paginatedData).toEqual([]); + expect(result.current.hasMore).toBe(false); + + act(() => result.current.loadMore()); + + expect(result.current.paginatedData).toEqual([]); + expect(result.current.hasMore).toBe(false); + }); + + it('treats negative pageSize the same as pageSize=0', () => { + const data = buildData(50); + const {result} = renderHook(() => usePaginatedData(data, -5)); + + expect(result.current.paginatedData).toEqual([]); + expect(result.current.hasMore).toBe(false); + + act(() => result.current.loadMore()); + + expect(result.current.paginatedData).toEqual([]); + expect(result.current.hasMore).toBe(false); + }); + }); +});