diff --git a/src/hooks/useSearchSelector/base.ts b/src/hooks/useSearchSelector/base.ts index 189e049d901a..607bfd2517e3 100644 --- a/src/hooks/useSearchSelector/base.ts +++ b/src/hooks/useSearchSelector/base.ts @@ -82,6 +82,9 @@ type UseSearchSelectorConfig = { /** Whether to keep selected options in availableOptions instead of filtering them out */ shouldKeepSelectedInAvailableOptions?: boolean; + + /** Whether to separate selected options that are not in availableOptions.personalDetails (e.g. non-existing users invited by email) */ + shouldSeparateNonExistingSelectedOptions?: boolean; }; type ContactState = { @@ -126,6 +129,9 @@ type UseSearchSelectorReturn = { /** Currently selected options used for list display. This prop can be used in selection list to display selected options that are filtered by search term */ selectedOptionsForDisplay: OptionData[]; + /** Selected options that are not present in availableOptions.personalDetails (e.g. non-existing users invited by email). Only populated when shouldSeparateNonExistingSelectedOptions is true */ + selectedNonExistingOptions?: OptionData[]; + /** Function to set selected options */ setSelectedOptions: (options: OptionData[]) => void; @@ -174,6 +180,7 @@ function useSearchSelectorBase({ recentAttendees, shouldAllowNameOnlyOptions = false, shouldKeepSelectedInAvailableOptions = false, + shouldSeparateNonExistingSelectedOptions = false, }: UseSearchSelectorConfig): UseSearchSelectorReturn { const {options: defaultOptions, areOptionsInitialized} = useOptionsList({ shouldInitialize, @@ -422,6 +429,13 @@ function useSearchSelectorBase({ ); }); + const selectedNonExistingOptions = shouldSeparateNonExistingSelectedOptions + ? (() => { + const personalDetailLogins = new Set(filteredPersonalDetails.map((option) => option.login).filter(Boolean)); + return selectedOptionsForDisplay.filter((option) => !personalDetailLogins.has(option.login)); + })() + : []; + return { searchTerm, debouncedSearchTerm, @@ -435,6 +449,7 @@ function useSearchSelectorBase({ contactState: undefined, onListEndReached, selectedOptionsForDisplay, + selectedNonExistingOptions, }; } diff --git a/src/pages/workspace/DynamicWorkspaceInvitePage.tsx b/src/pages/workspace/DynamicWorkspaceInvitePage.tsx index 1afa8ca275df..55d12f4c34d4 100644 --- a/src/pages/workspace/DynamicWorkspaceInvitePage.tsx +++ b/src/pages/workspace/DynamicWorkspaceInvitePage.tsx @@ -35,6 +35,7 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {InvitedEmailsToAccountIDs} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import getEmptyArray from '@src/types/utils/getEmptyArray'; import AccessOrNotFoundWrapper from './AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; @@ -106,7 +107,7 @@ function DynamicWorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { setSearchTerm, availableOptions, selectedOptions, - selectedOptionsForDisplay, + selectedNonExistingOptions = getEmptyArray(), toggleSelection, areOptionsInitialized, onListEndReached, @@ -120,6 +121,8 @@ function DynamicWorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { includeRecentReports: false, shouldInitialize: didScreenTransitionEnd, initialSelected: initiallySelectedOptions, + shouldKeepSelectedInAvailableOptions: true, + shouldSeparateNonExistingSelectedOptions: true, }); const sections: Array> = useMemo(() => { @@ -129,16 +132,16 @@ function DynamicWorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { return []; } - // Selected options section - if (selectedOptionsForDisplay.length > 0) { + // Selected non-existing users section (top) + if (selectedNonExistingOptions.length > 0) { sectionsArr.push({ title: undefined, - data: selectedOptionsForDisplay, + data: selectedNonExistingOptions, sectionIndex: 0, }); } - // Contacts section + // Contacts section (includes both selected and unselected items) if (availableOptions.personalDetails.length > 0) { sectionsArr.push({ title: translate('common.contacts'), @@ -147,8 +150,8 @@ function DynamicWorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { }); } - // User to invite section - if (availableOptions.userToInvite) { + // User to invite section (hide if already selected and shown in the top section) + if (availableOptions.userToInvite && !availableOptions.userToInvite.isSelected) { sectionsArr.push({ title: undefined, data: [availableOptions.userToInvite], @@ -157,7 +160,7 @@ function DynamicWorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { } return sectionsArr; - }, [areOptionsInitialized, selectedOptionsForDisplay, availableOptions.personalDetails, availableOptions.userToInvite, translate]); + }, [areOptionsInitialized, selectedNonExistingOptions, availableOptions.personalDetails, availableOptions.userToInvite, translate]); const handleToggleSelection = useCallback( (option: OptionData) => { @@ -265,6 +268,8 @@ function DynamicWorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { onSelectRow={handleToggleSelection} shouldShowTextInput textInputOptions={textInputOptions} + shouldUpdateFocusedIndex + shouldPreventAutoScrollOnSelect confirmButtonOptions={{ onConfirm: inviteUser, isDisabled: !selectedOptions.length, diff --git a/tests/unit/useSearchSelectorTest.tsx b/tests/unit/useSearchSelectorTest.tsx index 9ca977a6d319..b8e096202807 100644 --- a/tests/unit/useSearchSelectorTest.tsx +++ b/tests/unit/useSearchSelectorTest.tsx @@ -3,6 +3,7 @@ import type {OnyxMultiSetInput} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import useSearchSelectorBase from '@hooks/useSearchSelector/base'; import {getSearchOptions, getValidOptions} from '@libs/OptionsListUtils'; +import type {OptionData} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportAction} from '@src/types/onyx'; @@ -226,7 +227,7 @@ describe('useSearchSelector sortedActions integration', () => { expect(latestCallConfig?.sortedActions).toEqual(updatedData.sortedActions); }); - it('passes sortedActions to getSearchOptions for SEARCH context', async () => { + it('passes sortedActions to getSearchOptions for SEARCH context (SEARCH_CONTEXT_SEARCH)', async () => { const mockData = buildMockSortedActions(['1']); await act(async () => { @@ -248,3 +249,288 @@ describe('useSearchSelector sortedActions integration', () => { expect(searchConfig).toHaveProperty('sortedActions'); }); }); + +const EXISTING_CONTACT: OptionData = { + text: 'Alice Smith', + login: 'alice@expensify.com', + accountID: 100, + isSelected: false, + keyForList: 'alice@expensify.com', +} as OptionData; + +const SECOND_CONTACT: OptionData = { + text: 'Bob Jones', + login: 'bob@expensify.com', + accountID: 200, + isSelected: false, + keyForList: 'bob@expensify.com', +} as OptionData; + +const NON_EXISTING_USER_TO_INVITE: OptionData = { + text: 'newuser@gmail.com', + login: 'newuser@gmail.com', + accountID: 999999, + isOptimisticAccount: true, + isSelected: false, + keyForList: 'newuser@gmail.com', +} as OptionData; + +describe('useSearchSelector selection and non-existing options', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await act(async () => { + await Onyx.clear(); + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: { + accountID: MOCK_ACCOUNT_ID, + email: MOCK_EMAIL, + authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS, + }, + [ONYXKEYS.BETAS]: [], + [ONYXKEYS.COUNTRY_CODE]: CONST.DEFAULT_COUNTRY_CODE, + } as unknown as OnyxMultiSetInput); + }); + await waitForBatchedUpdatesWithAct(); + }); + + afterAll(async () => { + await act(async () => { + await Onyx.clear(); + }); + }); + + it('keeps selected contacts in availableOptions.personalDetails when shouldKeepSelectedInAvailableOptions is true', async () => { + const optionsWithSelected = { + recentReports: [], + personalDetails: [{...EXISTING_CONTACT, isSelected: true}, SECOND_CONTACT], + userToInvite: null, + currentUserOption: null, + }; + mockGetValidOptions.mockReturnValue(optionsWithSelected); + + const {result} = renderHook(() => + useSearchSelectorBase({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, + searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE, + shouldKeepSelectedInAvailableOptions: true, + initialSelected: [EXISTING_CONTACT], + }), + ); + await waitForBatchedUpdatesWithAct(); + + // The selected contact should remain in availableOptions.personalDetails + const personalDetailLogins = result.current.availableOptions.personalDetails.map((o) => o.login); + expect(personalDetailLogins).toContain('alice@expensify.com'); + expect(personalDetailLogins).toContain('bob@expensify.com'); + }); + + it('filters out selected contacts from availableOptions.personalDetails when shouldKeepSelectedInAvailableOptions is false', async () => { + const optionsWithSelected = { + recentReports: [], + personalDetails: [{...EXISTING_CONTACT, isSelected: true}, SECOND_CONTACT], + userToInvite: null, + currentUserOption: null, + }; + mockGetValidOptions.mockReturnValue(optionsWithSelected); + + const {result} = renderHook(() => + useSearchSelectorBase({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, + searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE, + shouldKeepSelectedInAvailableOptions: false, + initialSelected: [EXISTING_CONTACT], + }), + ); + await waitForBatchedUpdatesWithAct(); + + // The selected contact should be filtered out from availableOptions + const personalDetailLogins = result.current.availableOptions.personalDetails.map((o) => o.login); + expect(personalDetailLogins).not.toContain('alice@expensify.com'); + expect(personalDetailLogins).toContain('bob@expensify.com'); + }); + + it('populates selectedNonExistingOptions with selected users not in personalDetails when shouldSeparateNonExistingSelectedOptions is true', async () => { + // Return contacts that do NOT include the non-existing user + const optionsWithContacts = { + recentReports: [], + personalDetails: [EXISTING_CONTACT], + userToInvite: null, + currentUserOption: null, + }; + mockGetValidOptions.mockReturnValue(optionsWithContacts); + + const {result} = renderHook(() => + useSearchSelectorBase({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, + searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE, + shouldKeepSelectedInAvailableOptions: true, + shouldSeparateNonExistingSelectedOptions: true, + initialSelected: [NON_EXISTING_USER_TO_INVITE], + }), + ); + await waitForBatchedUpdatesWithAct(); + + // The non-existing user should appear in selectedNonExistingOptions + expect(result.current.selectedNonExistingOptions).toHaveLength(1); + expect(result.current.selectedNonExistingOptions?.[0].login).toBe('newuser@gmail.com'); + + // The non-existing user should NOT be in availableOptions.personalDetails + const personalDetailLogins = result.current.availableOptions.personalDetails.map((o) => o.login); + expect(personalDetailLogins).not.toContain('newuser@gmail.com'); + }); + + it('returns empty selectedNonExistingOptions when shouldSeparateNonExistingSelectedOptions is false', async () => { + const optionsWithContacts = { + recentReports: [], + personalDetails: [EXISTING_CONTACT], + userToInvite: null, + currentUserOption: null, + }; + mockGetValidOptions.mockReturnValue(optionsWithContacts); + + const {result} = renderHook(() => + useSearchSelectorBase({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, + searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE, + shouldKeepSelectedInAvailableOptions: true, + shouldSeparateNonExistingSelectedOptions: false, + initialSelected: [NON_EXISTING_USER_TO_INVITE], + }), + ); + await waitForBatchedUpdatesWithAct(); + + expect(result.current.selectedNonExistingOptions).toHaveLength(0); + }); + + it('does not include existing contacts in selectedNonExistingOptions', async () => { + const optionsWithContacts = { + recentReports: [], + personalDetails: [{...EXISTING_CONTACT, isSelected: true}, SECOND_CONTACT], + userToInvite: null, + currentUserOption: null, + }; + mockGetValidOptions.mockReturnValue(optionsWithContacts); + + const {result} = renderHook(() => + useSearchSelectorBase({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, + searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE, + shouldKeepSelectedInAvailableOptions: true, + shouldSeparateNonExistingSelectedOptions: true, + initialSelected: [EXISTING_CONTACT], + }), + ); + await waitForBatchedUpdatesWithAct(); + + // Existing contacts should NOT appear in selectedNonExistingOptions + expect(result.current.selectedNonExistingOptions).toHaveLength(0); + + // They should remain in availableOptions.personalDetails + const personalDetailLogins = result.current.availableOptions.personalDetails.map((o) => o.login); + expect(personalDetailLogins).toContain('alice@expensify.com'); + }); + + it('adds non-existing user to selectedNonExistingOptions after toggleSelection', async () => { + const optionsWithUserToInvite = { + recentReports: [], + personalDetails: [EXISTING_CONTACT], + userToInvite: NON_EXISTING_USER_TO_INVITE, + currentUserOption: null, + }; + mockGetValidOptions.mockReturnValue(optionsWithUserToInvite); + + const {result} = renderHook(() => + useSearchSelectorBase({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, + searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE, + shouldKeepSelectedInAvailableOptions: true, + shouldSeparateNonExistingSelectedOptions: true, + }), + ); + await waitForBatchedUpdatesWithAct(); + + // Initially no selected options + expect(result.current.selectedOptions).toHaveLength(0); + expect(result.current.selectedNonExistingOptions).toHaveLength(0); + + // Toggle the non-existing user + act(() => { + result.current.toggleSelection(NON_EXISTING_USER_TO_INVITE); + }); + await waitForBatchedUpdatesWithAct(); + + // Now the non-existing user should be in selectedOptions and selectedNonExistingOptions + expect(result.current.selectedOptions).toHaveLength(1); + expect(result.current.selectedOptions.at(0)?.login).toBe('newuser@gmail.com'); + expect(result.current.selectedNonExistingOptions).toHaveLength(1); + expect(result.current.selectedNonExistingOptions?.[0].login).toBe('newuser@gmail.com'); + }); + + it('removes non-existing user from selectedNonExistingOptions after deselection', async () => { + const optionsWithContacts = { + recentReports: [], + personalDetails: [EXISTING_CONTACT], + userToInvite: null, + currentUserOption: null, + }; + mockGetValidOptions.mockReturnValue(optionsWithContacts); + + const {result} = renderHook(() => + useSearchSelectorBase({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, + searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE, + shouldKeepSelectedInAvailableOptions: true, + shouldSeparateNonExistingSelectedOptions: true, + initialSelected: [NON_EXISTING_USER_TO_INVITE], + }), + ); + await waitForBatchedUpdatesWithAct(); + + expect(result.current.selectedNonExistingOptions).toHaveLength(1); + + // Deselect the non-existing user + act(() => { + result.current.toggleSelection(NON_EXISTING_USER_TO_INVITE); + }); + await waitForBatchedUpdatesWithAct(); + + expect(result.current.selectedOptions).toHaveLength(0); + expect(result.current.selectedNonExistingOptions).toHaveLength(0); + }); + + it('handles mix of existing and non-existing selected users correctly', async () => { + const optionsWithContacts = { + recentReports: [], + personalDetails: [{...EXISTING_CONTACT, isSelected: true}], + userToInvite: null, + currentUserOption: null, + }; + mockGetValidOptions.mockReturnValue(optionsWithContacts); + + const {result} = renderHook(() => + useSearchSelectorBase({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, + searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE, + shouldKeepSelectedInAvailableOptions: true, + shouldSeparateNonExistingSelectedOptions: true, + initialSelected: [EXISTING_CONTACT, NON_EXISTING_USER_TO_INVITE], + }), + ); + await waitForBatchedUpdatesWithAct(); + + // Both should be in selectedOptions + expect(result.current.selectedOptions).toHaveLength(2); + + // Only the non-existing user should be in selectedNonExistingOptions + expect(result.current.selectedNonExistingOptions).toHaveLength(1); + expect(result.current.selectedNonExistingOptions?.[0].login).toBe('newuser@gmail.com'); + + // The existing contact should remain in availableOptions.personalDetails + const personalDetailLogins = result.current.availableOptions.personalDetails.map((o) => o.login); + expect(personalDetailLogins).toContain('alice@expensify.com'); + }); +});