diff --git a/src/components/Search/FilterComponents/InSelector.tsx b/src/components/Search/FilterComponents/InSelector.tsx index 30242c8e2c64..26072c9f6de8 100644 --- a/src/components/Search/FilterComponents/InSelector.tsx +++ b/src/components/Search/FilterComponents/InSelector.tsx @@ -7,6 +7,7 @@ import type {TextInputOptions} from '@components/SelectionList/types'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebouncedState from '@hooks/useDebouncedState'; import useFilteredOptions from '@hooks/useFilteredOptions'; +import useInitialValue from '@hooks/useInitialValue'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; @@ -16,7 +17,7 @@ import useSortedActions from '@hooks/useSortedActions'; import {searchInServer} from '@libs/actions/Report'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {createOptionFromReport, filterAndOrderOptions, formatSectionsFromSearchTerm, getAlternateText, getSearchOptions} from '@libs/OptionsListUtils'; +import {createOptionFromReport, filterAndOrderOptions, getAlternateText, getSearchOptions} from '@libs/OptionsListUtils'; import type {Option, OptionWithKey, SelectionListSections} from '@libs/OptionsListUtils/types'; import type {OptionData} from '@libs/ReportUtils'; import {expensifyLoginsSelector} from '@libs/UserUtils'; @@ -74,24 +75,27 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); - const selectedOptions: OptionData[] = value.map((id) => { + const buildReportOption = (id: string, isSelected: boolean): OptionData => { const privateIsArchived = privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${id}`]; const reportData = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${id}`]; const reportPolicy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${reportData?.policyID}`]; - const report = getSelectedOptionData( - createOptionFromReport( - {...reportData, reportID: id}, - personalDetails, - privateIsArchived, - reportPolicy, - sortedActions, - reportAttributesDerived, - undefined, - undefined, - undefined, - isTrackIntentUser, + const report = { + ...getSelectedOptionData( + createOptionFromReport( + {...reportData, reportID: id}, + personalDetails, + privateIsArchived, + reportPolicy, + sortedActions, + reportAttributesDerived, + undefined, + undefined, + undefined, + isTrackIntentUser, + ), ), - ); + isSelected, + }; const isReportArchived = !!privateIsArchived; const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${reportData?.policyID}`]; const reportPolicyTags = policyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(report?.policyID)}`]; @@ -101,7 +105,14 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle {isReportArchived, personalDetails, policy, reportAttributesDerived, policyTags: reportPolicyTags, conciergeReportID, isTrackIntentUser}, ); return {...report, alternateText}; - }); + }; + + const selectedOptions: OptionData[] = value.map((id) => buildReportOption(id, true)); + + // Snapshot the reports that were already selected when the filter first opened. On a long list these stay + // pinned to the top so they're easy to find, but items toggled during the current session are NOT re-pinned: + // they keep their position so selecting doesn't scroll/jump the list. + const initialValue = useInitialValue(() => value); const defaultOptions = isLoading || !ready || !options @@ -130,25 +141,30 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle const sections: SelectionListSections = []; if (!isLoading) { - const formattedResults = formatSectionsFromSearchTerm( - cleanSearchTerm, - selectedOptions, - chatOptions.recentReports, - chatOptions.personalDetails, - privateIsArchivedMap, - currentUserAccountID, - allPolicies, - personalDetails, - false, - undefined, - reportAttributesDerived, - ); + // Only float the initially-selected reports to the top of a long list. Gate on the *unfiltered* list size so the + // decision doesn't flip as the user types, and key the pinned section on the snapshot (instead of the live + // `value`) so items toggled during this session stay put. + const shouldMoveSelectedToTop = defaultOptions.recentReports.length >= CONST.STANDARD_LIST_ITEM_LIMIT; + const pinnedReportIDs = shouldMoveSelectedToTop ? initialValue : []; + const pinnedReportIDSet = new Set(pinnedReportIDs); + + // Keep the pinned reports at the top whether or not a search value is entered, for consistency. When a term is + // typed, only surface the pinned reports that still match the filtered results. + const matchedReportIDs = new Set(chatOptions.recentReports.map((report) => report.reportID)); + const visiblePinnedReportIDs = cleanSearchTerm === '' ? pinnedReportIDs : pinnedReportIDs.filter((id) => matchedReportIDs.has(id)); + const pinnedSelectedOptions = visiblePinnedReportIDs.map((id) => buildReportOption(id, value.includes(id))); - sections.push(formattedResults.section); + sections.push({ + title: undefined, + sectionIndex: 0, + data: pinnedSelectedOptions, + }); - const visibleReportsWhenSearchTermNonEmpty = chatOptions.recentReports.map((report) => (value.includes(report.reportID) ? getSelectedOptionData(report) : report)); - const visibleReportsWhenSearchTermEmpty = chatOptions.recentReports.filter((report) => !value.includes(report.reportID)); - const reportsFiltered = cleanSearchTerm === '' ? visibleReportsWhenSearchTermEmpty : visibleReportsWhenSearchTermNonEmpty; + // Drop the pinned reports from the main section (they already appear in the top section above) and mark the + // remaining reports selected in place based on the live `value`, so the checkmark toggles without reordering. + const reportsFiltered = chatOptions.recentReports + .filter((report) => !pinnedReportIDSet.has(report.reportID)) + .map((report) => (value.includes(report.reportID) ? getSelectedOptionData(report) : report)); sections.push({ data: reportsFiltered, @@ -205,6 +221,9 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle onSelectRow={handleParticipantSelection} ListItem={InviteMemberListItem} canSelectMultiple + shouldClearInputOnSelect={false} + shouldUpdateFocusedIndex + shouldPreventAutoScrollOnSelect shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} textInputOptions={textInputOptions} isLoadingNewOptions={isLoadingNewOptions} diff --git a/tests/ui/InSelectorTest.tsx b/tests/ui/InSelectorTest.tsx new file mode 100644 index 000000000000..cad034175c5d --- /dev/null +++ b/tests/ui/InSelectorTest.tsx @@ -0,0 +1,150 @@ +import {render} from '@testing-library/react-native'; + +import InSelector from '@components/Search/FilterComponents/InSelector'; +import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +// The list of reports returned by the (mocked) options pipeline. Tests mutate these to switch between a +// long list (pinning enabled) and a short list (pinning disabled), and to simulate a search filtering the list. +// `mockDefaultReports` is the unfiltered base list (drives the "is the list long enough to pin" gate); `mockRecentReports` +// is what the filter returns for the current search term. +let mockRecentReports: Array<{reportID: string; keyForList: string; text: string}> = []; +let mockDefaultReports: Array<{reportID: string; keyForList: string; text: string}> = []; +let mockSearchTerm = ''; + +jest.mock('@components/SelectionList/SelectionListWithSections', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/InviteMemberListItem', () => jest.fn(() => null)); +jest.mock('@components/Search/FilterComponents/ListFilterViewWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); + +jest.mock('@components/OnyxListItemProvider', () => ({usePersonalDetails: jest.fn(() => ({}))})); +jest.mock('@hooks/useOnyx', () => jest.fn(() => [undefined])); +jest.mock('@hooks/useDebouncedState', () => jest.fn(() => [mockSearchTerm, mockSearchTerm, jest.fn()])); +jest.mock('@hooks/useFilteredOptions', () => jest.fn(() => ({options: {recentReports: [], personalDetails: []}, isLoading: false}))); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({email: 'me@expensify.com', accountID: 999}))); +jest.mock('@hooks/useSortedActions', () => jest.fn(() => ({}))); +jest.mock('@hooks/useReportAttributes', () => jest.fn(() => ({}))); +jest.mock('@hooks/usePrivateIsArchivedMap', () => jest.fn(() => ({}))); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => key, + })), +); +jest.mock('@libs/actions/Report', () => ({searchInServer: jest.fn()})); +jest.mock('@libs/DeviceCapabilities', () => ({canUseTouchScreen: () => false})); +jest.mock('@libs/OptionsListUtils', () => ({ + // Build a minimal option object from the report id the component passes in. + createOptionFromReport: (report: {reportID: string}) => ({reportID: report.reportID, keyForList: report.reportID, text: `Report ${report.reportID}`}), + getSearchOptions: () => ({options: {recentReports: mockDefaultReports, personalDetails: []}}), + filterAndOrderOptions: () => ({recentReports: mockRecentReports, personalDetails: []}), + getAlternateText: () => '', +})); + +describe('InSelector', () => { + const mockedSelectionList = jest.mocked(SelectionListWithSections); + + const buildRecentReports = (count: number) => + Array.from({length: count}, (_, index) => ({ + reportID: `r${index}`, + keyForList: `r${index}`, + text: `Report ${index}`, + })); + + const getSections = () => mockedSelectionList.mock.lastCall?.[0].sections ?? []; + + beforeEach(() => { + mockedSelectionList.mockClear(); + mockSearchTerm = ''; + // A long list so the "move selected to top" behavior is enabled. + mockRecentReports = buildRecentReports(CONST.STANDARD_LIST_ITEM_LIMIT + 2); + mockDefaultReports = mockRecentReports; + }); + + it('floats the initially-selected report to the top of a long list', () => { + render( + , + ); + + const sections = getSections(); + // Top section holds only the pre-selected report. + expect(sections.at(0)?.data.map((item) => item.keyForList)).toEqual(['r10']); + // Main section keeps every other report in its natural order, with the pinned one removed. + expect(sections.at(1)?.data.map((item) => item.keyForList)).not.toContain('r10'); + expect(sections.at(1)?.data).toHaveLength(CONST.STANDARD_LIST_ITEM_LIMIT + 1); + }); + + it('keeps a report in place when it is toggled after first render (does not jump to the top)', () => { + const {rerender} = render( + , + ); + + // Simulate the user toggling another report on: the parent re-renders the filter with the updated value. + rerender( + , + ); + + const sections = getSections(); + // Only the originally pre-selected report stays pinned at the top. + expect(sections.at(0)?.data.map((item) => item.keyForList)).toEqual(['r10']); + + // The newly toggled report is marked selected but stays in its natural position (index 3) instead of jumping up. + const mainSection = sections.at(1)?.data ?? []; + expect(mainSection.findIndex((item) => item.keyForList === 'r3')).toBe(3); + expect(mainSection.find((item) => item.keyForList === 'r3')?.isSelected).toBe(true); + }); + + it('keeps the initially-selected report pinned at the top while searching, and drops non-matching pinned reports', () => { + // Simulate the user typing a search term that narrows the list to a few matching reports (r3, r10, r11). + // r10 is pinned and matches -> it stays at the top. r5 is pinned but doesn't match -> it is dropped. + mockSearchTerm = 'report'; + mockRecentReports = [ + {reportID: 'r3', keyForList: 'r3', text: 'Report 3'}, + {reportID: 'r10', keyForList: 'r10', text: 'Report 10'}, + {reportID: 'r11', keyForList: 'r11', text: 'Report 11'}, + ]; + + render( + , + ); + + const sections = getSections(); + // The pinned report that still matches the search stays at the top; the non-matching pinned report is dropped. + expect(sections.at(0)?.data.map((item) => item.keyForList)).toEqual(['r10']); + // The main section shows the remaining matches with the pinned one removed (no duplicate). + expect(sections.at(1)?.data.map((item) => item.keyForList)).toEqual(['r3', 'r11']); + }); + + it('does not pin selected reports to the top of a short list', () => { + mockRecentReports = buildRecentReports(5); + mockDefaultReports = mockRecentReports; + + render( + , + ); + + const sections = getSections(); + // No pinned section for a short list. + expect(sections.at(0)?.data).toHaveLength(0); + // Every report stays in place; the selected one is simply marked in position. + const mainSection = sections.at(1)?.data ?? []; + expect(mainSection.map((item) => item.keyForList)).toEqual(['r0', 'r1', 'r2', 'r3', 'r4']); + expect(mainSection.find((item) => item.keyForList === 'r2')?.isSelected).toBe(true); + }); +});