Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 52 additions & 33 deletions src/components/Search/FilterComponents/InSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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)}`];
Expand All @@ -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
Expand Down Expand Up @@ -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 : [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep newly selected search-only reports visible

With pinnedReportIDs based only on initialValue, reports selected after opening the filter are rendered only if they remain in chatOptions.recentReports. useFilteredOptions switches back to the capped non-search report set when the search box is cleared, so a user can search for an older/non-recent chat, select it, clear the search, and that selected chat disappears from both sections even though value still contains its ID; the filter can then be applied with a hidden in: constraint or require re-searching to deselect it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's pre-existing bug happening on production, not caused by this PR so out of scope.

Repro step:

  1. Select any item from server results
  2. Observe in filter pill shows
  3. Save search
  4. Clear cache and restart
  5. Go to saved search
  6. Observe in filter pill doesn't show <--- bug
  7. Search for the selected item and get server results
  8. Observer selected item is not checked off <--- bug
  9. Go to another tab and back to saved search
  10. Observe in filter pill shows and selected item is checked off
Screen.Recording.2026-07-05.at.12.44.57.PM.mov

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,
Expand Down Expand Up @@ -205,6 +221,9 @@ function InSelector({value = [], selectionListTextInputStyle, selectionListStyle
onSelectRow={handleParticipantSelection}
ListItem={InviteMemberListItem}
canSelectMultiple
shouldClearInputOnSelect={false}
shouldUpdateFocusedIndex
shouldPreventAutoScrollOnSelect
shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()}
textInputOptions={textInputOptions}
isLoadingNewOptions={isLoadingNewOptions}
Expand Down
150 changes: 150 additions & 0 deletions tests/ui/InSelectorTest.tsx
Original file line number Diff line number Diff line change
@@ -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(
<InSelector
value={['r10']}
onChange={jest.fn()}
/>,
);

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(
<InSelector
value={['r10']}
onChange={jest.fn()}
/>,
);

// Simulate the user toggling another report on: the parent re-renders the filter with the updated value.
rerender(
<InSelector
value={['r10', 'r3']}
onChange={jest.fn()}
/>,
);

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(
<InSelector
value={['r10', 'r5']}
onChange={jest.fn()}
/>,
);

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(
<InSelector
value={['r2']}
onChange={jest.fn()}
/>,
);

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);
});
});
Loading