diff --git a/src/pages/OnboardingWorkspaceInvite/BaseOnboardingWorkspaceInvite.tsx b/src/pages/OnboardingWorkspaceInvite/BaseOnboardingWorkspaceInvite.tsx index f02d6f6f1866..2f11adbd48de 100644 --- a/src/pages/OnboardingWorkspaceInvite/BaseOnboardingWorkspaceInvite.tsx +++ b/src/pages/OnboardingWorkspaceInvite/BaseOnboardingWorkspaceInvite.tsx @@ -4,19 +4,20 @@ import type {SectionListData} from 'react-native'; import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {useSession} from '@components/OnyxListItemProvider'; +import {useOptionsList} from '@components/OptionListContextProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionListWithSections'; import InviteMemberListItem from '@components/SelectionListWithSections/InviteMemberListItem'; import type {Section} from '@components/SelectionListWithSections/types'; import Text from '@components/Text'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useOnboardingMessages from '@hooks/useOnboardingMessages'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useSearchSelector from '@hooks/useSearchSelector'; import useThemeStyles from '@hooks/useThemeStyles'; import {addMembersToWorkspace} from '@libs/actions/Policy/Member'; import {searchInServer} from '@libs/actions/Report'; @@ -25,7 +26,8 @@ import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import HttpUtils from '@libs/HttpUtils'; import {appendCountryCode} from '@libs/LoginUtils'; import {navigateAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; -import {getHeaderMessage} from '@libs/OptionsListUtils'; +import type {MemberForList} from '@libs/OptionsListUtils'; +import {filterAndOrderOptions, formatMemberForList, getHeaderMessage, getMemberInviteOptions, getSearchValueForPhoneOrEmail} from '@libs/OptionsListUtils'; import {addSMSDomainIfPhoneNumber, parsePhoneNumber} from '@libs/PhoneNumber'; import {getIneligibleInvitees, getMemberAccountIDsForWorkspace} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -34,9 +36,10 @@ import {setOnboardingAdminsChatReportID, setOnboardingPolicyID} from '@userActio import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {InvitedEmailsToAccountIDs} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {BaseOnboardingWorkspaceInviteProps} from './types'; -type Sections = SectionListData>; +type MembersSection = SectionListData>; function BaseOnboardingWorkspaceInvite({shouldUseNativeStyles}: BaseOnboardingWorkspaceInviteProps) { const styles = useThemeStyles(); @@ -48,11 +51,27 @@ function BaseOnboardingWorkspaceInvite({shouldUseNativeStyles}: BaseOnboardingWo // We need to use isSmallScreenWidth, see navigateAfterOnboarding function comment // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {onboardingIsMediumOrLargerScreenWidth, isSmallScreenWidth} = useResponsiveLayout(); + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const [selectedOptions, setSelectedOptions] = useState([]); + const [personalDetails, setPersonalDetails] = useState([]); + const [usersToInvite, setUsersToInvite] = useState([]); const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {canBeMissing: true, initWithStoredValues: false}); + const [betas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: false}); + const [countryCode] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false}); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const session = useSession(); const {isBetaEnabled} = usePermissions(); + const {options, areOptionsInitialized} = useOptionsList({ + shouldInitialize: didScreenTransitionEnd, + }); + + const welcomeNoteSubject = useMemo( + () => `# ${currentUserPersonalDetails?.displayName ?? ''} invited you to ${policy?.name ?? 'a workspace'}`, + [policy?.name, currentUserPersonalDetails?.displayName], + ); + + const welcomeNote = useMemo(() => translate('workspace.common.welcomeNote'), [translate]); const excludedUsers = useMemo(() => { const ineligibleInvitees = getIneligibleInvitees(policy?.employeeList); @@ -65,65 +84,142 @@ function BaseOnboardingWorkspaceInvite({shouldUseNativeStyles}: BaseOnboardingWo ); }, [policy?.employeeList]); - const {searchTerm, setSearchTerm, availableOptions, selectedOptions, selectedOptionsForDisplay, toggleSelection, areOptionsInitialized} = useSearchSelector({ - selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, - searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE, - includeUserToInvite: true, - excludeLogins: excludedUsers, - includeRecentReports: true, - shouldInitialize: didScreenTransitionEnd, - }); + const defaultOptions = useMemo(() => { + if (!areOptionsInitialized) { + return {recentReports: [], personalDetails: [], userToInvite: null, currentUserOption: null}; + } - const welcomeNoteSubject = useMemo( - () => `# ${currentUserPersonalDetails?.displayName ?? ''} invited you to ${policy?.name ?? 'a workspace'}`, - [policy?.name, currentUserPersonalDetails?.displayName], - ); + const inviteOptions = getMemberInviteOptions(options.personalDetails, betas ?? [], excludedUsers, true); - const welcomeNote = useMemo(() => translate('workspace.common.welcomeNote'), [translate]); + return {...inviteOptions, recentReports: [], currentUserOption: null}; + }, [areOptionsInitialized, betas, excludedUsers, options.personalDetails]); + + const inviteOptions = useMemo( + () => filterAndOrderOptions(defaultOptions, debouncedSearchTerm, countryCode, {excludeLogins: excludedUsers}), + [debouncedSearchTerm, defaultOptions, excludedUsers, countryCode], + ); useEffect(() => { - searchInServer(searchTerm); - }, [searchTerm]); + if (!areOptionsInitialized) { + return; + } + + const newUsersToInviteDict: Record = {}; + const newPersonalDetailsDict: Record = {}; + const newSelectedOptionsDict: Record = {}; + + // Update selectedOptions with the latest personalDetails and policyEmployeeList information + const detailsMap: Record = {}; + inviteOptions.personalDetails.forEach((detail) => { + if (!detail.login) { + return; + } + + detailsMap[detail.login] = formatMemberForList(detail); + }); + + const newSelectedOptions: MemberForList[] = []; + selectedOptions.forEach((option) => { + newSelectedOptions.push(option.login && option.login in detailsMap ? {...detailsMap[option.login], isSelected: true} : option); + }); + + const userToInvite = inviteOptions.userToInvite; - const sections: Sections[] = useMemo(() => { - const sectionsArr: Sections[] = []; + // Only add the user to the invitees list if it is valid + if (typeof userToInvite?.accountID === 'number') { + newUsersToInviteDict[userToInvite.accountID] = userToInvite; + } + + // Add all personal details to the new dict + inviteOptions.personalDetails.forEach((details) => { + if (typeof details.accountID !== 'number') { + return; + } + newPersonalDetailsDict[details.accountID] = details; + }); + + // Add all selected options to the new dict + newSelectedOptions.forEach((option) => { + if (typeof option.accountID !== 'number') { + return; + } + newSelectedOptionsDict[option.accountID] = option; + }); + + // Strip out dictionary keys and update arrays + setUsersToInvite(Object.values(newUsersToInviteDict)); + setPersonalDetails(Object.values(newPersonalDetailsDict)); + setSelectedOptions(Object.values(newSelectedOptionsDict)); + + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want to recalculate when selectedOptions change + }, [options.personalDetails, policy?.employeeList, betas, debouncedSearchTerm, excludedUsers, areOptionsInitialized, inviteOptions.personalDetails, inviteOptions.userToInvite]); + + const sections: MembersSection[] = useMemo(() => { + const sectionsArr: MembersSection[] = []; if (!areOptionsInitialized) { return []; } - // Selected options section - if (selectedOptionsForDisplay.length > 0) { - sectionsArr.push({ - title: undefined, - data: selectedOptionsForDisplay, - }); - } + // Filter all options that is a part of the search term or in the personal details + let filterSelectedOptions = selectedOptions; + if (debouncedSearchTerm !== '') { + filterSelectedOptions = selectedOptions.filter((option) => { + const accountID = option.accountID; + const isOptionInPersonalDetails = Object.values(personalDetails).some((personalDetail) => personalDetail.accountID === accountID); - // Contacts section - if (availableOptions.personalDetails.length > 0) { - sectionsArr.push({ - title: translate('common.contacts'), - data: availableOptions.personalDetails, - }); - } + const searchValue = getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode); - // User to invite section - if (availableOptions.userToInvite) { - sectionsArr.push({ - title: undefined, - data: [availableOptions.userToInvite], + const isPartOfSearchTerm = !!option.text?.toLowerCase().includes(searchValue) || !!option.login?.toLowerCase().includes(searchValue); + return isPartOfSearchTerm || isOptionInPersonalDetails; }); } + + sectionsArr.push({ + title: undefined, + data: filterSelectedOptions, + shouldShow: true, + }); + + // Filtering out selected users from the search results + const selectedLoginsSet = new Set(selectedOptions.map(({login}) => login)); + const personalDetailsFormatted = Object.values(personalDetails) + .filter(({login}) => !selectedLoginsSet.has(login ?? '')) + .map(formatMemberForList); + + sectionsArr.push({ + title: translate('common.contacts'), + data: personalDetailsFormatted, + shouldShow: !isEmptyObject(personalDetailsFormatted), + }); + + Object.values(usersToInvite).forEach((userToInvite) => { + const hasUnselectedUserToInvite = !selectedLoginsSet.has(userToInvite.login ?? ''); + + if (hasUnselectedUserToInvite) { + sectionsArr.push({ + title: undefined, + data: [formatMemberForList(userToInvite)], + shouldShow: true, + }); + } + }); + return sectionsArr; - }, [areOptionsInitialized, selectedOptionsForDisplay, availableOptions, translate]); + }, [areOptionsInitialized, selectedOptions, debouncedSearchTerm, personalDetails, translate, usersToInvite, countryCode]); - const handleToggleSelection = useCallback( - (option: OptionData) => { - toggleSelection(option); - }, - [toggleSelection], - ); + const toggleOption = (option: MemberForList) => { + const isOptionInList = selectedOptions.some((selectedOption) => selectedOption.login === option.login); + + let newSelectedOptions: MemberForList[]; + if (isOptionInList) { + newSelectedOptions = selectedOptions.filter((selectedOption) => selectedOption.login !== option.login); + } else { + newSelectedOptions = [...selectedOptions, {...option, isSelected: true}]; + } + + setSelectedOptions(newSelectedOptions); + }; const completeOnboarding = useCallback( (isInvitedAccountant: boolean) => { @@ -188,19 +284,23 @@ function BaseOnboardingWorkspaceInvite({shouldUseNativeStyles}: BaseOnboardingWo completeOnboarding(true); }, [completeOnboarding, onboardingPolicyID, policy?.employeeList, selectedOptions, welcomeNote, welcomeNoteSubject, formatPhoneNumber]); + useEffect(() => { + searchInServer(debouncedSearchTerm); + }, [debouncedSearchTerm]); + const headerMessage = useMemo(() => { - const searchValue = searchTerm.trim().toLowerCase(); - if (!availableOptions.userToInvite && CONST.EXPENSIFY_EMAILS_OBJECT[searchValue]) { + const searchValue = debouncedSearchTerm.trim().toLowerCase(); + if (usersToInvite.length === 0 && CONST.EXPENSIFY_EMAILS_OBJECT[searchValue]) { return translate('messages.errorMessageInvalidEmail'); } if ( - !availableOptions.userToInvite && + usersToInvite.length === 0 && excludedUsers[parsePhoneNumber(appendCountryCode(searchValue)).possible ? addSMSDomainIfPhoneNumber(appendCountryCode(searchValue)) : searchValue] ) { return translate('messages.userIsAlreadyMember', {login: searchValue, name: policy?.name ?? ''}); } - return getHeaderMessage(availableOptions.personalDetails.length !== 0, !!availableOptions.userToInvite, searchValue); - }, [excludedUsers, translate, searchTerm, policy?.name, availableOptions.personalDetails.length, availableOptions.userToInvite]); + return getHeaderMessage(personalDetails.length !== 0, usersToInvite.length > 0, searchValue); + }, [excludedUsers, translate, debouncedSearchTerm, policy?.name, usersToInvite, personalDetails.length]); const footerContent = useMemo( () => ( @@ -259,7 +359,7 @@ function BaseOnboardingWorkspaceInvite({shouldUseNativeStyles}: BaseOnboardingWo setSearchTerm(value); }} headerMessage={headerMessage} - onSelectRow={handleToggleSelection} + onSelectRow={toggleOption} onConfirm={inviteUser} showScrollIndicator showLoadingPlaceholder={!areOptionsInitialized || !didScreenTransitionEnd} diff --git a/src/pages/workspace/WorkspaceInvitePage.tsx b/src/pages/workspace/WorkspaceInvitePage.tsx index da04141db03c..d1d359db00bc 100644 --- a/src/pages/workspace/WorkspaceInvitePage.tsx +++ b/src/pages/workspace/WorkspaceInvitePage.tsx @@ -1,17 +1,18 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {SectionListData} from 'react-native'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import {useOptionsList} from '@components/OptionListContextProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionListWithSections'; import InviteMemberListItem from '@components/SelectionListWithSections/InviteMemberListItem'; import type {Section} from '@components/SelectionListWithSections/types'; import withNavigationTransitionEnd from '@components/withNavigationTransitionEnd'; import type {WithNavigationTransitionEndProps} from '@components/withNavigationTransitionEnd'; +import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import useSearchSelector from '@hooks/useSearchSelector'; import useThemeStyles from '@hooks/useThemeStyles'; import {setWorkspaceInviteMembersDraft} from '@libs/actions/Policy/Member'; import {clearErrors, openWorkspaceInvitePage as policyOpenWorkspaceInvitePage, setWorkspaceErrors} from '@libs/actions/Policy/Policy'; @@ -22,7 +23,8 @@ import HttpUtils from '@libs/HttpUtils'; import {appendCountryCode} from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import {getHeaderMessage} from '@libs/OptionsListUtils'; +import {filterAndOrderOptions, formatMemberForList, getHeaderMessage, getMemberInviteOptions, getSearchValueForPhoneOrEmail, getUserToInviteOption} from '@libs/OptionsListUtils'; +import type {MemberForList} from '@libs/OptionsListUtils'; import {addSMSDomainIfPhoneNumber, parsePhoneNumber} from '@libs/PhoneNumber'; import {getIneligibleInvitees, getMemberAccountIDsForWorkspace, goBackFromInvalidPolicy} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -38,7 +40,7 @@ import AccessOrNotFoundWrapper from './AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; -type Sections = SectionListData>; +type MembersSection = SectionListData>; type WorkspaceInvitePageProps = WithPolicyAndFullscreenLoadingProps & WithNavigationTransitionEndProps & @@ -47,13 +49,24 @@ type WorkspaceInvitePageProps = WithPolicyAndFullscreenLoadingProps & function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const [selectedOptions, setSelectedOptions] = useState([]); + const [personalDetails, setPersonalDetails] = useState([]); + const [usersToInvite, setUsersToInvite] = useState([]); const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false, canBeMissing: true}); + const [countryCode] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false}); + const firstRenderRef = useRef(true); + const [betas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: false}); + const [invitedEmailsToAccountIDsDraft] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID.toString()}`, {canBeMissing: true}); const openWorkspaceInvitePage = () => { const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList); policyOpenWorkspaceInvitePage(route.params.policyID, Object.keys(policyMemberEmailsToAccountIDs)); }; + const {options, areOptionsInitialized} = useOptionsList({ + shouldInitialize: didScreenTransitionEnd, + }); useEffect(() => { clearErrors(route.params.policyID); @@ -74,63 +87,165 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { ); }, [policy?.employeeList]); - const {searchTerm, setSearchTerm, availableOptions, selectedOptions, selectedOptionsForDisplay, toggleSelection, areOptionsInitialized, onListEndReached} = useSearchSelector({ - selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, - searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE, - includeUserToInvite: true, - excludeLogins: excludedUsers, - includeRecentReports: true, - shouldInitialize: didScreenTransitionEnd, - }); + const defaultOptions = useMemo(() => { + if (!areOptionsInitialized) { + return {recentReports: [], personalDetails: [], userToInvite: null, currentUserOption: null}; + } + + const inviteOptions = getMemberInviteOptions(options.personalDetails, betas ?? [], excludedUsers, true); - const sections: Sections[] = useMemo(() => { - const sectionsArr: Sections[] = []; + return {...inviteOptions, recentReports: [], currentUserOption: null}; + }, [areOptionsInitialized, betas, excludedUsers, options.personalDetails]); + const inviteOptions = useMemo( + () => filterAndOrderOptions(defaultOptions, debouncedSearchTerm, countryCode, {excludeLogins: excludedUsers}), + [debouncedSearchTerm, defaultOptions, excludedUsers, countryCode], + ); + + useEffect(() => { if (!areOptionsInitialized) { - return []; + return; } - // Selected options section - if (selectedOptionsForDisplay.length > 0) { - sectionsArr.push({ - title: undefined, - data: selectedOptionsForDisplay, + const newUsersToInviteDict: Record = {}; + const newPersonalDetailsDict: Record = {}; + const newSelectedOptionsDict: Record = {}; + + // Update selectedOptions with the latest personalDetails and policyEmployeeList information + const detailsMap: Record = {}; + inviteOptions.personalDetails.forEach((detail) => { + if (!detail.login) { + return; + } + + detailsMap[detail.login] = formatMemberForList(detail); + }); + + const newSelectedOptions: MemberForList[] = []; + if (firstRenderRef.current) { + // We only want to add the saved selected user on first render + firstRenderRef.current = false; + Object.entries(invitedEmailsToAccountIDsDraft ?? {}).forEach(([login, accountID]) => { + if (login in detailsMap) { + newSelectedOptions.push({...detailsMap[login], isSelected: true}); + } else { + const optimisticOption = getUserToInviteOption({ + searchValue: login, + }); + if (optimisticOption) { + optimisticOption.accountID = Number(accountID); + const memberOption = formatMemberForList({ + ...optimisticOption, + accountID: Number(accountID), + isSelected: true, + }); + newSelectedOptions.push(memberOption); + } + } }); } + selectedOptions.forEach((option) => { + newSelectedOptions.push(option.login && option.login in detailsMap ? {...detailsMap[option.login], isSelected: true} : option); + }); - // Recent reports section - if (availableOptions.recentReports.length > 0) { - sectionsArr.push({ - title: translate('common.recents'), - data: availableOptions.recentReports, - }); + const userToInvite = inviteOptions.userToInvite; + + // Only add the user to the invites list if it is valid + if (typeof userToInvite?.accountID === 'number') { + newUsersToInviteDict[userToInvite.accountID] = userToInvite; } - // Contacts section - if (availableOptions.personalDetails.length > 0) { - sectionsArr.push({ - title: translate('common.contacts'), - data: availableOptions.personalDetails, - }); + // Add all personal details to the new dict + inviteOptions.personalDetails.forEach((details) => { + if (typeof details.accountID !== 'number') { + return; + } + newPersonalDetailsDict[details.accountID] = details; + }); + + // Add all selected options to the new dict + newSelectedOptions.forEach((option) => { + if (typeof option.accountID !== 'number') { + return; + } + newSelectedOptionsDict[option.accountID] = option; + }); + + // Strip out dictionary keys and update arrays + setUsersToInvite(Object.values(newUsersToInviteDict)); + setPersonalDetails(Object.values(newPersonalDetailsDict)); + setSelectedOptions(Object.values(newSelectedOptionsDict)); + + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want to recalculate when selectedOptions change + }, [options.personalDetails, policy?.employeeList, betas, debouncedSearchTerm, excludedUsers, areOptionsInitialized, inviteOptions.personalDetails, inviteOptions.userToInvite]); + + const sections: MembersSection[] = useMemo(() => { + const sectionsArr: MembersSection[] = []; + + if (!areOptionsInitialized) { + return []; } - // User to invite section - if (availableOptions.userToInvite) { - sectionsArr.push({ - title: undefined, - data: [availableOptions.userToInvite], + // Filter all options that is a part of the search term or in the personal details + let filterSelectedOptions = selectedOptions; + if (debouncedSearchTerm !== '') { + filterSelectedOptions = selectedOptions.filter((option) => { + const accountID = option.accountID; + const isOptionInPersonalDetails = Object.values(personalDetails).some((personalDetail) => personalDetail.accountID === accountID); + + const searchValue = getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode); + + const isPartOfSearchTerm = !!option.text?.toLowerCase().includes(searchValue) || !!option.login?.toLowerCase().includes(searchValue); + return isPartOfSearchTerm || isOptionInPersonalDetails; }); } + sectionsArr.push({ + title: undefined, + data: filterSelectedOptions, + shouldShow: true, + }); + + // Filtering out selected users from the search results + const selectedLogins = selectedOptions.map(({login}) => login); + const personalDetailsWithoutSelected = Object.values(personalDetails).filter(({login}) => !selectedLogins.some((selectedLogin) => selectedLogin === login)); + const personalDetailsFormatted = personalDetailsWithoutSelected.map((item) => formatMemberForList(item)); + + sectionsArr.push({ + title: translate('common.contacts'), + data: personalDetailsFormatted, + shouldShow: !isEmptyObject(personalDetailsFormatted), + }); + + Object.values(usersToInvite).forEach((userToInvite) => { + const hasUnselectedUserToInvite = !selectedLogins.some((selectedLogin) => selectedLogin === userToInvite.login); + + if (hasUnselectedUserToInvite) { + sectionsArr.push({ + title: undefined, + data: [formatMemberForList(userToInvite)], + shouldShow: true, + }); + } + }); + return sectionsArr; - }, [areOptionsInitialized, selectedOptionsForDisplay, availableOptions, translate]); + }, [areOptionsInitialized, selectedOptions, debouncedSearchTerm, personalDetails, translate, usersToInvite, countryCode]); - const handleToggleSelection = useCallback( - (option: OptionData) => { - toggleSelection(option); - }, - [toggleSelection], - ); + const toggleOption = (option: MemberForList) => { + clearErrors(route.params.policyID); + + const isOptionInList = selectedOptions.some((selectedOption) => selectedOption.login === option.login); + + let newSelectedOptions: MemberForList[]; + if (isOptionInList) { + newSelectedOptions = selectedOptions.filter((selectedOption) => selectedOption.login !== option.login); + } else { + newSelectedOptions = [...selectedOptions, {...option, isSelected: true}]; + } + + setSelectedOptions(newSelectedOptions); + }; const inviteUser = useCallback(() => { const errors: Errors = {}; @@ -162,18 +277,18 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { const [policyName, shouldShowAlertPrompt] = useMemo(() => [policy?.name ?? '', !isEmptyObject(policy?.errors) || !!policy?.alertMessage], [policy]); const headerMessage = useMemo(() => { - const searchValue = searchTerm.trim().toLowerCase(); - if (!availableOptions.userToInvite && CONST.EXPENSIFY_EMAILS_OBJECT[searchValue]) { + const searchValue = debouncedSearchTerm.trim().toLowerCase(); + if (usersToInvite.length === 0 && CONST.EXPENSIFY_EMAILS_OBJECT[searchValue]) { return translate('messages.errorMessageInvalidEmail'); } if ( - !availableOptions.userToInvite && + usersToInvite.length === 0 && excludedUsers[parsePhoneNumber(appendCountryCode(searchValue)).possible ? addSMSDomainIfPhoneNumber(appendCountryCode(searchValue)) : searchValue] ) { return translate('messages.userIsAlreadyMember', {login: searchValue, name: policyName}); } - return getHeaderMessage(availableOptions.personalDetails.length !== 0, !!availableOptions.userToInvite, searchValue); - }, [excludedUsers, translate, searchTerm, policyName, availableOptions.personalDetails.length, availableOptions.userToInvite]); + return getHeaderMessage(personalDetails.length !== 0, usersToInvite.length > 0, searchValue); + }, [excludedUsers, translate, debouncedSearchTerm, policyName, usersToInvite, personalDetails.length]); const footerContent = useMemo( () => ( @@ -191,8 +306,8 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { ); useEffect(() => { - searchInServer(searchTerm); - }, [searchTerm]); + searchInServer(debouncedSearchTerm); + }, [debouncedSearchTerm]); return (