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
204 changes: 152 additions & 52 deletions src/pages/OnboardingWorkspaceInvite/BaseOnboardingWorkspaceInvite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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<OptionData, Section<OptionData>>;
type MembersSection = SectionListData<MemberForList, Section<MemberForList>>;

function BaseOnboardingWorkspaceInvite({shouldUseNativeStyles}: BaseOnboardingWorkspaceInviteProps) {
const styles = useThemeStyles();
Expand All @@ -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<MemberForList[]>([]);
const [personalDetails, setPersonalDetails] = useState<OptionData[]>([]);
const [usersToInvite, setUsersToInvite] = useState<OptionData[]>([]);
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);
Expand All @@ -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<number, OptionData> = {};
const newPersonalDetailsDict: Record<number, OptionData> = {};
const newSelectedOptionsDict: Record<number, MemberForList> = {};

// Update selectedOptions with the latest personalDetails and policyEmployeeList information
const detailsMap: Record<string, MemberForList> = {};
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);

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.

PERF-4

Objects passed as props should be properly memoized to prevent unnecessary re-renders.

The spread operator {...detailsMap[option.login], isSelected: true} creates a new object on every render within the forEach loop, which can cause performance issues.

const memoizedOption = useMemo(() => 
    option.login && option.login in detailsMap 
        ? {...detailsMap[option.login], isSelected: true} 
        : option, 
    [option, detailsMap]
);
newSelectedOptions.push(memoizedOption);

});

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}];

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.

PERF-4

Objects passed as props should be properly memoized to prevent unnecessary re-renders.

The spread operator {...option, isSelected: true} creates a new object on every render, which will cause the child component to re-render even when the actual data hasn't changed.

const memoizedOption = useMemo(() => ({...option, isSelected: true}), [option]);
newSelectedOptions = [...selectedOptions, memoizedOption];

}

setSelectedOptions(newSelectedOptions);
};

const completeOnboarding = useCallback(
(isInvitedAccountant: boolean) => {
Expand Down Expand Up @@ -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(
() => (
Expand Down Expand Up @@ -259,7 +359,7 @@ function BaseOnboardingWorkspaceInvite({shouldUseNativeStyles}: BaseOnboardingWo
setSearchTerm(value);
}}
headerMessage={headerMessage}
onSelectRow={handleToggleSelection}
onSelectRow={toggleOption}
onConfirm={inviteUser}
showScrollIndicator
showLoadingPlaceholder={!areOptionsInitialized || !didScreenTransitionEnd}
Expand Down
Loading
Loading