diff --git a/src/components/Search/SearchFiltersChatsSelector.tsx b/src/components/Search/SearchFiltersChatsSelector.tsx index f3dd7035b2b1..edff38c5f22a 100644 --- a/src/components/Search/SearchFiltersChatsSelector.tsx +++ b/src/components/Search/SearchFiltersChatsSelector.tsx @@ -3,9 +3,11 @@ import React, {useEffect, useState} from 'react'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import InviteMemberListItem from '@components/SelectionList/ListItem/InviteMemberListItem'; import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; +import type {Section} from '@components/SelectionList/SelectionListWithSections/types'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebouncedState from '@hooks/useDebouncedState'; import useFilteredOptions from '@hooks/useFilteredOptions'; +import useFrozenPreSelection from '@hooks/useFrozenPreSelection'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; @@ -14,9 +16,9 @@ import useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionS import useSortedActions from '@hooks/useSortedActions'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {createOptionFromReport, filterAndOrderOptions, formatSectionsFromSearchTerm, getAlternateText, getSearchOptions} from '@libs/OptionsListUtils'; +import {createOptionFromReport, filterAndOrderOptions, filterReports, getAlternateText, getSearchOptions} from '@libs/OptionsListUtils'; import type {Option} from '@libs/OptionsListUtils'; -import type {OptionWithKey, SelectionListSections} from '@libs/OptionsListUtils/types'; +import type {OptionWithKey, SearchOptionData} from '@libs/OptionsListUtils/types'; import type {OptionData} from '@libs/ReportUtils'; import Navigation from '@navigation/Navigation'; import {searchInServer} from '@userActions/Report'; @@ -105,35 +107,27 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, }); - const sections: SelectionListSections = []; + const selectedReportIDsSet = new Set(selectedReportIDs); + // Mark selected rows in place so the checkmark moves with the toggle without reordering the list. + const recentReportsWithSelection = chatOptions.recentReports.map((report) => (selectedReportIDsSet.has(report.reportID) ? getSelectedOptionData(report) : report)); + // Selected reports that don't show up in Recents — surface them but respect the search term. + const visibleReportIDsSet = new Set(chatOptions.recentReports.map((report) => report.reportID)); + const reportIDsMatchingSearch = cleanSearchTerm === '' ? null : new Set(filterReports(selectedOptions as SearchOptionData[], [cleanSearchTerm]).map((report) => report.reportID)); + const matchesSearchTerm = (report: OptionData) => reportIDsMatchingSearch === null || reportIDsMatchingSearch.has(report.reportID); + const extraSelectedReports = selectedOptions.filter((report) => !visibleReportIDsSet.has(report.reportID) && matchesSearchTerm(report)); + + const baseSections: Array> = []; if (!isLoading) { - const formattedResults = formatSectionsFromSearchTerm( - cleanSearchTerm, - selectedOptions, - chatOptions.recentReports, - chatOptions.personalDetails, - privateIsArchivedMap, - currentUserAccountID, - allPolicies, - personalDetails, - false, - undefined, - reportAttributesDerived, - ); - - sections.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; - - sections.push({ - data: reportsFiltered, - sectionIndex: 1, - }); + if (extraSelectedReports.length > 0) { + baseSections.push({data: extraSelectedReports, sectionIndex: 1}); + } + baseSections.push({data: recentReportsWithSelection, sectionIndex: 2}); } - const noResultsFound = didScreenTransitionEnd && sections.at(0)?.data.length === 0 && sections.at(1)?.data.length === 0; + + const sections = useFrozenPreSelection(baseSections, {initialSelectedValues: initialReportIDs, canCapture: !isLoading}); + + const noResultsFound = didScreenTransitionEnd && !isLoading && sections.every((section) => section.data.length === 0); const headerMessage = noResultsFound ? translate('common.noResultsFound') : undefined; useEffect(() => { @@ -191,6 +185,9 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen footerContent={footerContent} canSelectMultiple shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} + shouldUpdateFocusedIndex + shouldPreventAutoScrollOnSelect + shouldClearInputOnSelect={false} textInputOptions={textInputOptions} isLoadingNewOptions={isLoadingNewOptions} shouldShowLoadingPlaceholder={shouldShowLoadingPlaceholder} diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index 7486fd797887..73481b5ecf49 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -1,16 +1,15 @@ -import React, {useCallback, useEffect, useMemo} from 'react'; +import React, {useEffect, useState} from 'react'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import UserSelectionListItem from '@components/SelectionList/ListItem/UserSelectionListItem'; import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useFrozenPreSelection from '@hooks/useFrozenPreSelection'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; -import useReportAttributes from '@hooks/useReportAttributes'; import useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionStatus'; import useSearchSelector from '@hooks/useSearchSelector'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; -import {formatSectionsFromSearchTerm, getFilteredRecentAttendees, getParticipantsOption} from '@libs/OptionsListUtils'; +import {getFilteredRecentAttendees, getParticipantsOption} from '@libs/OptionsListUtils'; import {doesPersonalDetailMatchSearchTerm} from '@libs/OptionsListUtils/searchMatchUtils'; import type {OptionData} from '@libs/ReportUtils'; import {getDisplayNameForParticipant} from '@libs/ReportUtils'; @@ -21,9 +20,7 @@ import ROUTES from '@src/ROUTES'; import type {Attendee} from '@src/types/onyx/IOU'; import SearchFilterPageFooterButtons from './SearchFilterPageFooterButtons'; -/** - * Creates an OptionData object from a name-only attendee (attendee without a real accountID in personalDetails) - */ +// Builds an OptionData row for a name-only attendee — one without a real accountID in personalDetails. function getOptionDataFromAttendee(attendee: Attendee): OptionData { return { text: attendee.displayName, @@ -63,19 +60,13 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, const personalDetails = usePersonalDetails(); const {didScreenTransitionEnd} = useScreenWrapperTransitionStatus(); const [isSearchingForReports] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS); - const reportAttributesDerived = useReportAttributes(); - const privateIsArchivedMap = usePrivateIsArchivedMap(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountID = currentUserPersonalDetails.accountID; const currentUserEmail = currentUserPersonalDetails.email ?? ''; const [recentAttendees] = useOnyx(ONYXKEYS.NVP_RECENT_ATTENDEES); - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - // Transform raw recentAttendees into Option[] format for use with getValidOptions (only for attendee filter) - const recentAttendeeLists = useMemo( - () => (shouldAllowNameOnlyOptions ? getFilteredRecentAttendees(personalDetails, [], recentAttendees ?? [], currentUserEmail, currentUserAccountID) : []), - [personalDetails, recentAttendees, currentUserEmail, currentUserAccountID, shouldAllowNameOnlyOptions], - ); + // Only the attendee filter feeds recentAttendees into the picker; other filters use empty list. + const recentAttendeeLists = shouldAllowNameOnlyOptions ? getFilteredRecentAttendees(personalDetails, [], recentAttendees ?? [], currentUserEmail, currentUserAccountID) : []; const {searchTerm, debouncedSearchTerm, setSearchTerm, availableOptions, selectedOptions, setSelectedOptions, toggleSelection, areOptionsInitialized, onListEndReached} = useSearchSelector({ @@ -89,147 +80,114 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, includeCurrentUser: true, recentAttendees: recentAttendeeLists, shouldAllowNameOnlyOptions, + shouldKeepSelectedInAvailableOptions: true, }); - const {sections, headerMessage} = useMemo(() => { - const newSections = []; - if (!areOptionsInitialized) { - return {sections: [], headerMessage: undefined}; - } - - const chatOptions = {...availableOptions}; - const currentUserOption = chatOptions.currentUserOption; - - // Ensure current user is not in personalDetails when they should be excluded - if (currentUserOption) { - chatOptions.personalDetails = chatOptions.personalDetails.filter((detail) => detail.accountID !== currentUserOption.accountID); - } + // Flip to true once the hydration effect runs. Without this, a stale `initialAccountIDs` could let + // the pinning snapshot fire on the first toggled row and pin it by mistake. + const [hasAttemptedHydration, setHasAttemptedHydration] = useState(initialAccountIDs.length === 0); + + const trimmedSearchTerm = debouncedSearchTerm.trim().toLowerCase(); + const matchesSearchTerm = (option: OptionData) => !trimmedSearchTerm || doesPersonalDetailMatchSearchTerm(option, currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID, trimmedSearchTerm); + + const currentUserOption = areOptionsInitialized ? availableOptions.currentUserOption : null; + const isCurrentUserSelected = !!currentUserAccountID && selectedOptions.some((option) => option.accountID === currentUserAccountID); + + // Hide the current user from Recents / Contacts — they get their own row (or get pinned at the top). + const recentReportsWithoutCurrentUser = + areOptionsInitialized && currentUserOption?.accountID + ? availableOptions.recentReports.filter((report) => report.accountID !== currentUserOption.accountID) + : (availableOptions.recentReports ?? []); + const personalDetailsWithoutCurrentUser = + areOptionsInitialized && currentUserOption?.accountID + ? availableOptions.personalDetails.filter((detail) => detail.accountID !== currentUserOption.accountID) + : (availableOptions.personalDetails ?? []); + + // Selected items not visible in Recents / Contacts and not the current user — show them in a section above, but only if they match the search term. + // Dedupe by accountID for real users and by login for name-only attendees (which all share DEFAULT_NUMBER_ID). + const visibleAccountIDs = new Set( + [...personalDetailsWithoutCurrentUser, ...recentReportsWithoutCurrentUser].map((option) => option.accountID).filter((id): id is number => !!id && id !== CONST.DEFAULT_NUMBER_ID), + ); + const visibleLogins = new Set([...personalDetailsWithoutCurrentUser.map((detail) => detail.login), ...recentReportsWithoutCurrentUser.map((report) => report.login)].filter(Boolean)); + const extraSelectedOptions = selectedOptions.filter( + (option) => + option.accountID !== currentUserAccountID && + !(!!option.accountID && option.accountID !== CONST.DEFAULT_NUMBER_ID && visibleAccountIDs.has(option.accountID)) && + !(!!option.login && visibleLogins.has(option.login)) && + matchesSearchTerm(option), + ); - const formattedResults = formatSectionsFromSearchTerm( - debouncedSearchTerm.trim().toLowerCase(), - selectedOptions, - chatOptions.recentReports, - chatOptions.personalDetails, - privateIsArchivedMap, - currentUserAccountID, - allPolicies, - personalDetails, - true, - undefined, - reportAttributesDerived, - ); - const selectedCurrentUser = formattedResults.section.data.find((option) => option.accountID === currentUserAccountID); - - // 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); + // Current user row with the "(you)" suffix. Falls back to personalDetails when pagination drops them from availableOptions. + let currentUserRow: OptionData | undefined; + if (areOptionsInitialized) { + let candidate = currentUserOption ?? undefined; + if (!candidate && currentUserAccountID && personalDetails?.[currentUserAccountID]) { + candidate = getParticipantsOption(personalDetails[currentUserAccountID], personalDetails) as OptionData; } - - // If the current user is not selected, add them to the top of the list - // Falls back to creating from personal details to handle pagination edge cases - if (!selectedCurrentUser) { - let currentUserOptionToShow = chatOptions.currentUserOption; - const currentUserDetails = currentUserAccountID ? personalDetails?.[currentUserAccountID] : undefined; - if (!currentUserOptionToShow && currentUserAccountID && currentUserDetails) { - const candidateOption = getParticipantsOption(currentUserDetails, personalDetails) as OptionData; - const trimmedSearchTerm = debouncedSearchTerm.trim().toLowerCase(); - if (!trimmedSearchTerm || doesPersonalDetailMatchSearchTerm(candidateOption, currentUserAccountID, trimmedSearchTerm)) { - currentUserOptionToShow = candidateOption; - } - } - - if (currentUserOptionToShow) { - const formattedName = getDisplayNameForParticipant({ - accountID: currentUserOptionToShow.accountID, + if (candidate && matchesSearchTerm(candidate)) { + currentUserRow = { + ...candidate, + text: getDisplayNameForParticipant({ + accountID: candidate.accountID, shouldAddCurrentUserPostfix: true, personalDetailsData: personalDetails, formatPhoneNumber, - }); - currentUserOptionToShow.text = formattedName; - - newSections.push({ - title: '', - data: [currentUserOptionToShow], - sectionIndex: 0, - }); + }), + isSelected: isCurrentUserSelected, + }; + } + } + + const baseSections: Array<{title: string; data: OptionData[]; sectionIndex: number}> = []; + if (areOptionsInitialized) { + if (extraSelectedOptions.length > 0) { + baseSections.push({title: '', data: extraSelectedOptions, sectionIndex: 1}); + } + if (currentUserRow) { + baseSections.push({title: '', data: [currentUserRow], sectionIndex: 2}); + } + baseSections.push({title: '', data: recentReportsWithoutCurrentUser, sectionIndex: 3}); + baseSections.push({title: '', data: personalDetailsWithoutCurrentUser, sectionIndex: 4}); + } + + // `initialAccountIDs` holds accountIDs (or displayName / login for name-only attendees), but for any + // contact with a 1:1 DM, the default `keyForList` is the reportID. Use an explicit getKey so the hook + // matches on the right identifier. + const getKey = (option: OptionData) => { + if (shouldAllowNameOnlyOptions) { + if (option.accountID && option.accountID !== CONST.DEFAULT_NUMBER_ID && personalDetails?.[option.accountID]) { + return option.accountID.toString(); } + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- need || to handle empty string + return option.displayName || option.login; } + return option.accountID ? option.accountID.toString() : undefined; + }; - newSections.push({ - ...formattedResults.section, - data: formattedResults.section.data.map((option) => ({...option, isSelected: true})) as OptionData[], - }); + // The list is lazy-loaded, so pinned rows aren't always present in baseSections — the hook keeps them + // from the snapshot. Pass `matchesSearchTerm` so the pinned section still respects the search term. + const sections = useFrozenPreSelection(baseSections, { + initialSelectedValues: initialAccountIDs, + // Wait for hydration so a toggled row isn't mistaken for a pre-selection. + canCapture: areOptionsInitialized && hasAttemptedHydration, + shouldRenderPinned: matchesSearchTerm, + getKey, + }); - // Filter current user from recentReports to avoid duplicate with currentUserOption section - // Only filter if both the report and currentUserOption have valid accountIDs to avoid - // accidentally filtering out name-only attendees (which have accountID: undefined) - const filteredRecentReports = chatOptions.recentReports.filter( - (report) => !report.accountID || !chatOptions.currentUserOption?.accountID || report.accountID !== chatOptions.currentUserOption.accountID, - ); - - newSections.push({ - title: '', - data: filteredRecentReports, - sectionIndex: 1, - }); + const noResultsFound = areOptionsInitialized && sections.every((section) => section.data.length === 0); + const headerMessage = noResultsFound ? translate('common.noResultsFound') : undefined; - newSections.push({ - title: '', - data: chatOptions.personalDetails, - sectionIndex: 2, - }); - - const noResultsFound = chatOptions.personalDetails.length === 0 && chatOptions.recentReports.length === 0 && !chatOptions.currentUserOption; - const message = noResultsFound ? translate('common.noResultsFound') : undefined; - - return { - sections: newSections, - headerMessage: message, - }; - }, [ - areOptionsInitialized, - availableOptions, - debouncedSearchTerm, - selectedOptions, - privateIsArchivedMap, - currentUserAccountID, - personalDetails, - allPolicies, - reportAttributesDerived, - translate, - formatPhoneNumber, - ]); - - const resetChanges = useCallback(() => { + const resetChanges = () => { setSelectedOptions([]); - }, [setSelectedOptions]); - - const applyChanges = useCallback(() => { - let selectedIdentifiers: string[]; - - if (shouldAllowNameOnlyOptions) { - selectedIdentifiers = selectedOptions - .map((option) => { - // For real users (with valid accountID in personalDetails), use accountID - if (option.accountID && option.accountID !== CONST.DEFAULT_NUMBER_ID && personalDetails?.[option.accountID]) { - return option.accountID.toString(); - } - - // For name-only attendees, use displayName or login as identifier - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- need || to handle empty string - return option.displayName || option.login; - }) - .filter(Boolean) as string[]; - } else { - selectedIdentifiers = selectedOptions.map((option) => (option.accountID ? option.accountID.toString() : undefined)).filter(Boolean) as string[]; - } + }; + const applyChanges = () => { + const selectedIdentifiers = selectedOptions.map(getKey).filter(Boolean) as string[]; onFiltersUpdate(selectedIdentifiers); Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS.getRoute()); - }, [onFiltersUpdate, selectedOptions, personalDetails, shouldAllowNameOnlyOptions]); + }; - // This effect handles setting initial selectedOptions based on accountIDs (or displayNames for attendee filter) + // Hydrate selectedOptions from initialAccountIDs (or displayNames for the attendee filter). useEffect(() => { if (!initialAccountIDs || initialAccountIDs.length === 0 || !personalDetails) { return; @@ -240,7 +198,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, if (shouldAllowNameOnlyOptions) { preSelectedOptions = initialAccountIDs .map((identifier) => { - // First, try to look up as accountID in personalDetails + // Look up the identifier as an accountID first. const participant = personalDetails[identifier]; if (participant) { const optionData = { @@ -250,15 +208,14 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, return optionData as OptionData; } - // If not found in personalDetails, this might be a name-only attendee - // Search in recentAttendees by displayName or email + // Fall back to a name-only attendee match (by displayName or email). const attendee = recentAttendees?.find((recentAttendee) => recentAttendee.displayName === identifier || recentAttendee.email === identifier); if (attendee) { return getOptionDataFromAttendee(attendee); } - // Fallback: construct a minimal option from the identifier string to preserve - // name-only filters across sessions (e.g., after cache clear or on another device) + // Last resort: build a minimal option from the identifier so name-only filters survive + // a cache clear or a switch to another device. return { text: identifier, alternateText: identifier, @@ -290,38 +247,31 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, } setSelectedOptions(preSelectedOptions); + // eslint-disable-next-line react-hooks/set-state-in-effect -- one-shot flag the pinning snapshot waits on; derivable state doesn't work because hydration can resolve to an empty array. + setHasAttemptedHydration(true); // eslint-disable-next-line react-hooks/exhaustive-deps -- this should react only to changes in form data }, [initialAccountIDs, personalDetails, recentAttendees, shouldAllowNameOnlyOptions]); - const handleParticipantSelection = useCallback( - (option: OptionData) => { - toggleSelection(option); - }, - [toggleSelection], - ); + const handleParticipantSelection = (option: OptionData) => { + toggleSelection(option); + }; - const footerContent = useMemo( - () => ( - - ), - [applyChanges, resetChanges], + const footerContent = ( + ); const isLoadingNewOptions = !!isSearchingForReports; const shouldShowLoadingPlaceholder = !didScreenTransitionEnd || !areOptionsInitialized || !initialAccountIDs || !personalDetails; - const textInputOptions = useMemo( - () => ({ - value: searchTerm, - label: translate('selectionList.nameEmailOrPhoneNumber'), - onChangeText: setSearchTerm, - headerMessage, - }), - [searchTerm, translate, setSearchTerm, headerMessage], - ); + const textInputOptions = { + value: searchTerm, + label: translate('selectionList.nameEmailOrPhoneNumber'), + onChangeText: setSearchTerm, + headerMessage, + }; return ( = { + /** Item identifiers to pin on first capture. Matched against `getKey(item)`, or `item.keyForList` if `getKey` is omitted. */ + initialSelectedValues: string[]; + + /** Set to `true` once the sections are ready to inspect — capture fires on the next render. */ + canCapture: boolean; + + /** Optional filter for pinned rows (e.g. to honor a search term). Needed when pinned rows may not appear in the input `sections`. */ + shouldRenderPinned?: (item: TItem) => boolean; + + /** Optional identity extractor. Defaults to `item.keyForList`. Override when the caller's identifier doesn't match `keyForList`. */ + getKey?: (item: TItem) => string | undefined; +}; + +/** + * Pins pre-selected rows to a top section on first ready render, then locks them in place. + * Toggling a pinned row updates `isSelected` without moving it. + * Returns `sections` unchanged while not ready, or when the list is shorter than `STANDARD_LIST_ITEM_LIMIT`. + */ +function useFrozenPreSelection(sections: Array>, options: UseFrozenPreSelectionOptions): Array> { + const {initialSelectedValues, canCapture, shouldRenderPinned, getKey} = options; + const resolveKey = (item: TItem) => (getKey ? getKey(item) : item.keyForList); + + // null = not captured yet; empty Map = captured but the list was too short to pin. + const [frozenData, setFrozenData] = useState | null>(null); + + if (frozenData === null && canCapture) { + const totalCount = sections.reduce((sum, section) => sum + section.data.length, 0); + if (totalCount < CONST.STANDARD_LIST_ITEM_LIMIT) { + setFrozenData(new Map()); + } else { + const captured = new Map(); + for (const section of sections) { + for (const item of section.data) { + const key = resolveKey(item); + if (key && initialSelectedValues.includes(key)) { + captured.set(key, {...item, isSelected: false}); + } + } + } + setFrozenData(captured); + } + } + + if (!frozenData || frozenData.size === 0) { + return sections; + } + + const frozenSectionData = new Map(shouldRenderPinned ? frozenData : undefined); + const filteredSections = sections.map((section) => { + const data: TItem[] = []; + for (const item of section.data) { + const key = resolveKey(item); + if (key && frozenData.has(key)) { + frozenSectionData.set(key, {...item, isSelected: item.isSelected}); + } else { + data.push(item); + } + } + return {...section, data}; + }); + + if (shouldRenderPinned) { + for (const [key, value] of frozenSectionData) { + if (!shouldRenderPinned(value)) { + frozenSectionData.delete(key); + } + } + } + + if (frozenSectionData.size === 0) { + return filteredSections; + } + + return [{data: [...frozenSectionData.values()], sectionIndex: 0}, ...filteredSections]; +} + +export default useFrozenPreSelection; diff --git a/tests/unit/hooks/useFrozenPreSelection.test.ts b/tests/unit/hooks/useFrozenPreSelection.test.ts new file mode 100644 index 000000000000..52b8f3d6d32a --- /dev/null +++ b/tests/unit/hooks/useFrozenPreSelection.test.ts @@ -0,0 +1,198 @@ +import {renderHook} from '@testing-library/react-native'; +import useFrozenPreSelection from '@hooks/useFrozenPreSelection'; +import CONST from '@src/CONST'; + +type Item = { + keyForList: string; + text?: string; + isSelected?: boolean; +}; + +type Section = {data: Item[]; sectionIndex: number}; + +const padTo = (target: number, existing: Item[]): Section[] => { + const filler: Item[] = []; + for (let i = existing.length; i < target; i++) { + filler.push({keyForList: `filler-${i}`}); + } + return [{data: [...existing, ...filler], sectionIndex: 1}]; +}; + +const longList = CONST.STANDARD_LIST_ITEM_LIMIT; + +describe('useFrozenPreSelection', () => { + it('returns input unchanged while canCapture is false', () => { + const item: Item = {keyForList: '1'}; + const sections = padTo(longList, [item]); + const {result} = renderHook(() => useFrozenPreSelection(sections, {initialSelectedValues: ['1'], canCapture: false})); + + expect(result.current).toBe(sections); + }); + + it('pins pre-selected rows in a new top section and removes them from the input sections', () => { + const pinned: Item = {keyForList: '1', isSelected: true}; + const other: Item = {keyForList: '2'}; + const sections = padTo(longList, [pinned, other]); + const {result} = renderHook(() => useFrozenPreSelection(sections, {initialSelectedValues: ['1'], canCapture: true})); + + expect(result.current.at(0)).toEqual({data: [pinned], sectionIndex: 0}); + expect(result.current.at(1)?.data.some((item) => item.keyForList === '1')).toBe(false); + expect(result.current.at(1)?.data.some((item) => item.keyForList === '2')).toBe(true); + }); + + it('returns input unchanged when the combined item count is below the threshold', () => { + const item: Item = {keyForList: '1'}; + const sections: Section[] = [{data: [item], sectionIndex: 1}]; + const {result} = renderHook(() => useFrozenPreSelection(sections, {initialSelectedValues: ['1'], canCapture: true})); + + expect(result.current).toBe(sections); + }); + + it('locks the snapshot once captured, even when initialSelectedValues changes later', () => { + const captured: Item = {keyForList: '1'}; + const newcomer: Item = {keyForList: '99'}; + const initialSections = padTo(longList, [captured, newcomer]); + const {result, rerender} = renderHook( + ({sections, initialSelectedValues}: {sections: Section[]; initialSelectedValues: string[]}) => useFrozenPreSelection(sections, {initialSelectedValues, canCapture: true}), + {initialProps: {sections: initialSections, initialSelectedValues: ['1']}}, + ); + + expect(result.current.at(0)?.data).toEqual([captured]); + + rerender({sections: initialSections, initialSelectedValues: ['99']}); + + expect(result.current.at(0)?.data).toEqual([captured]); + }); + + it('preserves capture order across renders, using the live row from input sections', () => { + const a: Item = {keyForList: 'a'}; + const b: Item = {keyForList: 'b'}; + const initialSections = padTo(longList, [b, a]); + const {result, rerender} = renderHook(({sections}: {sections: Section[]}) => useFrozenPreSelection(sections, {initialSelectedValues: ['a', 'b'], canCapture: true}), { + initialProps: {sections: initialSections}, + }); + + // Capture order follows traversal of sections — b appears before a. + expect(result.current.at(0)?.data.map((item) => item.keyForList)).toEqual(['b', 'a']); + + // Live row replaces the captured row so toggles refresh in place. + const aLive: Item = {keyForList: 'a', isSelected: false}; + const bLive: Item = {keyForList: 'b', isSelected: true}; + rerender({sections: padTo(longList, [bLive, aLive])}); + + expect(result.current.at(0)?.data).toEqual([bLive, aLive]); + }); + + it('drops frozen items that are not present in any input section', () => { + const visible: Item = {keyForList: 'visible'}; + const sections = padTo(longList, [visible]); + const {result} = renderHook(() => useFrozenPreSelection(sections, {initialSelectedValues: ['missing', 'visible'], canCapture: true})); + + expect(result.current.at(0)?.data).toEqual([visible]); + }); + + it('returns input unchanged when no initialSelectedValues match anything in sections', () => { + const sections = padTo(longList, [{keyForList: 'visible'}]); + const {result} = renderHook(() => useFrozenPreSelection(sections, {initialSelectedValues: ['nothing-matches'], canCapture: true})); + + expect(result.current).toBe(sections); + }); + + it('returns input unchanged when initialSelectedValues is empty', () => { + const sections = padTo(longList, [{keyForList: 'visible'}]); + const {result} = renderHook(() => useFrozenPreSelection(sections, {initialSelectedValues: [], canCapture: true})); + + expect(result.current).toBe(sections); + }); + + it('with shouldRenderPinned, retains pinned rows even when they are absent from input sections', () => { + const pinned: Item = {keyForList: 'pinned'}; + const initialSections = padTo(longList, [pinned]); + const {result, rerender} = renderHook( + ({sections}: {sections: Section[]}) => useFrozenPreSelection(sections, {initialSelectedValues: ['pinned'], canCapture: true, shouldRenderPinned: () => true}), + { + initialProps: {sections: initialSections}, + }, + ); + + // While the row is in the input sections, the live copy surfaces (no isSelected on the live row). + expect(result.current.at(0)?.data).toEqual([pinned]); + + // Simulate a search filtering "pinned" out of the input sections — the captured copy (initialized + // with isSelected: false at capture time) should still surface. + rerender({sections: padTo(longList, [{keyForList: 'other'}])}); + + expect(result.current.at(0)?.data).toEqual([{keyForList: 'pinned', isSelected: false}]); + }); + + it('with shouldRenderPinned, drops pinned rows for which the predicate returns false', () => { + const matching: Item = {keyForList: 'match'}; + const filtered: Item = {keyForList: 'filtered'}; + const sections = padTo(longList, [matching, filtered]); + const {result} = renderHook(() => + useFrozenPreSelection(sections, { + initialSelectedValues: ['match', 'filtered'], + canCapture: true, + shouldRenderPinned: (item) => item.keyForList === 'match', + }), + ); + + expect(result.current.at(0)?.data).toEqual([matching]); + }); + + it('with shouldRenderPinned, omits the top section entirely when the predicate filters everything out', () => { + const filtered: Item = {keyForList: 'filtered'}; + const other: Item = {keyForList: 'other'}; + const sections = padTo(longList, [filtered, other]); + const {result} = renderHook(() => + useFrozenPreSelection(sections, { + initialSelectedValues: ['filtered'], + canCapture: true, + shouldRenderPinned: () => false, + }), + ); + + // No frozen section at the top, but "filtered" is still removed from the input section because it was captured. + expect(result.current.at(0)?.data.some((item) => item.keyForList === 'filtered')).toBe(false); + expect(result.current.at(0)?.data.some((item) => item.keyForList === 'other')).toBe(true); + }); + + it('with getKey, matches items against initialSelectedValues using the extractor instead of keyForList', () => { + type ItemWithAccountID = Item & {accountID: number}; + const pinned: ItemWithAccountID = {keyForList: 'report-1', accountID: 1}; + const other: ItemWithAccountID = {keyForList: 'report-2', accountID: 2}; + const filler: ItemWithAccountID[] = []; + for (let i = 0; i < longList - 2; i++) { + filler.push({keyForList: `report-${i + 3}`, accountID: i + 3}); + } + const sections: Array<{data: ItemWithAccountID[]; sectionIndex: number}> = [{data: [pinned, other, ...filler], sectionIndex: 1}]; + + const {result} = renderHook(() => + useFrozenPreSelection(sections, { + initialSelectedValues: ['1'], + canCapture: true, + getKey: (item) => item.accountID.toString(), + }), + ); + + expect(result.current.at(0)?.data).toEqual([pinned]); + expect(result.current.at(1)?.data.some((item) => item.keyForList === 'report-1')).toBe(false); + }); + + it('does not re-capture on later renders after capturing an empty snapshot below the threshold', () => { + const item: Item = {keyForList: '1'}; + const initialSections: Section[] = [{data: [item], sectionIndex: 1}]; + const {result, rerender} = renderHook(({sections}: {sections: Section[]}) => useFrozenPreSelection(sections, {initialSelectedValues: ['1'], canCapture: true}), { + initialProps: {sections: initialSections}, + }); + + // Below threshold on first render → empty snapshot captured. + expect(result.current).toBe(initialSections); + + // List grows past the threshold on a later render; snapshot stays empty, no pinning. + const grownSections = padTo(longList, [item]); + rerender({sections: grownSections}); + + expect(result.current).toBe(grownSections); + }); +});