Skip to content

Commit 287e99e

Browse files
Merge pull request Expensify#79922 from TaduJR/fix-Hide-assigned-Guide/AM-from-invite-and-assign-card-contact-lists
fix: Hide assigned Guide/AM from invite and assign-card contact lists
2 parents 6de916d + 5031919 commit 287e99e

8 files changed

Lines changed: 169 additions & 18 deletions

File tree

src/hooks/useSearchSelector.base.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,12 @@ type UseSearchSelectorConfig = {
3535
/** Whether to include user to invite option */
3636
includeUserToInvite?: boolean;
3737

38-
/** Logins to exclude from results */
38+
/** Logins to exclude from results (hard exclusions - cannot be selected at all) */
3939
excludeLogins?: Record<string, boolean>;
4040

41+
/** Logins to exclude from suggestions only (soft exclusions - can still be manually entered) */
42+
excludeFromSuggestionsOnly?: Record<string, boolean>;
43+
4144
/** Whether to include recent reports (for getMemberInviteOptions) */
4245
includeRecentReports?: boolean;
4346

@@ -135,6 +138,7 @@ function useSearchSelectorBase({
135138
searchContext = 'search',
136139
includeUserToInvite = true,
137140
excludeLogins = CONST.EMPTY_OBJECT,
141+
excludeFromSuggestionsOnly = CONST.EMPTY_OBJECT,
138142
includeRecentReports = false,
139143
getValidOptionsConfig = CONST.EMPTY_OBJECT,
140144
onSelectionChange,
@@ -213,6 +217,7 @@ function useSearchSelectorBase({
213217
includeP2P: true,
214218
includeSelectedOptions: false,
215219
excludeLogins,
220+
excludeFromSuggestionsOnly,
216221
includeRecentReports,
217222
maxElements: maxResults,
218223
maxRecentReportElements: maxRecentReportsToShow,
@@ -231,6 +236,7 @@ function useSearchSelectorBase({
231236
maxRecentReportElements: maxRecentReportsToShow,
232237
includeUserToInvite,
233238
excludeLogins,
239+
excludeFromSuggestionsOnly,
234240
personalDetails,
235241
});
236242
case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_SHARE_LOG:
@@ -286,6 +292,7 @@ function useSearchSelectorBase({
286292
includeP2P: true,
287293
includeSelectedOptions: false,
288294
excludeLogins,
295+
excludeFromSuggestionsOnly,
289296
loginsToExclude: excludeLogins,
290297
includeRecentReports,
291298
maxElements: maxResults,
@@ -313,6 +320,7 @@ function useSearchSelectorBase({
313320
countryCode,
314321
loginList,
315322
excludeLogins,
323+
excludeFromSuggestionsOnly,
316324
includeRecentReports,
317325
maxRecentReportsToShow,
318326
getValidOptionsConfig,

src/libs/OptionsListUtils/index.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2206,6 +2206,7 @@ function getValidOptions(
22062206
currentUserEmail: string,
22072207
{
22082208
excludeLogins = {},
2209+
excludeFromSuggestionsOnly = {},
22092210
includeSelectedOptions = false,
22102211
includeRecentReports = true,
22112212
recentAttendees,
@@ -2228,11 +2229,18 @@ function getValidOptions(
22282229
const restrictedLogins = getRestrictedLogins(config, options, canShowManagerMcTest, nvpDismissedProductTraining);
22292230

22302231
// Gather shared configs:
2232+
// Hard exclusions: cannot be selected at all
22312233
const loginsToExclude: Record<string, boolean> = {
22322234
[CONST.EMAIL.NOTIFICATIONS]: true,
22332235
...excludeLogins,
22342236
...restrictedLogins,
22352237
};
2238+
2239+
// Soft exclusions: hidden from suggestions but can be manually entered (e.g., Guide/AM)
2240+
const loginsToExcludeFromSuggestions: Record<string, boolean> = {
2241+
...loginsToExclude,
2242+
...excludeFromSuggestionsOnly,
2243+
};
22362244
// If we're including selected options from the search results, we only want to exclude them if the search input is empty
22372245
// This is because on certain pages, we show the selected options at the top when the search input is empty
22382246
// This prevents the issue of seeing the selected option twice if you have them as a recent chat and select them
@@ -2241,7 +2249,9 @@ function getValidOptions(
22412249
if (!option.login) {
22422250
continue;
22432251
}
2252+
// Prevent re-inviting already selected users
22442253
loginsToExclude[option.login] = true;
2254+
loginsToExcludeFromSuggestions[option.login] = true;
22452255
}
22462256
}
22472257
const {includeP2P = true, shouldBoldTitleByDefault = true, includeDomainEmail = false, shouldShowGBR = false, ...getValidReportsConfig} = config;
@@ -2286,7 +2296,7 @@ function getValidOptions(
22862296
...getValidReportsConfig,
22872297
includeP2P,
22882298
includeDomainEmail,
2289-
loginsToExclude,
2299+
loginsToExclude: loginsToExcludeFromSuggestions,
22902300
currentUserAccountID,
22912301
},
22922302
draftComment,
@@ -2334,7 +2344,7 @@ function getValidOptions(
23342344
recentAttendees.filter((attendee) => {
23352345
const login = attendee.login ?? attendee.displayName;
23362346
if (login) {
2337-
loginsToExclude[login] = true;
2347+
loginsToExcludeFromSuggestions[login] = true;
23382348
return true;
23392349
}
23402350

@@ -2354,10 +2364,10 @@ function getValidOptions(
23542364
};
23552365

23562366
if (includeP2P) {
2357-
let personalDetailLoginsToExclude = loginsToExclude;
2367+
let personalDetailLoginsToExclude = loginsToExcludeFromSuggestions;
23582368
if (currentUserEmail) {
23592369
personalDetailLoginsToExclude = {
2360-
...loginsToExclude,
2370+
...loginsToExcludeFromSuggestions,
23612371
[currentUserEmail]: !config.includeCurrentUser,
23622372
};
23632373
}

src/libs/OptionsListUtils/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ type IsValidReportsConfig = Pick<
186186

187187
type GetOptionsConfig = {
188188
excludeLogins?: Record<string, boolean>;
189+
excludeFromSuggestionsOnly?: Record<string, boolean>;
189190
includeCurrentUser?: boolean;
190191
includeRecentReports?: boolean;
191192
includeSelectedOptions?: boolean;

src/libs/PolicyUtils.ts

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,19 @@ import CONST from '@src/CONST';
88
import ONYXKEYS from '@src/ONYXKEYS';
99
import ROUTES from '@src/ROUTES';
1010
import INPUT_IDS from '@src/types/form/NetSuiteCustomFieldForm';
11-
import type {OnyxInputOrEntry, Policy, PolicyCategories, PolicyEmployeeList, PolicyTagLists, PolicyTags, Report, TaxRate, Transaction, TravelSettings} from '@src/types/onyx';
11+
import type {
12+
OnyxInputOrEntry,
13+
PersonalDetailsList,
14+
Policy,
15+
PolicyCategories,
16+
PolicyEmployeeList,
17+
PolicyTagLists,
18+
PolicyTags,
19+
Report,
20+
TaxRate,
21+
Transaction,
22+
TravelSettings,
23+
} from '@src/types/onyx';
1224
import type {ErrorFields, PendingAction, PendingFields} from '@src/types/onyx/OnyxCommon';
1325
import type {
1426
ConnectionLastSync,
@@ -508,6 +520,89 @@ function getIneligibleInvitees(employeeList?: PolicyEmployeeList): string[] {
508520
return memberEmailsToExclude;
509521
}
510522

523+
/**
524+
* Get Guide and Account Manager information including their emails/logins and exclusion record.
525+
* Used for filtering Guide/AM from contact lists while allowing manual entry.
526+
*
527+
* @param policy - The policy to get the assigned guide from
528+
* @param accountManagerAccountID - The account manager's account ID from the account object (string from ONYXKEYS.ACCOUNT)
529+
* @param personalDetails - Personal details collection to look up account manager login
530+
* @returns Object containing extracted emails/logins and exclusions record
531+
*/
532+
function getGuideAndAccountManagerInfo(
533+
policy: OnyxEntry<Policy>,
534+
accountManagerAccountID: string | undefined,
535+
personalDetails: OnyxEntry<PersonalDetailsList>,
536+
): {
537+
assignedGuideEmail: string | undefined;
538+
accountManagerLogin: string | undefined;
539+
exclusions: Record<string, boolean>;
540+
} {
541+
const assignedGuideEmail = policy?.assignedGuide?.email?.toLowerCase();
542+
const accountManagerLogin = accountManagerAccountID ? personalDetails?.[Number(accountManagerAccountID)]?.login?.toLowerCase() : undefined;
543+
544+
const exclusions: Record<string, boolean> = {};
545+
if (assignedGuideEmail) {
546+
exclusions[assignedGuideEmail] = true;
547+
}
548+
if (accountManagerLogin) {
549+
exclusions[accountManagerLogin] = true;
550+
}
551+
552+
return {
553+
assignedGuideEmail,
554+
accountManagerLogin,
555+
exclusions,
556+
};
557+
}
558+
559+
/**
560+
* Get soft exclusions (Guide/Account Manager) that should be hidden from auto-suggestions
561+
* but can still be manually entered by the user.
562+
*
563+
* @param policy - The policy to get the assigned guide from
564+
* @param accountManagerAccountID - The account manager's account ID from the account object (string from ONYXKEYS.ACCOUNT)
565+
* @param personalDetails - Personal details collection to look up account manager login
566+
* @returns Record mapping lowercase emails to true for Guide and Account Manager
567+
*/
568+
function getSoftExclusionsForGuideAndAccountManager(
569+
policy: OnyxEntry<Policy>,
570+
accountManagerAccountID: string | undefined,
571+
personalDetails: OnyxEntry<PersonalDetailsList>,
572+
): Record<string, boolean> {
573+
return getGuideAndAccountManagerInfo(policy, accountManagerAccountID, personalDetails).exclusions;
574+
}
575+
576+
/**
577+
* Filter out Guide and Account Manager from a list of items.
578+
* Used for filtering local data (e.g., policy.employeeList) that isn't filtered at the data layer.
579+
*
580+
* @param items - Array of items to filter (must have login or alternateText properties)
581+
* @param assignedGuideEmail - The assigned guide's email (should be lowercase)
582+
* @param accountManagerLogin - The account manager's login (should be lowercase)
583+
* @returns Filtered array with Guide and Account Manager removed
584+
*/
585+
function filterGuideAndAccountManager<T extends {login?: string | null; alternateText?: string | null}>(
586+
items: T[],
587+
assignedGuideEmail: string | undefined,
588+
accountManagerLogin: string | undefined,
589+
): T[] {
590+
return items.filter((item) => {
591+
const itemLogin = item.login?.toLowerCase();
592+
const itemAltText = item.alternateText?.toLowerCase();
593+
594+
if (assignedGuideEmail && (itemLogin === assignedGuideEmail || itemAltText === assignedGuideEmail)) {
595+
return false;
596+
}
597+
598+
if (accountManagerLogin && (itemLogin === accountManagerLogin || itemAltText === accountManagerLogin)) {
599+
return false;
600+
}
601+
602+
return true;
603+
});
604+
}
605+
511606
function getSortedTagKeys(policyTagList: OnyxEntry<PolicyTagLists>): Array<keyof PolicyTagLists> {
512607
if (isEmptyObject(policyTagList)) {
513608
return [];
@@ -1796,6 +1891,9 @@ export {
17961891
getCountOfEnabledTagsOfList,
17971892
getIneligibleInvitees,
17981893
getMemberAccountIDsForWorkspace,
1894+
getGuideAndAccountManagerInfo,
1895+
getSoftExclusionsForGuideAndAccountManager,
1896+
filterGuideAndAccountManager,
17991897
getNumericValue,
18001898
isMultiLevelTags,
18011899
// This will be fixed as part of https://github.com/Expensify/App/issues/66397

src/pages/OnboardingWorkspaceInvite/BaseOnboardingWorkspaceInvite.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, {useEffect, useState} from 'react';
1+
import React, {useEffect, useMemo, useState} from 'react';
22
import {View} from 'react-native';
33
import type {SectionListData} from 'react-native';
44
import Button from '@components/Button';
@@ -28,7 +28,7 @@ import {appendCountryCode} from '@libs/LoginUtils';
2828
import {navigateAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding';
2929
import {getHeaderMessage} from '@libs/OptionsListUtils';
3030
import {addSMSDomainIfPhoneNumber, parsePhoneNumber} from '@libs/PhoneNumber';
31-
import {getIneligibleInvitees, getMemberAccountIDsForWorkspace} from '@libs/PolicyUtils';
31+
import {getIneligibleInvitees, getMemberAccountIDsForWorkspace, getSoftExclusionsForGuideAndAccountManager} from '@libs/PolicyUtils';
3232
import type {OptionData} from '@libs/ReportUtils';
3333
import {completeOnboarding as completeOnboardingReport} from '@userActions/Report';
3434
import {setOnboardingAdminsChatReportID, setOnboardingPolicyID} from '@userActions/Welcome';
@@ -53,6 +53,8 @@ function BaseOnboardingWorkspaceInvite({shouldUseNativeStyles}: BaseOnboardingWo
5353
const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false);
5454
const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {canBeMissing: true, initWithStoredValues: false});
5555
const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false});
56+
const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false});
57+
const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true});
5658
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
5759
const session = useSession();
5860
const {isBetaEnabled} = usePermissions();
@@ -63,12 +65,18 @@ function BaseOnboardingWorkspaceInvite({shouldUseNativeStyles}: BaseOnboardingWo
6365
excludedUsers[login] = true;
6466
}
6567

68+
const softExclusions = useMemo(
69+
() => getSoftExclusionsForGuideAndAccountManager(policy, account?.accountManagerAccountID, personalDetails),
70+
[policy, account?.accountManagerAccountID, personalDetails],
71+
);
72+
6673
const {searchTerm, debouncedSearchTerm, setSearchTerm, availableOptions, selectedOptions, selectedOptionsForDisplay, toggleSelection, areOptionsInitialized, searchOptions} =
6774
useSearchSelector({
6875
selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI,
6976
searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE,
7077
includeUserToInvite: true,
7178
excludeLogins: excludedUsers,
79+
excludeFromSuggestionsOnly: softExclusions,
7280
includeRecentReports: false,
7381
shouldInitialize: didScreenTransitionEnd,
7482
});

src/pages/workspace/WorkspaceInvitePage.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import Navigation from '@libs/Navigation/Navigation';
2525
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
2626
import {getHeaderMessage, getParticipantsOption} from '@libs/OptionsListUtils';
2727
import {addSMSDomainIfPhoneNumber, parsePhoneNumber} from '@libs/PhoneNumber';
28-
import {getIneligibleInvitees, getMemberAccountIDsForWorkspace, goBackFromInvalidPolicy} from '@libs/PolicyUtils';
28+
import {getIneligibleInvitees, getMemberAccountIDsForWorkspace, getSoftExclusionsForGuideAndAccountManager, goBackFromInvalidPolicy} from '@libs/PolicyUtils';
2929
import type {OptionData} from '@libs/ReportUtils';
3030
import type {SettingsNavigatorParamList} from '@navigation/types';
3131
import CONST from '@src/CONST';
@@ -53,6 +53,7 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) {
5353
const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false});
5454
const [invitedEmailsToAccountIDsDraft] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID}`, {canBeMissing: true});
5555
const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false});
56+
const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true});
5657
const openWorkspaceInvitePage = () => {
5758
const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList);
5859
policyOpenWorkspaceInvitePage(route.params.policyID, Object.keys(policyMemberEmailsToAccountIDs));
@@ -77,6 +78,11 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) {
7778
);
7879
}, [policy?.employeeList]);
7980

81+
const softExclusions = useMemo(
82+
() => getSoftExclusionsForGuideAndAccountManager(policy, account?.accountManagerAccountID, personalDetails),
83+
[policy, account?.accountManagerAccountID, personalDetails],
84+
);
85+
8086
const initiallySelectedOptions = useMemo(() => {
8187
if (!invitedEmailsToAccountIDsDraft || !personalDetails) {
8288
return [];
@@ -111,6 +117,7 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) {
111117
searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE,
112118
includeUserToInvite: true,
113119
excludeLogins: excludedUsers,
120+
excludeFromSuggestionsOnly: softExclusions,
114121
includeRecentReports: false,
115122
shouldInitialize: didScreenTransitionEnd,
116123
initialSelected: initiallySelectedOptions,

src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {format} from 'date-fns';
22
import {Str} from 'expensify-common';
3-
import React, {useEffect, useState} from 'react';
3+
import React, {useEffect, useMemo, useState} from 'react';
44
import {Keyboard} from 'react-native';
55
import InteractiveStepWrapper from '@components/InteractiveStepWrapper';
66
import SelectionList from '@components/SelectionList';
@@ -21,7 +21,7 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig
2121
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
2222
import {getHeaderMessage, getSearchValueForPhoneOrEmail, sortAlphabetically} from '@libs/OptionsListUtils';
2323
import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils';
24-
import {getIneligibleInvitees, isDeletedPolicyEmployee} from '@libs/PolicyUtils';
24+
import {filterGuideAndAccountManager, getGuideAndAccountManagerInfo, getIneligibleInvitees, isDeletedPolicyEmployee} from '@libs/PolicyUtils';
2525
import tokenizedSearch from '@libs/tokenizedSearch';
2626
import Navigation from '@navigation/Navigation';
2727
import {setAssignCardStepAndData} from '@userActions/CompanyCards';
@@ -45,6 +45,8 @@ function AssigneeStep({route}: AssigneeStepProps) {
4545
const policy = usePolicy(policyID);
4646
const [assignCard] = useOnyx(ONYXKEYS.ASSIGN_CARD, {canBeMissing: true});
4747
const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false});
48+
const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false});
49+
const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true});
4850
const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false);
4951
const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false, canBeMissing: true});
5052

@@ -54,11 +56,18 @@ function AssigneeStep({route}: AssigneeStepProps) {
5456
excludedUsers[login] = true;
5557
}
5658

59+
const {
60+
assignedGuideEmail,
61+
accountManagerLogin,
62+
exclusions: softExclusions,
63+
} = useMemo(() => getGuideAndAccountManagerInfo(policy, account?.accountManagerAccountID, personalDetails), [policy, account?.accountManagerAccountID, personalDetails]);
64+
5765
const {searchTerm, setSearchTerm, debouncedSearchTerm, availableOptions, selectedOptionsForDisplay, areOptionsInitialized} = useSearchSelector({
5866
selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI,
5967
searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE,
6068
includeUserToInvite: true,
6169
excludeLogins: excludedUsers,
70+
excludeFromSuggestionsOnly: softExclusions,
6271
includeRecentReports: true,
6372
shouldInitialize: didScreenTransitionEnd,
6473
});
@@ -178,10 +187,11 @@ function AssigneeStep({route}: AssigneeStepProps) {
178187
sortAlphabetically(membersDetails, 'text', localeCompare);
179188
}
180189

181-
let assignees = membersDetails;
190+
let assignees = filterGuideAndAccountManager(membersDetails, assignedGuideEmail, accountManagerLogin);
182191
if (debouncedSearchTerm && areOptionsInitialized) {
183192
const searchValueForOptions = getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode).toLowerCase();
184-
const filteredOptions = tokenizedSearch(membersDetails, searchValueForOptions, (option) => [option.text ?? '', option.alternateText ?? '']);
193+
const filteredMembers = filterGuideAndAccountManager(membersDetails, assignedGuideEmail, accountManagerLogin);
194+
const filteredOptions = tokenizedSearch(filteredMembers, searchValueForOptions, (option) => [option.text ?? '', option.alternateText ?? '']);
185195

186196
const options = [
187197
...filteredOptions,

0 commit comments

Comments
 (0)