diff --git a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx index a521e6b12fe9..c51a7ff18f56 100644 --- a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx +++ b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx @@ -49,6 +49,7 @@ import {isAdminSelector} from '@src/selectors/Domain'; import {lastExpensesSearchQuerySelector} from '@src/selectors/SearchFilters'; import {emailSelector} from '@src/selectors/Session'; import type * as OnyxTypes from '@src/types/onyx'; +import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type IconAsset from '@src/types/utils/IconAsset'; import type {ReactNode} from 'react'; @@ -134,6 +135,9 @@ type BuildWorkspaceNavigationItemsParams = { /** Login used to evaluate Workspace membership and permissions. */ currentUserLogin: string | undefined; + /** Personal details used to evaluate Workspace membership and permissions. */ + currentUserPersonalDetails: CurrentUserPersonalDetails; + /** Icons consumed by the shared Workspace menu source. */ icons: Parameters[0]['icons']; @@ -295,6 +299,7 @@ function buildWorkspaceNavigationItems({ policies, policyCategories, currentUserLogin, + currentUserPersonalDetails, icons, isOffline, isVendorMatchingBetaEnabled, @@ -316,6 +321,7 @@ function buildWorkspaceNavigationItems({ policy, policyID: policy.id, currentUserLogin, + currentUserPersonalDetails, icons, policyCategories: policyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy.id}`], isVendorMatchingBetaEnabled, @@ -454,6 +460,7 @@ function useNavigationSuggestions(query: string, shouldWatchForApprovals = true) policies: allPolicies, policyCategories, currentUserLogin, + currentUserPersonalDetails, icons, isOffline: !!isOffline, isVendorMatchingBetaEnabled: isBetaEnabled(CONST.BETAS.VENDOR_MATCHING), diff --git a/src/hooks/useAgents.ts b/src/hooks/useAgents.ts index d4d359b7c73a..ea5fdb91b77a 100644 --- a/src/hooks/useAgents.ts +++ b/src/hooks/useAgents.ts @@ -78,14 +78,14 @@ function useAgents() { let ownerDisplayName: string | undefined; let ownerPolicyID: string | undefined; if (agent.ownerType === CONST.AGENTS.OWNER_TYPE.PERSON) { - const ownerDetails = personalDetailsList?.[agent.ownerAccountID]; + const ownerDetails = agent.ownerAccountID ? personalDetailsList?.[agent.ownerAccountID] : undefined; ownerDisplayName = ownerDetails?.displayName ?? ownerDetails?.login; } else if (agent.ownerType === CONST.AGENTS.OWNER_TYPE.WORKSPACE) { - const policy = policiesByAccountID.get(agent.ownerAccountID); + const policy = agent.ownerAccountID ? policiesByAccountID.get(agent.ownerAccountID) : undefined; ownerDisplayName = policy?.name; ownerPolicyID = policy?.id; } else if (agent.ownerType === CONST.AGENTS.OWNER_TYPE.DOMAIN) { - const ownerDetails = personalDetailsList?.[agent.ownerAccountID]; + const ownerDetails = agent.ownerAccountID ? personalDetailsList?.[agent.ownerAccountID] : undefined; ownerDisplayName = ownerDetails?.login ? Str.extractEmailDomain(ownerDetails.login) : undefined; } diff --git a/src/hooks/useWorkspaceMembers.ts b/src/hooks/useWorkspaceMembers.ts index 3e7e1d5a7147..f492151be1dd 100644 --- a/src/hooks/useWorkspaceMembers.ts +++ b/src/hooks/useWorkspaceMembers.ts @@ -16,6 +16,9 @@ import useNetwork from './useNetwork'; import {usePersonalDetailsByLogins} from './usePersonalDetailByLogin'; import {useAllPersonalDetails} from './usePersonalDetails'; +/** + * Hook that returns a workspace's members joined with personal details, filling in fallback identities when details haven't loaded and filtering out Expensify team members on Expensify-owned policies. + */ function useWorkspaceMembers(policy: OnyxEntry) { const policyID = policy?.id; const policyOwner = policy?.owner; diff --git a/src/libs/API/parameters/CreateCompanyAgentParams.ts b/src/libs/API/parameters/CreateCompanyAgentParams.ts new file mode 100644 index 000000000000..0236edb08004 --- /dev/null +++ b/src/libs/API/parameters/CreateCompanyAgentParams.ts @@ -0,0 +1,19 @@ +import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types'; + +import type CONST from '@src/CONST'; + +import type {ValueOf} from 'type-fest'; + +type CreateCompanyAgentParams = { + prompt: string; + policyID: string; + role: ValueOf; + firstName?: string; + customExpensifyAvatarID?: string; + file?: File | CustomRNImageManipulatorResult; + optimisticAccountID: number; + optimisticReportID: string; + createdReportActionID: string; +}; + +export default CreateCompanyAgentParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 2a6acdbc69fd..dc96b366774d 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -713,6 +713,7 @@ export type {default as UpdateDomainSecurityGroupParams} from './UpdateDomainSec export type {default as SetDefaultDomainSecurityGroupParams} from './SetDefaultDomainSecurityGroupParams'; export type {default as DeleteDomainSecurityGroupParams} from './DeleteDomainSecurityGroupParams'; export type {default as CreateAgentParams} from './CreateAgentParams'; +export type {default as CreateCompanyAgentParams} from './CreateCompanyAgentParams'; export type {default as CreateDomainSecurityGroupParams} from './CreateDomainSecurityGroupParams'; export type {default as UpdateAgentNameParams} from './UpdateAgentNameParams'; export type {default as UpdateAgentPromptParams} from './UpdateAgentPromptParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 93ec38c33a27..8e7fcdd32c3f 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -786,6 +786,7 @@ const WRITE_COMMANDS = { SET_DEFAULT_DOMAIN_SECURITY_GROUP: 'SetDefaultDomainSecurityGroup', DELETE_DOMAIN_SECURITY_GROUP: 'DeleteDomainSecurityGroup', CREATE_AGENT: 'CreateAgent', + CREATE_COMPANY_AGENT: 'CreateCompanyAgent', CREATE_DOMAIN_SECURITY_GROUP: 'CreateDomainSecurityGroup', UPDATE_AGENT_NAME: 'UpdateAgentName', UPDATE_AGENT_PROMPT: 'UpdateAgentPrompt', @@ -1610,6 +1611,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_DEFAULT_DOMAIN_SECURITY_GROUP]: Parameters.SetDefaultDomainSecurityGroupParams; [WRITE_COMMANDS.DELETE_DOMAIN_SECURITY_GROUP]: Parameters.DeleteDomainSecurityGroupParams; [WRITE_COMMANDS.CREATE_AGENT]: Parameters.CreateAgentParams; + [WRITE_COMMANDS.CREATE_COMPANY_AGENT]: Parameters.CreateCompanyAgentParams; [WRITE_COMMANDS.UPDATE_AGENT_NAME]: Parameters.UpdateAgentNameParams; [WRITE_COMMANDS.UPDATE_AGENT_PROMPT]: Parameters.UpdateAgentPromptParams; [WRITE_COMMANDS.UPDATE_AGENT_AVATAR]: Parameters.UpdateAgentAvatarParams; diff --git a/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx index c6acf30a7184..e219d7e024f5 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx @@ -4,6 +4,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import createSplitNavigator from '@libs/Navigation/AppNavigator/createSplitNavigator'; import useSplitNavigatorScreenOptions from '@libs/Navigation/AppNavigator/useSplitNavigatorScreenOptions'; +import withAgentAccessDenied from '@libs/Navigation/AppNavigator/withAgentAccessDenied'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceNavigatorParamList, WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; @@ -23,7 +24,7 @@ const CENTRAL_PANE_WORKSPACE_SCREENS = { [SCREENS.WORKSPACE.WORKFLOWS]: () => require('../../../../pages/workspace/workflows/WorkspaceWorkflowsPageRevamp').default, [SCREENS.WORKSPACE.INVOICES]: () => require('../../../../pages/workspace/invoices/WorkspaceInvoicesPage').default, [SCREENS.WORKSPACE.MEMBERS]: () => require('../../../../pages/workspace/WorkspaceMembersPage').default, - [SCREENS.WORKSPACE.AGENTS]: () => require('../../../../pages/workspace/WorkspaceAgentsPage').default, + [SCREENS.WORKSPACE.AGENTS]: withAgentAccessDenied(() => require('../../../../pages/workspace/WorkspaceAgentsPage').default), [SCREENS.WORKSPACE.ROOMS]: () => require('../../../../pages/workspace/rooms/WorkspaceRoomsPage').default, [SCREENS.WORKSPACE.ACCOUNTING.ROOT]: () => require('../../../../pages/workspace/accounting/PolicyAccountingPage').default, [SCREENS.WORKSPACE.HR]: () => require('../../../../pages/workspace/hr/WorkspaceHRPage').default, diff --git a/src/libs/Navigation/AppNavigator/withAgentAccessDenied.tsx b/src/libs/Navigation/AppNavigator/withAgentAccessDenied.tsx index 2904b812cb42..d3f77bdacffc 100644 --- a/src/libs/Navigation/AppNavigator/withAgentAccessDenied.tsx +++ b/src/libs/Navigation/AppNavigator/withAgentAccessDenied.tsx @@ -3,8 +3,10 @@ import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView import useIsAgentAccount from '@hooks/useIsAgentAccount'; import useOnyx from '@hooks/useOnyx'; +import getTopmostFullScreenRoute from '@libs/Navigation/helpers/getTopmostFullScreenRoute'; import Navigation from '@libs/Navigation/Navigation'; +import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -46,6 +48,13 @@ function withAgentAccessDenied(getComponent: () => React.ComponentType): () => R forceReplace: true, }); }; + const resetWorkspaceTabAndNavigateToHome = () => { + Navigation.popRootToTop(); + Navigation.navigate(ROUTES.WORKSPACES_LIST.getRoute(), { + forceReplace: true, + afterTransition: () => Navigation.navigate(ROUTES.HOME), + }); + }; // The guarded screen can be open inside a modal/RHP (e.g. the agent-edit page the owner was // on when they tapped "Copilot into account"), or an unguarded RHP (e.g. the agent DM) can be @@ -59,6 +68,12 @@ function withAgentAccessDenied(getComponent: () => React.ComponentType): () => R return; } + const topmostFullScreenRoute = getTopmostFullScreenRoute(); + if (topmostFullScreenRoute?.name === NAVIGATORS.WORKSPACE_NAVIGATOR || topmostFullScreenRoute?.name === NAVIGATORS.WORKSPACE_SPLIT_NAVIGATOR) { + resetWorkspaceTabAndNavigateToHome(); + return; + } + redirectToProfile(); }); }, [isAgent]); diff --git a/src/libs/actions/Agent.ts b/src/libs/actions/Agent.ts index 012fbde98e98..8b53c3784ae0 100644 --- a/src/libs/actions/Agent.ts +++ b/src/libs/actions/Agent.ts @@ -21,6 +21,7 @@ import type PolicyEmployee from '@src/types/onyx/PolicyEmployee'; import type {AnyOnyxUpdate} from '@src/types/onyx/Request'; import type {OnyxCollection, OnyxCollectionInputValue, OnyxUpdate} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import Onyx from 'react-native-onyx'; @@ -40,15 +41,14 @@ function openProfilePage() { read(READ_COMMANDS.OPEN_PROFILE_PAGE, null); } -function createAgent( - firstName: string | undefined, +function prepareNewAgentOnyxData( + currentUserAccountID: number, + currentUserLogin: string | undefined, prompt: string, - ownerAccountID: number, - ownerLogin: string | undefined, + policy?: Policy, + firstName?: string, customExpensifyAvatarID?: string, - file?: File | CustomRNImageManipulatorResult, optimisticAvatarURI?: string, - policyID?: string, ) { const optimisticAccountID = Number(generateReportID()); @@ -59,7 +59,7 @@ function createAgent( avatarURI = optimisticAvatarURI; } - // Optimistic row for Settings > Agents until CREATE_AGENT responds. Omit `login` (server-assigned). + // Optimistic row for Settings > Agents until BE responds. Omit `login` (server-assigned). const optimisticPersonalDetail = { accountID: optimisticAccountID, displayName: firstName, @@ -71,16 +71,17 @@ function createAgent( // Generate the DM report ID client-side so it can be written to Onyx and opened immediately. const optimisticReportID = generateReportID(); const optimisticChatReport = buildOptimisticChatReport({ - participantList: [ownerAccountID, optimisticAccountID], - ownerAccountID, + participantList: [currentUserAccountID, optimisticAccountID], + ownerAccountID: currentUserAccountID, optimisticReportID, - currentUserAccountID: ownerAccountID, + currentUserAccountID, }); + // Reuse the client-generated CREATED action ID when CreateAgent creates the DM. const createdReportActionID = rand64(); const optimisticCreatedAction = buildOptimisticCreatedReportAction({ - emailCreatingAction: ownerLogin ?? CONST.REPORT.OWNER_EMAIL_FAKE, - currentUserAccountID: ownerAccountID, + emailCreatingAction: currentUserLogin ?? CONST.REPORT.OWNER_EMAIL_FAKE, + currentUserAccountID, optimisticReportActionID: createdReportActionID, }); @@ -88,7 +89,11 @@ function createAgent( { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.AGENT}${optimisticAccountID}`, - value: {prompt, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + value: { + prompt, + ownerAccountID: policy?.policyAccountID, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, }, { onyxMethod: Onyx.METHOD.SET, @@ -128,8 +133,6 @@ function createAgent( onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.AGENT}${optimisticAccountID}`, value: { - prompt, - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, errors: getMicroSecondOnyxErrorWithTranslationKey('agentsPage.error.genericAdd'), }, }, @@ -150,11 +153,63 @@ function createAgent( }, ]; + return {optimisticData, successData, failureData, optimisticAccountID, avatarURI, optimisticReportID, createdReportActionID, optimisticPersonalDetailPromise}; +} + +function createAgent( + firstName: string | undefined, + prompt: string, + currentUserAccountID: number, + currentUserLogin: string | undefined, + customExpensifyAvatarID?: string, + file?: File | CustomRNImageManipulatorResult, + optimisticAvatarURI?: string, +) { + const {optimisticData, successData, failureData, optimisticAccountID, avatarURI, optimisticReportID, createdReportActionID, optimisticPersonalDetailPromise} = prepareNewAgentOnyxData( + currentUserAccountID, + currentUserLogin, + prompt, + undefined, + firstName, + customExpensifyAvatarID, + optimisticAvatarURI, + ); + write( // Flag this as the user's personal agent; the backend makes personal agents a full co-pilot of the creator. // optimisticReportID is the owner<->agent DM's optimistic reportID; CreateAgent creates the DM under this exact ID. WRITE_COMMANDS.CREATE_AGENT, - {firstName, prompt, customExpensifyAvatarID, file, policyID, optimisticAccountID: String(optimisticAccountID), isPersonalAgent: true, optimisticReportID, createdReportActionID}, + {firstName, prompt, customExpensifyAvatarID, file, optimisticAccountID: String(optimisticAccountID), isPersonalAgent: true, optimisticReportID, createdReportActionID}, + {optimisticData, successData, failureData}, + ); + + return {optimisticAccountID, avatarURI, optimisticReportID, optimisticPersonalDetailPromise}; +} + +function createCompanyAgent( + currentUserAccountID: number, + currentUserLogin: string | undefined, + prompt: string, + policy: Policy, + role: ValueOf, + firstName?: string, + customExpensifyAvatarID?: string, + file?: File | CustomRNImageManipulatorResult, + optimisticAvatarURI?: string, +) { + const {optimisticData, successData, failureData, optimisticAccountID, avatarURI, optimisticReportID, createdReportActionID, optimisticPersonalDetailPromise} = prepareNewAgentOnyxData( + currentUserAccountID, + currentUserLogin, + prompt, + policy, + firstName, + customExpensifyAvatarID, + optimisticAvatarURI, + ); + + write( + WRITE_COMMANDS.CREATE_COMPANY_AGENT, + {prompt, policyID: policy.id, role, firstName, customExpensifyAvatarID, file, optimisticAccountID, optimisticReportID, createdReportActionID}, {optimisticData, successData, failureData}, ); @@ -433,6 +488,7 @@ export { openAgentsPage, openProfilePage, createAgent, + createCompanyAgent, setNewAgentTemplate, clearNewAgentTemplate, clearAgentError, diff --git a/src/pages/settings/Agents/DynamicAddAgentPage.tsx b/src/pages/settings/Agents/DynamicAddAgentPage.tsx index fd5495ba6906..7254ad98bba4 100644 --- a/src/pages/settings/Agents/DynamicAddAgentPage.tsx +++ b/src/pages/settings/Agents/DynamicAddAgentPage.tsx @@ -1,5 +1,6 @@ import UserAvatar from '@components/Avatar/UserAvatar'; import AvatarButtonWithIcon from '@components/AvatarButtonWithIcon'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import CollapsibleHeaderOnKeyboard from '@components/CollapsibleHeaderOnKeyboard'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; @@ -9,6 +10,8 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; +import ValuePicker from '@components/ValuePicker'; +import {getAssignableWorkspaceMemberRoleItems} from '@components/WorkspaceMemberRoleList'; import useBeforeRemove from '@hooks/useBeforeRemove'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -17,6 +20,7 @@ import useKeyboardState from '@hooks/useKeyboardState'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import usePolicy from '@hooks/usePolicy'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -25,13 +29,15 @@ import {buildFileFromAvatarCropResult} from '@libs/AvatarCropUtils'; import {AGENT_AVATARS} from '@libs/Avatars/AgentAvatarCatalog'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import isInLandscapeModeUtil from '@libs/isInLandscapeMode'; +import Log from '@libs/Log'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {canMemberWrite} from '@libs/PolicyUtils'; import type {AvatarSource} from '@libs/UserAvatarUtils'; -import {clearNewAgentAvatarDraft, clearNewAgentTemplate, createAgent, setNewAgentAvatarPreset} from '@userActions/Agent'; +import {clearNewAgentAvatarDraft, clearNewAgentTemplate, createAgent, createCompanyAgent, setNewAgentAvatarPreset} from '@userActions/Agent'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -63,6 +69,7 @@ type DynamicAddAgentPageContentProps = { function DynamicAddAgentPageContent({route, template}: DynamicAddAgentPageContentProps) { const StyleUtils = useStyleUtils(); const policyID = route.params?.policyID; + const policy = usePolicy(policyID); const {translate} = useLocalize(); const styles = useThemeStyles(); const {windowWidth, windowHeight} = useWindowDimensions(); @@ -73,7 +80,7 @@ function DynamicAddAgentPageContent({route, template}: DynamicAddAgentPageConten const shouldAutoGrowPromptInput = Platform.OS !== 'web' && !isInLandscapeMode; const shouldUseScrollableLayout = shouldAutoGrowPromptInput || isInLandscapeMode; const shouldShrinkPromptInput = isInLandscapeMode && isKeyboardActive; - const {accountID: ownerAccountID, login: ownerLogin, displayName} = useCurrentUserPersonalDetails(); + const {accountID: currentUserAccountID, login: currentUserLogin, displayName} = useCurrentUserPersonalDetails(); const defaultAgentName = template?.name ?? (displayName ? translate('addAgentPage.defaultAgentName', displayName) : undefined); const defaultPrompt = template?.prompt ?? translate('addAgentPage.defaultPrompt'); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Pencil']); @@ -82,6 +89,15 @@ function DynamicAddAgentPageContent({route, template}: DynamicAddAgentPageConten const hasSubmittedRef = useRef(false); const hasLeftPageRef = useRef(false); const formRef = useRef(null); + const availableRoleItems = getAssignableWorkspaceMemberRoleItems(translate, undefined, policy, currentUserLogin ?? ''); + const defaultRole = availableRoleItems.at(0)?.value; + const roleOptions = availableRoleItems.map((roleItem) => ({ + label: roleItem.text, + value: roleItem.value, + description: roleItem.alternateText, + })); + const isAddingCompanyAgent = !!policyID; + const canWriteAgents = isAddingCompanyAgent ? canMemberWrite(policy, currentUserLogin ?? '', CONST.POLICY.POLICY_FEATURE.AGENTS) : true; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.AGENT_ADD.path); const submitFormOnModEnter = (event: TextInputKeyPressEvent | KeyboardEvent) => { @@ -136,6 +152,9 @@ function DynamicAddAgentPageContent({route, template}: DynamicAddAgentPageConten if (!values[INPUT_IDS.PROMPT].trim()) { errors[INPUT_IDS.PROMPT] = translate('common.error.fieldRequired'); } + if (!values[INPUT_IDS.ROLE]) { + errors[INPUT_IDS.ROLE] = translate('common.error.fieldRequired'); + } return errors; }; @@ -143,13 +162,39 @@ function DynamicAddAgentPageContent({route, template}: DynamicAddAgentPageConten hasSubmittedRef.current = true; const firstName = values[INPUT_IDS.FIRST_NAME].trim() || defaultAgentName; const prompt = values[INPUT_IDS.PROMPT].trim(); - - // Pure optimistic flow: `createAgent` writes the agent and the owner<->agent DM to Onyx under a - // reportID it generates client-side, and CreateAgent creates the DM under that exact ID (see - // CreateAgent.cpp), so we can navigate to the DM immediately, online or offline, without waiting. - const {optimisticReportID, optimisticPersonalDetailPromise} = uploadedAvatar?.uri - ? createAgent(firstName, prompt, ownerAccountID, ownerLogin, undefined, buildFileFromAvatarCropResult(uploadedAvatar), uploadedAvatar.uri, policyID) - : createAgent(firstName, prompt, ownerAccountID, ownerLogin, selectedPresetID ?? AGENT_AVATARS.getRandomID(), undefined, undefined, policyID); + const role = values[INPUT_IDS.ROLE]; + + const avatarArgs = uploadedAvatar?.uri + ? {file: buildFileFromAvatarCropResult(uploadedAvatar), optimisticAvatarURI: uploadedAvatar.uri} + : {customExpensifyAvatarID: selectedPresetID ?? AGENT_AVATARS.getRandomID()}; + + let result: { + optimisticAccountID: number; + avatarURI: string | undefined; + optimisticReportID: string; + optimisticPersonalDetailPromise: Promise; + }; + if (isAddingCompanyAgent) { + if (!policy) { + Log.warn('Invalid policy is used to when trying to create company agent'); + return; + } + result = createCompanyAgent( + currentUserAccountID, + currentUserLogin, + prompt, + policy, + role, + firstName, + avatarArgs.customExpensifyAvatarID, + avatarArgs.file, + avatarArgs.optimisticAvatarURI, + ); + } else { + result = createAgent(firstName, prompt, currentUserAccountID, currentUserLogin, avatarArgs.customExpensifyAvatarID, avatarArgs.file, avatarArgs.optimisticAvatarURI); + } + const {optimisticReportID, optimisticPersonalDetailPromise} = result; + const backToAgentsListRoute = isAddingCompanyAgent ? ROUTES.WORKSPACE_AGENTS.getRoute(policy?.id) : ROUTES.SETTINGS_AGENTS; clearNewAgentTemplate(); @@ -158,6 +203,9 @@ function DynamicAddAgentPageContent({route, template}: DynamicAddAgentPageConten // regardless of window size. getIsNarrowLayout() reflects the actual window width. const isNarrowLayout = getIsNarrowLayout(); + // Pure optimistic flow: `createAgent/createCompanyAgent` writes the agent and the owner<->agent DM to Onyx under a + // reportID it generates client-side, and CreateAgent/CreateCompanyAgent creates the DM under that exact ID, + // so we can navigate to the DM immediately, online or offline, without waiting. optimisticPersonalDetailPromise.then(() => { // The user left the builder before the write resolved, so don't pull them into the DM from whatever screen is now active. if (hasLeftPageRef.current) { @@ -168,7 +216,7 @@ function DynamicAddAgentPageContent({route, template}: DynamicAddAgentPageConten if (isNarrowLayout) { // Reveal the DM under the modal before dismissing so we navigate directly to it in one animation, // instead of dismissing to the agents list first and navigating to the DM afterward. - Navigation.revealRouteBeforeDismissingModal(ROUTES.REPORT_WITH_ID.getRoute(optimisticReportID, undefined, undefined, ROUTES.SETTINGS_AGENTS), { + Navigation.revealRouteBeforeDismissingModal(ROUTES.REPORT_WITH_ID.getRoute(optimisticReportID, undefined, undefined, backToAgentsListRoute), { afterTransition: () => { clearNewAgentAvatarDraft(); }, @@ -206,79 +254,93 @@ function DynamicAddAgentPageContent({route, template}: DynamicAddAgentPageConten offlineIndicatorStyle={styles.mtAuto} shouldEnableMaxHeight={shouldAutoGrowPromptInput} > - - Navigation.goBack(backPath)} - /> - - - - - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_ADD_AVATAR.getRoute()))} - editIcon={expensifyIcons.Pencil} - editIconStyle={styles.smallEditIconAccount} - sentryLabel={CONST.SENTRY_LABEL.ADD_AGENT_PAGE.AVATAR} - /> - - + + Navigation.goBack(backPath)} /> - { - promptTopOffsetRef.current = event.nativeEvent.layout.y; - }} - > + + + + + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_ADD_AVATAR.getRoute()))} + editIcon={expensifyIcons.Pencil} + editIconStyle={styles.smallEditIconAccount} + sentryLabel={CONST.SENTRY_LABEL.ADD_AGENT_PAGE.AVATAR} + /> + + {isAddingCompanyAgent && ( + + + + )} + { + promptTopOffsetRef.current = event.nativeEvent.layout.y; + }} + > + + + {`${translate('addAgentPage.copilotNote')} ${translate('workspace.rules.agentRules.disclaimer')}`} - {`${translate('addAgentPage.copilotNote')} ${translate('workspace.rules.agentRules.disclaimer')}`} - - + + ); } diff --git a/src/pages/settings/Agents/DynamicNewAgentPage.tsx b/src/pages/settings/Agents/DynamicNewAgentPage.tsx index e71419de5e6b..aadb3e3884fb 100644 --- a/src/pages/settings/Agents/DynamicNewAgentPage.tsx +++ b/src/pages/settings/Agents/DynamicNewAgentPage.tsx @@ -1,5 +1,6 @@ import ActivityIndicator from '@components/ActivityIndicator'; import BlockingView from '@components/BlockingViews/BlockingView'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; @@ -7,10 +8,12 @@ import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import usePolicy from '@hooks/usePolicy'; import useStyleUtils from '@hooks/useStyleUtils'; import useSuggestedAgents from '@hooks/useSuggestedAgents'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -21,6 +24,7 @@ import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/crea import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {canMemberWrite} from '@libs/PolicyUtils'; import {clearNewAgentAvatarDraft, clearNewAgentTemplate, setNewAgentTemplate, getAgentTemplates} from '@userActions/Agent'; @@ -79,16 +83,20 @@ type DynamicNewAgentPageProps = PlatformStackScreenProps 0; const shouldShowLoadingIndicator = isLoading && !hasTemplates && !isOffline; const shouldShowEmptyState = !hasTemplates && (!isLoading || isOffline); + const isAddingCompanyAgent = !!policyID; + const canWriteAgents = isAddingCompanyAgent ? canMemberWrite(policy, currentUserLogin ?? '', CONST.POLICY.POLICY_FEATURE.AGENTS) : true; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.AGENT_NEW.path); useEffect(() => { @@ -185,11 +193,13 @@ function DynamicNewAgentPage({route}: DynamicNewAgentPageProps) { shouldEnableMaxHeight offlineIndicatorStyle={styles.mtAuto} > - Navigation.goBack(backPath)} - /> - {body} + + Navigation.goBack(backPath)} + /> + {body} + ); } diff --git a/src/pages/workspace/WorkspaceAgentsPage.tsx b/src/pages/workspace/WorkspaceAgentsPage.tsx index 152ff8862c4b..b203a3966b25 100644 --- a/src/pages/workspace/WorkspaceAgentsPage.tsx +++ b/src/pages/workspace/WorkspaceAgentsPage.tsx @@ -1,3 +1,4 @@ +import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import RenderHTML from '@components/RenderHTML'; @@ -17,6 +18,7 @@ import useWorkspaceAgents from '@hooks/useWorkspaceAgents'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {canMemberWrite} from '@libs/PolicyUtils'; @@ -24,6 +26,7 @@ import {canMemberWrite} from '@libs/PolicyUtils'; import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; import CONST from '@src/CONST'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; @@ -55,9 +58,26 @@ function WorkspaceAgentsPage({route}: WorkspaceAgentsPageProps) { useWorkspaceDocumentTitle(policy?.name, 'agentsPage.title'); - // The new agent functionality will be added after CreateCompanyAgent is exposed - const newAgentButton = canWriteAgents && hasAgents ? null : null; - const emptyStateButtons = canWriteAgents ? [] : undefined; + const newAgentButton = + canWriteAgents && hasAgents ? ( + + ) : null; + const emptyStateButtons = canWriteAgents + ? [ + { + icon: icons.Plus, + buttonText: translate('agentsPage.newAgent'), + buttonVariant: CONST.BUTTON_VARIANT.SUCCESS, + buttonAction: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.AGENT_NEW.getRoute())), + }, + ] + : undefined; const bulkActionsButtonOptions: Array>> = [ { diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index a25b2c687b20..f7c6489b674d 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -10,6 +10,7 @@ import ScrollView from '@components/ScrollView'; import useCardFeedErrors from '@hooks/useCardFeedErrors'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useGetReceiptPartnersIntegrationData from '@hooks/useGetReceiptPartnersIntegrationData'; import useHasApprovalWorkflowWithNonMemberApprover from '@hooks/useHasApprovalWorkflowWithNonMemberApprover'; import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; @@ -83,6 +84,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const {convertToDisplayString} = useCurrencyListActions(); const {isBetaEnabled} = usePermissions(); const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const isFocused = useIsFocused(); const isWorkspacesTabFocused = useIsWorkspacesTabFocused(); @@ -194,6 +196,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac policy, policyID, currentUserLogin: currentUserLogin ?? undefined, + currentUserPersonalDetails, icons: expensifyIcons, isConnectionInProgress: isConnectionInProgress(connectionSyncProgress, policy), policyCategories, diff --git a/src/pages/workspace/getWorkspaceMenuItems.ts b/src/pages/workspace/getWorkspaceMenuItems.ts index 2fa26cf6f11f..e22df7e3f2a8 100644 --- a/src/pages/workspace/getWorkspaceMenuItems.ts +++ b/src/pages/workspace/getWorkspaceMenuItems.ts @@ -36,6 +36,7 @@ import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; +import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type {PolicyFeatureName} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -102,6 +103,8 @@ type GetWorkspaceMenuItemsParams = { policyID: string | undefined; /** Login used to determine the member's feature access. */ currentUserLogin?: string; + /** Personal details used to determine the member's feature access. */ + currentUserPersonalDetails?: CurrentUserPersonalDetails; /** Lazily loaded icons used by the menu items. */ icons: WorkspaceMenuIconMap; /** Whether an accounting connection is currently syncing. */ @@ -134,6 +137,7 @@ function getWorkspaceMenuItems({ policy, policyID, currentUserLogin, + currentUserPersonalDetails, icons, isConnectionInProgress = false, policyCategories, @@ -148,6 +152,7 @@ function getWorkspaceMenuItems({ isUnifiedConnectionsBetaEnabled = false, convertToDisplayString, }: GetWorkspaceMenuItemsParams): WorkspaceMenuItem[] { + const isAgentAccount = currentUserPersonalDetails?.isCustomAgent; const canReadPolicyFeature = (policyFeature: PolicyFeature) => canMemberRead(policy, currentUserLogin ?? '', policyFeature); const canReadMoreFeatures = canReadPolicyFeature(CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); const shouldShowProtectedItems = [ @@ -252,7 +257,7 @@ function getWorkspaceMenuItems({ return canReadPolicyFeature(CONST.POLICY.POLICY_FEATURE.MEMBERS); }); - if (isGroupPolicy(policy) && canReadPolicyFeature(CONST.POLICY.POLICY_FEATURE.AGENTS) && isCompanyAgentsBetaEnabled) { + if (isGroupPolicy(policy) && canReadPolicyFeature(CONST.POLICY.POLICY_FEATURE.AGENTS) && isCompanyAgentsBetaEnabled && !isAgentAccount) { const membersItemIndex = items.findIndex((item) => item.screenName === SCREENS.WORKSPACE.MEMBERS); items.splice(membersItemIndex + 1, 0, { translationKey: 'agentsPage.title', diff --git a/src/types/form/AddAgentForm.ts b/src/types/form/AddAgentForm.ts index db9a02105c92..2be221400a23 100644 --- a/src/types/form/AddAgentForm.ts +++ b/src/types/form/AddAgentForm.ts @@ -1,3 +1,5 @@ +import type CONST from '@src/CONST'; + import type {ValueOf} from 'type-fest'; import type Form from './Form'; @@ -5,6 +7,7 @@ import type Form from './Form'; const INPUT_IDS = { FIRST_NAME: 'firstName', PROMPT: 'prompt', + ROLE: 'role', } as const; type InputID = ValueOf; @@ -14,6 +17,7 @@ type AddAgentForm = Form< { [INPUT_IDS.FIRST_NAME]: string; [INPUT_IDS.PROMPT]: string; + [INPUT_IDS.ROLE]: ValueOf; } >; diff --git a/src/types/onyx/Agent.ts b/src/types/onyx/Agent.ts index c13225ad111f..8e7b97608031 100644 --- a/src/types/onyx/Agent.ts +++ b/src/types/onyx/Agent.ts @@ -13,10 +13,10 @@ type Agent = OnyxValueWithOfflineFeedback<{ prompt: string; /** The agent owner account ID */ - ownerAccountID: number; + ownerAccountID?: number; /** The agent owner account type (person, workspace or domain) */ - ownerType: AgentOwnerType; + ownerType?: AgentOwnerType; /** Errors from the last failed action */ errors?: Errors | null; diff --git a/tests/unit/AgentActionTest.ts b/tests/unit/AgentActionTest.ts index a3236debd56f..4ea9c96021e8 100644 --- a/tests/unit/AgentActionTest.ts +++ b/tests/unit/AgentActionTest.ts @@ -3,7 +3,7 @@ import {WRITE_COMMANDS} from '@libs/API/types'; import Navigation from '@libs/Navigation/Navigation'; import {isRecord} from '@libs/ObjectUtils'; -import {clearAgentAvatarUpdateError, clearAgentUpdateError, createAgent, deleteAgent, updateAgentAvatar, updateAgentName, updateAgentPrompt} from '@userActions/Agent'; +import {clearAgentAvatarUpdateError, clearAgentUpdateError, createAgent, createCompanyAgent, deleteAgent, updateAgentAvatar, updateAgentName, updateAgentPrompt} from '@userActions/Agent'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -227,9 +227,16 @@ describe('createAgent', () => { }); it('forwards policyID in the write params when provided', () => { - createAgent('Bot', 'My prompt', OWNER_ACCOUNT_ID, OWNER_LOGIN, undefined, undefined, undefined, 'POLICY_42'); - - expect(mockWrite).toHaveBeenCalledWith(WRITE_COMMANDS.CREATE_AGENT, expect.objectContaining({firstName: 'Bot', prompt: 'My prompt', policyID: 'POLICY_42'}), expect.any(Object)); + const policy = { + ...createRandomPolicy(0), + id: 'POLICY_42', + }; + createCompanyAgent(OWNER_ACCOUNT_ID, OWNER_LOGIN, 'My prompt', policy, CONST.POLICY.ROLE.ADMIN, 'Bot'); + expect(mockWrite).toHaveBeenCalledWith( + WRITE_COMMANDS.CREATE_COMPANY_AGENT, + expect.objectContaining({firstName: 'Bot', prompt: 'My prompt', policyID: 'POLICY_42'}), + expect.any(Object), + ); }); it('returns the optimistic accountID and avatarURI so callers can chain follow-up navigation', () => { @@ -326,7 +333,7 @@ describe('createAgent', () => { }); it('omits login on the optimistic personal detail entry — the real email is server-assigned', () => { - createAgent('Bot', 'My prompt', OWNER_ACCOUNT_ID, OWNER_LOGIN, undefined, undefined, undefined, 'POLICY_42'); + createAgent('Bot', 'My prompt', OWNER_ACCOUNT_ID, OWNER_LOGIN, undefined, undefined, undefined); const accountID = getOptimisticAccountID(); const entry = getOptimisticPersonalDetailEntry(accountID); @@ -360,7 +367,7 @@ describe('createAgent', () => { expect(mockWrite).toHaveBeenCalledWith(WRITE_COMMANDS.CREATE_AGENT, expect.objectContaining({optimisticAccountID: String(result.optimisticAccountID)}), expect.any(Object)); }); - it('failure data preserves optimistic personal detail and merges errors onto the prompt entry', () => { + it('failure data merges errors onto the prompt entry', () => { createAgent('Bot', 'My prompt', OWNER_ACCOUNT_ID, OWNER_LOGIN); const {failureData} = getWriteOptions(); @@ -372,11 +379,6 @@ describe('createAgent', () => { isOptimisticPersonalDetail: true, }); const promptValue = getUpdateRecord(failureData, `${ONYXKEYS.COLLECTION.AGENT}${accountID}`); - - expect(promptValue).toMatchObject({ - prompt: 'My prompt', - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - }); expect(promptValue.errors).toBeTruthy(); }); }); diff --git a/tests/unit/SearchRouterNavigationTest.ts b/tests/unit/SearchRouterNavigationTest.ts index 8f2ffc545e48..84e3c26eac38 100644 --- a/tests/unit/SearchRouterNavigationTest.ts +++ b/tests/unit/SearchRouterNavigationTest.ts @@ -710,6 +710,7 @@ describe('Workspace Search Router navigation source', () => { policies: Object.fromEntries(policies.map((policy) => [`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy])), policyCategories: undefined, currentUserLogin: workspaceCurrentUserLogin, + currentUserPersonalDetails: {accountID: 1}, icons: workspaceIcons, isOffline, isVendorMatchingBetaEnabled: false, diff --git a/tests/unit/pages/settings/AddAgentPageTest.tsx b/tests/unit/pages/settings/AddAgentPageTest.tsx index 5bf4e0067b73..9740c812bd64 100644 --- a/tests/unit/pages/settings/AddAgentPageTest.tsx +++ b/tests/unit/pages/settings/AddAgentPageTest.tsx @@ -11,11 +11,14 @@ import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import AddAgentPage from '@pages/settings/Agents/DynamicAddAgentPage'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import type {PolicyEmployeeList} from '@src/types/onyx'; import type HybridAppModuleType from '@expensify/react-native-hybrid-app/src/types'; +import type {ValueOf} from 'type-fest'; import React from 'react'; @@ -35,6 +38,16 @@ const mockCreateAgent = jest.fn<{optimisticAccountID: number; avatarURI: string resolveOptimisticPersonalDetail = resolve; }), })); +const mockCreateCompanyAgent = jest.fn<{optimisticAccountID: number; avatarURI: string | undefined; optimisticReportID: string; optimisticPersonalDetailPromise: Promise}, unknown[]>( + () => ({ + optimisticAccountID: -123456, + avatarURI: undefined, + optimisticReportID: OPTIMISTIC_REPORT_ID, + optimisticPersonalDetailPromise: new Promise((resolve) => { + resolveOptimisticPersonalDetail = resolve; + }), + }), +); const mockSetNewAgentAvatarPreset = jest.fn(); const mockClearNewAgentAvatarDraft = jest.fn(); const mockClearNewAgentTemplate = jest.fn(); @@ -42,9 +55,11 @@ let mockAvatarDraft: {customExpensifyAvatarID?: string; uploadedAvatar?: {uri: s let mockAvatarDraftStatus: 'loading' | 'loaded' = 'loaded'; let mockIsNarrowLayout = true; let mockTemplate: {name: string; prompt: string; avatarID: string} | undefined; +let mockPolicy: {id: string; policyAccountID: number; employeeList: PolicyEmployeeList} | undefined; jest.mock('@userActions/Agent', () => ({ createAgent: (...args: unknown[]) => mockCreateAgent(...args), + createCompanyAgent: (...args: unknown[]) => mockCreateCompanyAgent(...args), setNewAgentAvatarPreset: (...args: unknown[]) => mockSetNewAgentAvatarPreset(...args), clearNewAgentAvatarDraft: (...args: unknown[]) => mockClearNewAgentAvatarDraft(...args), clearNewAgentTemplate: (...args: unknown[]) => mockClearNewAgentTemplate(...args), @@ -91,6 +106,9 @@ jest.mock('@hooks/useOnyx', () => { if (key === onyxKeys.NEW_AGENT_TEMPLATE) { return [mockTemplate, {status: 'loaded'}] as const; } + if (key.startsWith(onyxKeys.COLLECTION.POLICY)) { + return [mockPolicy, {status: 'loaded'}] as const; + } return [undefined, {status: 'loaded'}] as const; }), }; @@ -148,7 +166,7 @@ jest.mock('@components/HeaderWithBackButton', () => { return MockHeader; }); -let mockFormOnSubmit: ((values: {firstName: string; prompt: string}) => void) | undefined; +let mockFormOnSubmit: ((values: {firstName: string; prompt: string; role?: ValueOf}) => void) | undefined; jest.mock('@components/Form/FormProvider', () => { function MockFormProvider({children, onSubmit}: {children: React.ReactNode; onSubmit: (values: {firstName: string; prompt: string}) => void}) { @@ -210,6 +228,7 @@ describe('AddAgentPage', () => { mockAvatarDraftStatus = 'loaded'; mockIsNarrowLayout = true; mockTemplate = undefined; + mockPolicy = undefined; mockUseCurrentUserPersonalDetails.mockReturnValue({accountID: OWNER_ACCOUNT_ID, login: OWNER_LOGIN}); mockAvatarOnPress = undefined; resolveOptimisticPersonalDetail = undefined; @@ -339,7 +358,7 @@ describe('AddAgentPage', () => { mockFormOnSubmit?.({firstName: 'Bot', prompt: 'Reject gambling.'}); - expect(mockCreateAgent).toHaveBeenCalledWith('Bot', 'Reject gambling.', OWNER_ACCOUNT_ID, OWNER_LOGIN, 'bot-avatar--blue', undefined, undefined, undefined); + expect(mockCreateAgent).toHaveBeenCalledWith('Bot', 'Reject gambling.', OWNER_ACCOUNT_ID, OWNER_LOGIN, 'bot-avatar--blue', undefined, undefined); expect(mockClearNewAgentTemplate).toHaveBeenCalledTimes(1); expect(mockClearNewAgentAvatarDraft).not.toHaveBeenCalled(); expect(mockRevealRouteBeforeDismissingModal).not.toHaveBeenCalled(); @@ -354,16 +373,28 @@ describe('AddAgentPage', () => { expect(mockClearNewAgentAvatarDraft).toHaveBeenCalledTimes(1); }); - it('forwards policyID from route params to createAgent', async () => { - renderAddAgentPage({policyID: 'POL_42'}); + it('forwards policyID from route params to createCompanyAgent', async () => { + mockPolicy = {id: 'POL_42', policyAccountID: 5444, employeeList: {[OWNER_LOGIN]: {role: CONST.POLICY.ROLE.ADMIN}}}; - mockFormOnSubmit?.({firstName: 'Bot', prompt: 'Reject gambling.'}); + renderAddAgentPage({policyID: 'POL_42'}); - expect(mockCreateAgent).toHaveBeenCalledWith('Bot', 'Reject gambling.', OWNER_ACCOUNT_ID, OWNER_LOGIN, 'bot-avatar--blue', undefined, undefined, 'POL_42'); + mockFormOnSubmit?.({firstName: 'Bot', prompt: 'Reject gambling.', role: CONST.POLICY.ROLE.ADMIN}); + + expect(mockCreateCompanyAgent).toHaveBeenCalledWith( + OWNER_ACCOUNT_ID, + OWNER_LOGIN, + 'Reject gambling.', + mockPolicy, + CONST.POLICY.ROLE.ADMIN, + 'Bot', + 'bot-avatar--blue', + undefined, + undefined, + ); resolveOptimisticPersonalDetail?.(); await Promise.resolve(); expect(mockRevealRouteBeforeDismissingModal).toHaveBeenCalledWith( - ROUTES.REPORT_WITH_ID.getRoute(OPTIMISTIC_REPORT_ID, undefined, undefined, ROUTES.SETTINGS_AGENTS), + ROUTES.REPORT_WITH_ID.getRoute(OPTIMISTIC_REPORT_ID, undefined, undefined, ROUTES.WORKSPACE_AGENTS.getRoute('POL_42')), expect.anything(), ); expect(getRevealAfterTransition()).toEqual(expect.any(Function)); @@ -415,7 +446,7 @@ describe('AddAgentPage', () => { mockFormOnSubmit?.({firstName: 'Bot', prompt: 'Reject gambling.'}); - expect(mockCreateAgent).toHaveBeenCalledWith('Bot', 'Reject gambling.', OWNER_ACCOUNT_ID, OWNER_LOGIN, 'bot-avatar--blue', undefined, undefined, undefined); + expect(mockCreateAgent).toHaveBeenCalledWith('Bot', 'Reject gambling.', OWNER_ACCOUNT_ID, OWNER_LOGIN, 'bot-avatar--blue', undefined, undefined); }); it('creates the agent with the reconstructed uploaded file when a photo was uploaded', () => { diff --git a/tests/unit/pages/settings/NewAgentPageTest.tsx b/tests/unit/pages/settings/NewAgentPageTest.tsx index 8c8af4ccde3d..fbae8eeaf686 100644 --- a/tests/unit/pages/settings/NewAgentPageTest.tsx +++ b/tests/unit/pages/settings/NewAgentPageTest.tsx @@ -1,5 +1,6 @@ import {fireEvent, render, screen, waitFor} from '@testing-library/react-native'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useNetwork from '@hooks/useNetwork'; import useSuggestedAgents from '@hooks/useSuggestedAgents'; @@ -13,12 +14,17 @@ import NewAgentPage from '@pages/settings/Agents/DynamicNewAgentPage'; import {clearNewAgentAvatarDraft, clearNewAgentTemplate, setNewAgentTemplate} from '@userActions/Agent'; +import CONST from '@src/CONST'; +import type ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import type {PolicyEmployeeList} from '@src/types/onyx'; import type SuggestedAgent from '@src/types/onyx/SuggestedAgent'; import React from 'react'; +let mockPolicy: {id: string; policyAccountID: number; employeeList: PolicyEmployeeList} | undefined; + jest.mock('@hooks/useLazyAsset', () => ({ useMemoizedLazyExpensifyIcons: jest.fn(() => ({Bot: 'Bot'})), useMemoizedLazyIllustrations: jest.fn(() => ({Lightbulb: 'Lightbulb'})), @@ -100,12 +106,28 @@ jest.mock('@components/HeaderWithBackButton', () => { return MockHeader; }); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({}))); + +jest.mock('@hooks/useOnyx', () => { + const onyxKeys = jest.requireActual<{default: typeof ONYXKEYS}>('@src/ONYXKEYS').default; + return { + __esModule: true, + default: jest.fn((key: string) => { + if (key.startsWith(onyxKeys.COLLECTION.POLICY)) { + return [mockPolicy, {status: 'loaded'}] as const; + } + return [undefined, {status: 'loaded'}] as const; + }), + }; +}); + const mockedUseSuggestedAgents = jest.mocked(useSuggestedAgents); const mockedUseNetwork = jest.mocked(useNetwork); const mockNavigate = jest.mocked(Navigation.navigate); const mockSetNewAgentTemplate = jest.mocked(setNewAgentTemplate); const mockClearNewAgentTemplate = jest.mocked(clearNewAgentTemplate); const mockClearNewAgentAvatarDraft = jest.mocked(clearNewAgentAvatarDraft); +const mockUseCurrentUserPersonalDetails = jest.mocked(useCurrentUserPersonalDetails); type NewAgentRouteProp = PlatformStackRouteProp; type NewAgentNavigationProp = PlatformStackScreenProps['navigation']; @@ -124,6 +146,9 @@ function renderNewAgentPage(routeParams: NewAgentRouteProp['params'] = {}) { ); } +const OWNER_ACCOUNT_ID = 999; +const OWNER_LOGIN = 'owner@test.com'; + const TEMPLATES: SuggestedAgent[] = [ {id: 't1', name: 'Cheapskate Charlie', description: 'Watches spend.', prompt: 'Reject pricey expenses.'}, {id: 't2', name: 'Enforcer Eliza', description: 'Keeps records clean.', prompt: 'Require receipts.'}, @@ -137,6 +162,8 @@ describe('NewAgentPage', () => { mockSetNewAgentTemplate.mockResolvedValue(undefined); mockClearNewAgentTemplate.mockResolvedValue(undefined); mockClearNewAgentAvatarDraft.mockResolvedValue(undefined); + mockUseCurrentUserPersonalDetails.mockReturnValue({accountID: OWNER_ACCOUNT_ID, login: OWNER_LOGIN}); + mockPolicy = undefined; }); it('always renders the build custom agent button', () => { @@ -225,6 +252,8 @@ describe('NewAgentPage', () => { }); it('forwards the policyID to the builder route when present', async () => { + mockPolicy = {id: 'POL_42', policyAccountID: 5444, employeeList: {[OWNER_LOGIN]: {role: CONST.POLICY.ROLE.ADMIN}}}; + renderNewAgentPage({policyID: 'POL_42'}); fireEvent.press(screen.getByText('newAgentPage.buildCustomAgent'));