diff --git a/src/hooks/usePersonalDetailOptions.ts b/src/hooks/usePersonalDetailOptions.ts index aefd8f4c04bb..91a578764003 100644 --- a/src/hooks/usePersonalDetailOptions.ts +++ b/src/hooks/usePersonalDetailOptions.ts @@ -2,7 +2,7 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import memoize, {equivalentArgsComparator} from '@libs/memoize'; -import {createOptionList, filterPersonalDetailsByLogins} from '@libs/PersonalDetailOptionsListUtils'; +import {createOptionList, filterAgentsPersonalDetailsByAccountIDs, filterPersonalDetailsByLogins} from '@libs/PersonalDetailOptionsListUtils'; import type {OptionData, PrivateIsArchivedMap} from '@libs/PersonalDetailOptionsListUtils/types'; import {isOneOnOneChat, isSelfDM} from '@libs/ReportUtils'; import {registerSessionCleanupCallback} from '@libs/SessionCleanup'; @@ -23,6 +23,10 @@ type UseFilteredOptionsConfig = { enabled?: boolean; /** When set, only the personal details whose login is in this set are turned into options */ includeLoginsOnly?: Set; + /** + * When set, only the agents in this set are turned into options. + */ + agentsWhitelist?: Set; /* Whether to include report errors in the option data (default: false) */ shouldStoreReportErrors?: boolean; /* Whether to include brick road indicator status in the option data (default: false) */ @@ -108,7 +112,7 @@ const createReportAttributesSelector = /** * Building an option per personal details entry is the expensive step of this hook and depends only on Onyx values. * Arguments are compared one level deep because the maps are rebuilt each render from those same unchanged values. - * A single entry is held, so a consumer narrowing the list with `includeLoginsOnly` evicts the whole-list one. + * A single entry is held, so a consumer narrowing the list with `includeLoginsOnly` or `agentsWhitelist` evicts the whole-list one. */ const memoizedCreateOptionList = memoize(createOptionList, { maxSize: 1, @@ -128,6 +132,7 @@ type BuildOptionsDataParams = { accountID: number; allPersonalDetails: OnyxEntry; includeLoginsOnly: Set | undefined; + agentsWhitelist: Set | undefined; reports: OnyxCollection; reportAttributes: FilteredReportAttributes | undefined; reportNameValuePairs: PrivateIsArchivedMap | undefined; @@ -142,6 +147,7 @@ function buildOptionsData({ accountID, allPersonalDetails, includeLoginsOnly, + agentsWhitelist, reports, reportAttributes, reportNameValuePairs, @@ -150,9 +156,14 @@ function buildOptionsData({ shouldStoreReportErrors, shouldShowBrickRoadIndicator, }: BuildOptionsDataParams) { - const personalDetails = includeLoginsOnly ? filterPersonalDetailsByLogins(allPersonalDetails, includeLoginsOnly) : allPersonalDetails; + let personalDetails = allPersonalDetails; + if (includeLoginsOnly) { + personalDetails = filterPersonalDetailsByLogins(personalDetails, includeLoginsOnly); + } + if (agentsWhitelist) { + personalDetails = filterAgentsPersonalDetailsByAccountIDs(personalDetails, agentsWhitelist); + } const accountIDToReportIDMap = generateAccountIDToReportIDMap(reports, accountID); - return memoizedCreateOptionList(accountID, personalDetails, accountIDToReportIDMap, reports, reportAttributes, reportNameValuePairs ?? {}, formatPhoneNumber, translate, { shouldStoreReportErrors, shouldShowBrickRoadIndicator, @@ -179,7 +190,7 @@ function buildOptionsData({ * /> */ function usePersonalDetailOptions(config: UseFilteredOptionsConfig = {}): UseFilteredOptionsResult { - const {enabled = true, includeLoginsOnly, shouldStoreReportErrors = false, shouldShowBrickRoadIndicator = false} = config; + const {enabled = true, includeLoginsOnly, agentsWhitelist, shouldStoreReportErrors = false, shouldShowBrickRoadIndicator = false} = config; const reportAttributesSelector = createReportAttributesSelector(shouldStoreReportErrors, shouldShowBrickRoadIndicator); @@ -200,6 +211,7 @@ function usePersonalDetailOptions(config: UseFilteredOptionsConfig = {}): UseFil accountID, allPersonalDetails, includeLoginsOnly, + agentsWhitelist, reports, reportAttributes, reportNameValuePairs, diff --git a/src/hooks/usePersonalDetailSearchSelector/base.ts b/src/hooks/usePersonalDetailSearchSelector/base.ts index 436eb9c41d90..c1db8b56dcc8 100644 --- a/src/hooks/usePersonalDetailSearchSelector/base.ts +++ b/src/hooks/usePersonalDetailSearchSelector/base.ts @@ -41,6 +41,11 @@ type UseSearchSelectorConfig = { /** When set, only the logins in this set are turned into options */ includeLoginsOnly?: Set; + /** + * When set, only the agents in this set are turned into options. + */ + agentsWhitelist?: Set; + includeRecentReports?: boolean; includeCurrentUser?: boolean; includeDomainEmail?: boolean; @@ -187,6 +192,7 @@ function usePersonalDetailSearchSelectorBase({ excludeLogins = CONST.EMPTY_OBJECT, excludeFromSuggestionsOnly = CONST.EMPTY_OBJECT, includeLoginsOnly, + agentsWhitelist, includeRecentReports = true, onSelectionChange, onSingleSelect, @@ -203,7 +209,7 @@ function usePersonalDetailSearchSelectorBase({ }: UseSearchSelectorConfig): UseSearchSelectorReturn { const {translate, formatPhoneNumber} = useLocalize(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const {options: defaultOptions, currentOption, isLoading: isPersonalDetailsOptionsLoading} = usePersonalDetailOptions({enabled: shouldInitialize, includeLoginsOnly}); + const {options: defaultOptions, currentOption, isLoading: isPersonalDetailsOptionsLoading} = usePersonalDetailOptions({enabled: shouldInitialize, includeLoginsOnly, agentsWhitelist}); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); const [loginList] = useOnyx(ONYXKEYS.LOGINS, {selector: expensifyLoginsSelector}); diff --git a/src/libs/PersonalDetailOptionsListUtils/index.ts b/src/libs/PersonalDetailOptionsListUtils/index.ts index a7d79f745d12..296281ccd778 100644 --- a/src/libs/PersonalDetailOptionsListUtils/index.ts +++ b/src/libs/PersonalDetailOptionsListUtils/index.ts @@ -500,6 +500,21 @@ function filterPersonalDetailsByLogins(personalDetails: OnyxEntry, accountIDs: Set): PersonalDetailsList { + const filteredPersonalDetails: PersonalDetailsList = {}; + for (const [accountID, personalDetail] of Object.entries(personalDetails ?? {})) { + if (personalDetail?.isCustomAgent && !accountIDs.has(Number(accountID))) { + continue; + } + filteredPersonalDetails[accountID] = personalDetail; + } + return filteredPersonalDetails; +} + /** * Helper method that returns the text to be used for the header's message and title (if any) */ @@ -565,6 +580,7 @@ export { getValidOptions, createOptionList, filterPersonalDetailsByLogins, + filterAgentsPersonalDetailsByAccountIDs, getHeaderMessage, getUserToInviteOption, getFilteredRecentAttendees, diff --git a/src/pages/workspace/DynamicWorkspaceInvitePage.tsx b/src/pages/workspace/DynamicWorkspaceInvitePage.tsx index bfd1449dc5c3..f62fc1f0fa96 100644 --- a/src/pages/workspace/DynamicWorkspaceInvitePage.tsx +++ b/src/pages/workspace/DynamicWorkspaceInvitePage.tsx @@ -7,6 +7,7 @@ import SelectionListWithSections from '@components/SelectionList/SelectionListWi import type {Section} from '@components/SelectionList/SelectionListWithSections/types'; import withNavigationTransitionEnd from '@components/withNavigationTransitionEnd'; +import useAgents from '@hooks/useAgents'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -66,6 +67,9 @@ function DynamicWorkspaceInvitePageContent({route, policy, invitedEmailsToAccoun const [personalDetails] = useAllPersonalDetails(); const [account] = useOnyx(ONYXKEYS.ACCOUNT); const employeePersonalDetails = usePersonalDetailsByLogins(Object.keys(policy?.employeeList ?? {})); + const {agents} = useAgents(); + const agentsAccountIDs = new Set(agents.map(({accountID}) => accountID)); + const openWorkspaceInvitePage = () => { const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList, employeePersonalDetails); policyOpenWorkspaceInvitePage(route.params.policyID, Object.keys(policyMemberEmailsToAccountIDs)); @@ -139,6 +143,7 @@ function DynamicWorkspaceInvitePageContent({route, policy, invitedEmailsToAccoun initialSelected, initialExtraOptions, shouldKeepSelectedInAvailableOptions: true, + agentsWhitelist: agentsAccountIDs, }); const sections: Array> = useMemo(() => { diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index f7c6489b674d..923024fd1e18 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -329,7 +329,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac highlighted={!!item?.highlighted} focused={getItemScreenNames(item).some((screenName) => activeRoute?.startsWith(screenName))} role={CONST.ROLE.TAB} - badgeText={item.badgeText} + badgeText={item.badgeTranslationKey ? translate(item.badgeTranslationKey) : item.badgeText} shouldIconUseAutoWidthStyle sentryLabel={item.sentryLabel} shouldGreyOutWhenDisabled={hasPolicyCreationError} diff --git a/src/pages/workspace/getWorkspaceMenuItems.ts b/src/pages/workspace/getWorkspaceMenuItems.ts index e22df7e3f2a8..3b094e1eb52d 100644 --- a/src/pages/workspace/getWorkspaceMenuItems.ts +++ b/src/pages/workspace/getWorkspaceMenuItems.ts @@ -85,6 +85,7 @@ type WorkspaceMenuItem = WithSentryLabel & { getRoute: () => Route; brickRoadIndicator?: ValueOf; screenName: WorkspaceTopLevelScreens; + badgeTranslationKey?: TranslationPaths; badgeText?: string; highlighted?: boolean; @@ -265,6 +266,7 @@ function getWorkspaceMenuItems({ getRoute: () => ROUTES.WORKSPACE_AGENTS.getRoute(policyID), screenName: SCREENS.WORKSPACE.AGENTS, sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.AGENTS, + badgeTranslationKey: 'common.beta', }); } diff --git a/tests/unit/usePersonalDetailOptionsTest.tsx b/tests/unit/usePersonalDetailOptionsTest.tsx index af335a5b3eb3..798f68bb0386 100644 --- a/tests/unit/usePersonalDetailOptionsTest.tsx +++ b/tests/unit/usePersonalDetailOptionsTest.tsx @@ -25,6 +25,8 @@ const PERSONAL_DETAILS: PersonalDetailsList = Object.fromEntries( {accountID: 2, login: 'member@example.com', displayName: 'Member'}, {accountID: 3, login: 'outsider@example.com', displayName: 'Outsider'}, {accountID: 4, login: 'other@example.com', displayName: 'Other'}, + {accountID: 5, login: 'agent_5@expensify.ai', displayName: 'Agent A', isCustomAgent: true}, + {accountID: 6, login: 'agent_6@expensify.ai', displayName: 'Agent B', isCustomAgent: true}, ].map((personalDetail) => [personalDetail.accountID, personalDetail]), ); @@ -38,8 +40,8 @@ function Wrapper({children}: {children: React.ReactNode}) { ); } -const renderOptions = async (includeLoginsOnly?: Set) => { - const {result} = renderHook(() => usePersonalDetailOptions({includeLoginsOnly}), {wrapper: Wrapper}); +const renderOptions = async (includeLoginsOnly?: Set, agentsWhitelist?: Set) => { + const {result} = renderHook(() => usePersonalDetailOptions({includeLoginsOnly, agentsWhitelist}), {wrapper: Wrapper}); await waitForBatchedUpdatesWithAct(); return result; }; @@ -73,7 +75,14 @@ describe('usePersonalDetailOptions', () => { it('builds an option for every personal detail when no allowlist is given', async () => { const result = await renderOptions(); - expect(loginsOf(result.current.options)).toEqual(['me@example.com', 'member@example.com', 'other@example.com', 'outsider@example.com']); + expect(loginsOf(result.current.options)).toEqual([ + 'agent_5@expensify.ai', + 'agent_6@expensify.ai', + 'me@example.com', + 'member@example.com', + 'other@example.com', + 'outsider@example.com', + ]); expect(result.current.currentOption?.login).toBe(CURRENT_USER_EMAIL); expect(result.current.isLoading).toBe(false); }); @@ -103,4 +112,16 @@ describe('usePersonalDetailOptions', () => { expect(result.current.options).toEqual([]); expect(result.current.isLoading).toBe(false); }); + + it('excludes agents not in agents allow list', async () => { + const result = await renderOptions(undefined, new Set([5])); + + expect(loginsOf(result.current.options)).toEqual(['agent_5@expensify.ai', 'me@example.com', 'member@example.com', 'other@example.com', 'outsider@example.com']); + }); + + it('excludes all agents if allow list is empty', async () => { + const result = await renderOptions(undefined, new Set()); + + expect(loginsOf(result.current.options)).toEqual(['me@example.com', 'member@example.com', 'other@example.com', 'outsider@example.com']); + }); });