diff --git a/src/components/ApproverSelectionList.tsx b/src/components/ApproverSelectionList.tsx index 49c218598329..8b4c7227daa4 100644 --- a/src/components/ApproverSelectionList.tsx +++ b/src/components/ApproverSelectionList.tsx @@ -32,12 +32,14 @@ type ApproverSelectionListPageProps = { shouldShowNotFoundViewLink?: boolean; listEmptyContentSubtitle?: string; footerContent?: React.ReactNode; + headerContent?: React.JSX.Element | null; subtitle?: React.ReactNode; shouldShowTextInput?: boolean; allApprovers: SelectionListApprover[]; shouldShowListEmptyContent?: boolean; allowMultipleSelection?: boolean; onSelectApprover?: (approvers: SelectionListApprover[]) => void; + onDismissError?: (approver: SelectionListApprover) => void; shouldShowLoadingPlaceholder?: boolean; shouldEnableHeaderMaxHeight?: boolean; shouldUpdateFocusedIndex?: boolean; @@ -60,10 +62,12 @@ function ApproverSelectionList({ shouldShowNotFoundViewLink = true, listEmptyContentSubtitle, footerContent = null, + headerContent = null, allApprovers, shouldShowListEmptyContent: shouldShowListEmptyContentProp = true, allowMultipleSelection = false, onSelectApprover, + onDismissError, shouldShowLoadingPlaceholder, shouldEnableHeaderMaxHeight, shouldUpdateFocusedIndex = true, @@ -153,6 +157,7 @@ function ApproverSelectionList({ = { previousYear: 'Vorheriges Jahr', nextYear: 'Nächstes Jahr', avatar: 'Avatar', + agent: 'Agent', restrictions: 'Beschränkungen', }, socials: { @@ -2674,6 +2675,8 @@ ${amount} für ${merchant} – ${date}`, genericErrorMessage: 'Die genehmigende Person konnte nicht geändert werden. Bitte versuche es erneut oder kontaktiere den Support.', title: 'Genehmigenden festlegen', description: 'Diese Person wird die Ausgaben genehmigen.', + createNewAgent: 'Neue Agent:in erstellen', + createNewAgentDescription: 'Automatisieren Sie Ihre Genehmigungen mit Prompt', }, workflowsApprovalLimitPage: { title: 'Genehmiger', diff --git a/src/languages/en.ts b/src/languages/en.ts index ef149ad0c94d..00f4b88cfa62 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -359,6 +359,7 @@ const translations = { update: 'Update', member: 'Member', auditor: 'Auditor', + agent: 'Agent', role: 'Role', roleCannotBeChanged: (workflowsLinkPage: string) => `Role can't be changed because this member is a payer on this workspace.`, currency: 'Currency', @@ -2731,6 +2732,8 @@ const translations = { genericErrorMessage: "The approver couldn't be changed. Please try again or contact support.", title: 'Set approver', description: 'This person will approve the expenses.', + createNewAgent: 'Create new agent', + createNewAgentDescription: 'Automate your approvals with prompt', }, workflowsApprovalLimitPage: { title: 'Approver', diff --git a/src/languages/es.ts b/src/languages/es.ts index 810476825385..3db06b91dcf1 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -449,6 +449,7 @@ const translations: TranslationDeepObject = { expensifyLogo: 'Logo de Expensify', approver: 'Aprobador', enterDigitLabel: ({digitIndex, totalDigits}: {digitIndex: number; totalDigits: number}) => `introducir dígito ${digitIndex} de ${totalDigits}`, + agent: 'Agente', restrictions: 'Restricciones', }, socials: { @@ -2546,6 +2547,8 @@ ${amount} para ${merchant} - ${date}`, genericErrorMessage: 'El aprobador no pudo ser cambiado. Por favor, inténtelo de nuevo o contacte al soporte.', title: 'Establecer aprobador', description: 'Esta persona aprobará los gastos.', + createNewAgent: 'Crear nuevo agente', + createNewAgentDescription: 'Automatiza tus aprobaciones con rapidez', }, workflowsApprovalLimitPage: { title: 'Aprobador', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 57360072a731..6bc84d09e142 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -497,6 +497,7 @@ const translations: TranslationDeepObject = { previousYear: 'Année précédente', nextYear: 'L’an prochain', avatar: 'Avatar', + agent: 'Agent', restrictions: 'Restrictions', }, socials: { @@ -2681,6 +2682,8 @@ ${amount} pour ${merchant} - ${date}`, genericErrorMessage: 'L’approbateur n’a pas pu être modifié. Veuillez réessayer ou contacter l’assistance.', title: 'Définir l’approbateur', description: 'Cette personne approuvera les dépenses.', + createNewAgent: 'Créer un nouvel agent', + createNewAgentDescription: 'Automatisez vos approbations avec l’IA', }, workflowsApprovalLimitPage: { title: 'Approbateur', diff --git a/src/languages/it.ts b/src/languages/it.ts index 051aedcc4544..9ac8ab95d18f 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -497,6 +497,7 @@ const translations: TranslationDeepObject = { previousYear: 'Anno precedente', nextYear: "L'anno prossimo", avatar: 'Avatar', + agent: 'Agente', restrictions: 'Restrizioni', }, socials: { @@ -2669,6 +2670,8 @@ ${amount} per ${merchant} - ${date}`, genericErrorMessage: "Non è stato possibile modificare l'approvatore. Riprova o contatta l'assistenza.", title: 'Imposta approvatore', description: 'Questa persona approverà le spese.', + createNewAgent: 'Crea nuovo agente', + createNewAgentDescription: 'Automatizza le tue approvazioni con prompt', }, workflowsApprovalLimitPage: { title: 'Approvante', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 6c670e1a9122..d237fa16085d 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -496,6 +496,7 @@ const translations: TranslationDeepObject = { previousYear: '前年', nextYear: '来年', avatar: 'アバター', + agent: 'エージェント', restrictions: '制限', }, socials: { @@ -2557,7 +2558,7 @@ ${date} の ${merchant} への ${amount}`, accessibilityLabel: ({members, approvers}: {members: string; approvers: string}) => `${members} の経費で、承認者は ${approvers} です`, addApprovalButton: '承認ワークフローを追加', editWorkflowAction: '編集', - addAgentAction: 'エージェントを追加', + addAgentAction: '担当者を追加', findWorkflow: 'ワークフローを検索', addApprovalTip: 'より詳細なワークフローが存在する場合を除き、このデフォルトのワークフローがすべてのメンバーに適用されます。', approver: '承認者', @@ -2644,6 +2645,8 @@ ${date} の ${merchant} への ${amount}`, genericErrorMessage: '承認者を変更できませんでした。もう一度お試しいただくか、サポートにお問い合わせください。', title: '承認者を設定', description: 'この人が経費を承認します。', + createNewAgent: '新しいエージェントを作成', + createNewAgentDescription: '迅速な承認でワークフローを自動化しましょう', }, workflowsApprovalLimitPage: { title: '承認者', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index c46fe4664738..9574ad6119f9 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -496,6 +496,7 @@ const translations: TranslationDeepObject = { previousYear: 'Vorig jaar', nextYear: 'Volgend jaar', avatar: 'Avatar', + agent: 'Agent', restrictions: 'Beperkingen', }, socials: { @@ -2666,6 +2667,8 @@ ${amount} voor ${merchant} - ${date}`, genericErrorMessage: 'De fiatteur kon niet worden gewijzigd. Probeer het opnieuw of neem contact op met support.', title: 'Stel fiatteur in', description: 'Deze persoon keurt de declaraties goed.', + createNewAgent: 'Nieuwe agent toevoegen', + createNewAgentDescription: 'Automatiseer je goedkeuringen met prompt', }, workflowsApprovalLimitPage: { title: 'Fiatteur', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index b16eea888463..c8c6ff5c23a0 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -496,6 +496,7 @@ const translations: TranslationDeepObject = { previousYear: 'Poprzedni rok', nextYear: 'W przyszłym roku', avatar: 'Avatar', + agent: 'Agent', restrictions: 'Ograniczenia', }, socials: { @@ -2662,6 +2663,8 @@ ${amount} dla ${merchant} - ${date}`, genericErrorMessage: 'Nie udało się zmienić osoby zatwierdzającej. Spróbuj ponownie lub skontaktuj się z pomocą techniczną.', title: 'Ustaw zatwierdzającego', description: 'Ta osoba będzie zatwierdzać wydatki.', + createNewAgent: 'Utwórz nowego agenta', + createNewAgentDescription: 'Automatyzuj swoje akceptacje za pomocą promptów', }, workflowsApprovalLimitPage: { title: 'Osoba zatwierdzająca', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 71ba13605cd2..e955054375a2 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -495,6 +495,7 @@ const translations: TranslationDeepObject = { previousYear: 'Ano anterior', nextYear: 'Ano que vem', avatar: 'Avatar', + agent: 'Agente', restrictions: 'Restrições', }, socials: { @@ -2660,6 +2661,8 @@ ${amount} para ${merchant} - ${date}`, genericErrorMessage: 'O aprovador não pôde ser alterado. Tente novamente ou entre em contato com o suporte.', title: 'Definir aprovador', description: 'Essa pessoa vai aprovar as despesas.', + createNewAgent: 'Criar novo agente', + createNewAgentDescription: 'Automatize suas aprovações com agilidade', }, workflowsApprovalLimitPage: { title: 'Aprovador', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 970ae5c097bc..aa8a18f55208 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -492,6 +492,7 @@ const translations: TranslationDeepObject = { previousYear: '上一年', nextYear: '明年', avatar: '头像', + agent: '代理人', restrictions: '限制', }, socials: { @@ -2591,6 +2592,8 @@ ${amount},商户:${merchant} - 日期:${date}`, genericErrorMessage: '无法更改审批人。请重试或联系支持。', title: '设置审批人', description: '此人将审核并批准这些报销。', + createNewAgent: '创建新代理', + createNewAgentDescription: '使用 Prompt 自动化您的审批', }, workflowsApprovalLimitPage: { title: '审批人', diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 559eae99da31..a78e604bfcec 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -774,6 +774,15 @@ function resolveOptimisticAgent({ } } + // When the optimistic personal detail is still present, this tab originated the + // CREATE_AGENT write and the mapping will land here. Falling back to prompt-match in + // that window would collapse the brand-new optimistic agent into another agent that + // shares the same prompt text (e.g. two agents created with the default prompt) — + // wait for the mapping instead. + if (personalDetails[optimisticAccountID]?.isOptimisticPersonalDetail) { + return undefined; + } + if (!pendingAgentPrompt || !agentPrompts) { return undefined; } diff --git a/src/libs/actions/Agent.ts b/src/libs/actions/Agent.ts index c9c625c0721d..3a8cdfb96b54 100644 --- a/src/libs/actions/Agent.ts +++ b/src/libs/actions/Agent.ts @@ -10,7 +10,9 @@ import type {AvatarSource} from '@libs/UserAvatarUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {ApprovalWorkflowOnyx} from '@src/types/onyx'; import type {AnyOnyxUpdate} from '@src/types/onyx/Request'; +import {clearApprovalWorkflowApprover} from './Workflow'; function openAgentsPage() { read(READ_COMMANDS.OPEN_AGENTS_PAGE, null); @@ -158,6 +160,20 @@ function clearPendingAgentFromApprovalWorkflow(policyID: string, firstApproverEm Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {errorFields: {[CONST.POLICY.COLLECTION_KEYS.ADD_AGENT]: null}}); } +/** + * Discard a failed optimistic agent that was seeded into the in-progress APPROVAL_WORKFLOW + * (Set Approver flow). Removes the pending approver at `approverIndex`, the optimistic + * personal detail + prompt entry, the optimistic->real ID mapping slot, and the policy-level + * addAgent error. Used by the RBR X click on the Set Approver page. + */ +function clearOptimisticAgentFromApprovalWorkflow(policyID: string, approverIndex: number, currentApprovalWorkflow: ApprovalWorkflowOnyx | undefined, optimisticAccountID: number) { + clearApprovalWorkflowApprover({approverIndex, currentApprovalWorkflow}); + Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[optimisticAccountID]: null}); + Onyx.set(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${optimisticAccountID}`, null); + Onyx.merge(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING, {[optimisticAccountID]: null}); + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {errorFields: {[CONST.POLICY.COLLECTION_KEYS.ADD_AGENT]: null}}); +} + function clearAgentUpdateError(accountID: number) { Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, {errors: null, nameErrors: null, promptErrors: null, avatarErrors: null}); } @@ -358,6 +374,7 @@ export { createAgent, clearAgentError, clearPendingAgentFromApprovalWorkflow, + clearOptimisticAgentFromApprovalWorkflow, clearAgentUpdateError, clearAgentNameUpdateError, clearAgentPromptUpdateError, diff --git a/src/pages/settings/Agents/AddAgentPage.tsx b/src/pages/settings/Agents/AddAgentPage.tsx index 1ff1f40b9697..7c9ce3b339af 100644 --- a/src/pages/settings/Agents/AddAgentPage.tsx +++ b/src/pages/settings/Agents/AddAgentPage.tsx @@ -14,6 +14,8 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePersonalDetailsByEmail from '@hooks/usePersonalDetailsByEmail'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {isMobile} from '@libs/Browser'; @@ -23,10 +25,11 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {SettingsNavigatorParamList, WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import type {AvatarSource} from '@libs/UserAvatarUtils'; import {createAgent} from '@userActions/Agent'; +import {setApprovalWorkflowApprover} from '@userActions/Workflow'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; +import SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/AddAgentForm'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import {clearPendingAvatar, getPendingAvatar, setInitialPresetID, setNavigationToken, setReturnRoute} from './pendingAgentAvatarStore'; @@ -38,7 +41,11 @@ type AddAgentPageProps = function AddAgentPage({route}: AddAgentPageProps) { const policyID = route.params?.policyID; const workflowApproverEmail = route.params?.workflowApproverEmail; - const isWorkflowSeedFlow = !!policyID && !!workflowApproverEmail; + const isWorkflowSeedFlow = !!policyID && !!workflowApproverEmail && route.name === SCREENS.WORKSPACE.WORKFLOWS_ADD_AGENT; + const isSetApproverSeedFlow = !!policyID && !!workflowApproverEmail && route.name === SCREENS.SETTINGS.AGENTS.ADD; + const [approvalWorkflow] = useOnyx(ONYXKEYS.APPROVAL_WORKFLOW); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + const personalDetailsByEmail = usePersonalDetailsByEmail(); const {translate} = useLocalize(); const styles = useThemeStyles(); const {windowWidth, windowHeight} = useWindowDimensions(); @@ -76,7 +83,15 @@ function AddAgentPage({route}: AddAgentPageProps) { const presetID = botAvatarIDs.get(avatarSource as BotAvatar); setInitialPresetID(presetID); setNavigationToken(); - setReturnRoute(isWorkflowSeedFlow ? ROUTES.WORKSPACE_WORKFLOWS_ADD_AGENT.getRoute({policyID, workflowApproverEmail}) : ROUTES.SETTINGS_AGENTS_ADD.getRoute()); + let returnRoute; + if (isWorkflowSeedFlow) { + returnRoute = ROUTES.WORKSPACE_WORKFLOWS_ADD_AGENT.getRoute({policyID, workflowApproverEmail}); + } else if (isSetApproverSeedFlow) { + returnRoute = ROUTES.SETTINGS_AGENTS_ADD.getRoute({policyID, workflowApproverEmail}); + } else { + returnRoute = ROUTES.SETTINGS_AGENTS_ADD.getRoute(); + } + setReturnRoute(returnRoute); Navigation.navigate(ROUTES.SETTINGS_AGENTS_ADD_AVATAR); }; @@ -96,7 +111,7 @@ function AddAgentPage({route}: AddAgentPageProps) { // Pure optimistic flow — no waiting on the server, online or offline. `createAgent` // returns the optimistic accountID it wrote into Onyx so we can hand it to the next // screen and let it render the agent with opacity until CREATE_AGENT resolves. - const {optimisticAccountID} = pendingFile + const {optimisticAccountID, avatarURI} = pendingFile ? createAgent(firstName, prompt, undefined, pendingFile.file, pendingFile.uri, policyID) : createAgent(firstName, prompt, botAvatarIDs.get(avatarSource as BotAvatar), undefined, undefined, policyID); @@ -110,6 +125,42 @@ function AddAgentPage({route}: AddAgentPageProps) { return; } + if (isSetApproverSeedFlow && policyID && approvalWorkflow) { + // Seeded from the Set Approver page: write the optimistic agent into the in-progress + // approval workflow as approver[0] with `pendingAction = ADD`. The picker's + // reconciliation effect upgrades the row to the real email/accountID once + // CREATE_AGENT lands and the agent shows up in `policy.employeeList`. + setApprovalWorkflowApprover({ + approver: { + email: '', + accountID: optimisticAccountID, + avatar: avatarURI, + displayName: firstName ?? '', + approvalLimit: null, + overLimitForwardsTo: '', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + approverIndex: 0, + currentApprovalWorkflow: approvalWorkflow, + policy, + personalDetailsByEmail, + }); + // Pop AddAgentPage + the Set Approver picker so the admin lands on the workflow + // edit/create screen with the optimistic agent already chosen as approver[0]. From + // there the Save button commits the workflow (the agent renders opaque until + // CREATE_AGENT resolves; on failure the picker's RBR X clears the pending agent). + // The EDIT route is keyed by the workflow's original first approver email (the saved + // state); read it from the in-memory workflow rather than the URL param, which may + // carry the picker's in-progress selection instead of the workflow identifier. + const originalFirstApproverEmail = approvalWorkflow.originalApprovers?.at(0)?.email; + if (approvalWorkflow.action === CONST.APPROVAL_WORKFLOW.ACTION.EDIT && originalFirstApproverEmail) { + Navigation.goBack(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(policyID, originalFirstApproverEmail)); + } else { + Navigation.goBack(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_NEW.getRoute(policyID)); + } + return; + } + Navigation.goBack(); }; diff --git a/src/pages/workspace/MemberRightIcon.tsx b/src/pages/workspace/MemberRightIcon.tsx index 647161c3b587..6373eaebe929 100644 --- a/src/pages/workspace/MemberRightIcon.tsx +++ b/src/pages/workspace/MemberRightIcon.tsx @@ -11,10 +11,11 @@ type MemberRightIconProps = { owner?: string; role?: string; login?: string; + isAgent?: boolean; badgeStyles?: StyleProp; }; -export default function MemberRightIcon({role, owner, login, badgeStyles}: MemberRightIconProps) { +export default function MemberRightIcon({role, owner, login, isAgent = false, badgeStyles}: MemberRightIconProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const {isFocused} = useListItemFocus(); @@ -26,6 +27,8 @@ export default function MemberRightIcon({role, owner, login, badgeStyles}: Membe badgeText = 'common.admin'; } else if (role === CONST.POLICY.ROLE.AUDITOR) { badgeText = 'common.auditor'; + } else if (isAgent) { + badgeText = 'common.agent'; } if (badgeText) { return ( diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx index bd2a1ad33b74..783416c6bc04 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx @@ -1,24 +1,30 @@ import {useNavigationState} from '@react-navigation/native'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import type {SelectionListApprover} from '@components/ApproverSelectionList'; import ApproverSelectionList from '@components/ApproverSelectionList'; +import MenuItem from '@components/MenuItem'; import Text from '@components/Text'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import usePersonalDetailsByEmail from '@hooks/usePersonalDetailsByEmail'; import useThemeStyles from '@hooks/useThemeStyles'; -import {clearApprovalWorkflowApprover, clearApprovalWorkflowApprovers, setApprovalWorkflowApprover} from '@libs/actions/Workflow'; +import {clearOptimisticAgentFromApprovalWorkflow} from '@libs/actions/Agent'; +import {clearApprovalWorkflowApprover, clearApprovalWorkflowApprovers, setApprovalWorkflow, setApprovalWorkflowApprover} from '@libs/actions/Workflow'; import {isAnyHRReadOnlyWorkflowMode} from '@libs/HRUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import {getDefaultApprover, getMemberAccountIDsForWorkspace, isExpensifyTeam, shouldFilterExpensifyTeam} from '@libs/PolicyUtils'; +import {isAgentEmail} from '@libs/SessionUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import MemberRightIcon from '@pages/workspace/MemberRightIcon'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; +import colors from '@styles/theme/colors'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -31,8 +37,12 @@ type WorkspaceWorkflowsApprovalsApproverPageProps = WithPolicyAndFullscreenLoadi function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoadingReportData = true, route}: WorkspaceWorkflowsApprovalsApproverPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); + const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar', 'Bot']); + const {isBetaEnabled} = usePermissions(); + const isCustomAgentEnabled = isBetaEnabled(CONST.BETAS.CUSTOM_AGENT); const [approvalWorkflow, approvalWorkflowMetadata] = useOnyx(ONYXKEYS.APPROVAL_WORKFLOW); + const [optimisticAgentAccountIDMapping] = useOnyx(ONYXKEYS.OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING); + const [agentPrompts] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT); const isApprovalWorkflowLoading = isLoadingOnyxValue(approvalWorkflowMetadata); const personalDetailsByEmail = usePersonalDetailsByEmail(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); @@ -117,6 +127,7 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa role={employee.role} owner={policy?.owner} login={login} + isAgent={isAgentEmail(employee.email)} /> ), }; @@ -138,6 +149,65 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa shouldFilterOutExpensifyTeam, ]); + // Optimistic agent approver (seeded by AddAgentPage). The pending approver carries the + // optimistic accountID but no email until CREATE_AGENT resolves; render it as a top row + // with reduced opacity (via `pendingAction`) and surface CREATE_AGENT errors so the admin + // can dismiss + retry without leaving the picker. + const pendingOptimisticApprover = + currentApprover?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && !!currentApprover?.accountID && !currentApprover.email ? currentApprover : undefined; + const pendingOptimisticAccountID = pendingOptimisticApprover?.accountID; + const pendingOptimisticDetail = pendingOptimisticAccountID ? personalDetails?.[pendingOptimisticAccountID] : undefined; + const pendingOptimisticPromptErrors = pendingOptimisticAccountID ? agentPrompts?.[`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${pendingOptimisticAccountID}`]?.errors : undefined; + const addAgentPolicyErrors = policy?.errorFields?.[CONST.POLICY.COLLECTION_KEYS.ADD_AGENT]; + const pendingOptimisticErrors = addAgentPolicyErrors ?? pendingOptimisticPromptErrors; + + const pendingOptimisticDetailDisplayName = pendingOptimisticDetail?.displayName; + const pendingOptimisticDetailAvatar = pendingOptimisticDetail?.avatar; + const pendingOptimisticApproverRow: SelectionListApprover | undefined = (() => { + if (!pendingOptimisticApprover || !pendingOptimisticAccountID) { + return undefined; + } + const displayName = pendingOptimisticDetailDisplayName ?? pendingOptimisticApprover.displayName ?? ''; + const avatar = pendingOptimisticDetailAvatar ?? pendingOptimisticApprover.avatar; + return { + text: displayName, + keyForList: String(pendingOptimisticAccountID), + isSelected: true, + login: '', + icons: [{source: avatar ?? icons.FallbackAvatar, type: CONST.ICON_TYPE_AVATAR, name: displayName, id: pendingOptimisticAccountID}], + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + errors: pendingOptimisticErrors ?? undefined, + isInteractive: false, + }; + })(); + + // Once CREATE_AGENT resolves, the server echoes `{optimisticID: realID}` in + // `OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING` and the real agent lands in `policy.employeeList`, + // which means `allApprovers` already contains the real row before the reconcile effect + // rewrites `APPROVAL_WORKFLOW`. In that interim render we'd otherwise show both rows. + // Detect the mapping early, force the real row to render as selected, and drop the + // optimistic placeholder. + const reconciledRealAccountID = pendingOptimisticAccountID ? optimisticAgentAccountIDMapping?.[pendingOptimisticAccountID] : undefined; + const reconciledLogin = reconciledRealAccountID ? personalDetails?.[reconciledRealAccountID]?.login : undefined; + const isOptimisticReconciled = !!reconciledLogin && !!policy?.employeeList?.[reconciledLogin]; + + const allApproversWithOptimistic = (() => { + if (isOptimisticReconciled && reconciledLogin) { + return allApprovers.map((approver) => (approver.keyForList === reconciledLogin ? {...approver, isSelected: true} : approver)); + } + if (pendingOptimisticApproverRow) { + return [pendingOptimisticApproverRow, ...allApprovers]; + } + return allApprovers; + })(); + + const onDismissOptimisticApprover = () => { + if (!pendingOptimisticAccountID) { + return; + } + clearOptimisticAgentFromApprovalWorkflow(route.params.policyID, approverIndex, approvalWorkflow, pendingOptimisticAccountID); + }; + const shouldShowListEmptyContent = !!approvalWorkflow && !isApprovalWorkflowLoading && !removingApproverEmail; const goBack = useCallback(() => { @@ -221,6 +291,68 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa [translate, styles.textHeadlineH1, styles.mh5, styles.mv3, styles.mb3, styles.textSupporting], ); + const shouldShowCreateAgentRow = isCustomAgentEnabled && approverIndex === 0; + + const onCreateAgentPress = () => { + Navigation.navigate( + ROUTES.SETTINGS_AGENTS_ADD.getRoute({ + policyID: route.params.policyID, + workflowApproverEmail: selectedApproverEmail ?? firstApprover, + }), + ); + }; + + const headerContent = !shouldShowCreateAgentRow ? null : ( + + ); + + // Reconcile the optimistic agent approver once the server-side CREATE_AGENT response lands. + // The new agent is written to `approvalWorkflow.approvers[approverIndex]` with `accountID` set + // but `email = ''` and `pendingAction = ADD`. When the server replies, the real accountID + // (mapped via `OPTIMISTIC_AGENT_ACCOUNT_ID_MAPPING`) shows up in `employeeList` with a login; + // upgrade the approver to the real email so the picker can match it to a row in `allApprovers`. + useEffect(() => { + if (!approvalWorkflow || !policy?.employeeList || !personalDetails) { + return; + } + const pendingApprover = approvalWorkflow.approvers.at(approverIndex); + if (!pendingApprover || pendingApprover.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD || pendingApprover.email || !pendingApprover.accountID) { + return; + } + const mappedRealAccountID = optimisticAgentAccountIDMapping?.[pendingApprover.accountID]; + if (!mappedRealAccountID) { + return; + } + const mappedDetail = personalDetails[mappedRealAccountID]; + const mappedLogin = mappedDetail?.login; + if (!mappedLogin || !policy.employeeList?.[mappedLogin]) { + return; + } + const upgradedApprovers = approvalWorkflow.approvers.map((approver, index) => + index === approverIndex && approver + ? { + ...approver, + email: mappedLogin, + accountID: mappedDetail.accountID, + avatar: mappedDetail.avatar ?? approver.avatar, + displayName: mappedDetail.displayName ?? approver.displayName, + pendingAction: undefined, + } + : approver, + ); + setApprovalWorkflow({...approvalWorkflow, approvers: upgradedApprovers}); + }, [approvalWorkflow, policy?.employeeList, personalDetails, optimisticAgentAccountIDMapping, approverIndex]); + return ( ); diff --git a/src/styles/index.ts b/src/styles/index.ts index e94be16342b4..729348a16adc 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5473,6 +5473,13 @@ const staticStyles = (theme: ThemeColors) => borderRadius: variables.componentBorderRadiusNormal, }, + avatarAgentApprover: { + backgroundColor: theme.icon, + borderRadius: variables.componentBorderRadiusCircle, + height: 40, + width: 40, + }, + successBankSharedCardIllustration: { width: 164, height: 164, diff --git a/tests/unit/pages/settings/AddAgentPageTest.tsx b/tests/unit/pages/settings/AddAgentPageTest.tsx index 976a43f88352..ecabeadbf2a3 100644 --- a/tests/unit/pages/settings/AddAgentPageTest.tsx +++ b/tests/unit/pages/settings/AddAgentPageTest.tsx @@ -7,7 +7,7 @@ import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import AddAgentPage from '@pages/settings/Agents/AddAgentPage'; import {setInitialPresetID, setNavigationToken} from '@pages/settings/Agents/pendingAgentAvatarStore'; import ROUTES from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; +import SCREENS from '@src/SCREENS'; jest.mock('@userActions/Agent', () => ({ createAgent: jest.fn(() => ({optimisticAccountID: -123456, avatarURI: undefined})), @@ -114,8 +114,8 @@ const mockUseCurrentUserPersonalDetails = jest.mocked(useCurrentUserPersonalDeta type AddAgentRouteProp = PlatformStackRouteProp; -function makeRoute(params: AddAgentRouteProp['params'] = {}): AddAgentRouteProp { - return {name: '', key: '', params} as unknown as AddAgentRouteProp; +function makeRoute(params: AddAgentRouteProp['params'] = {}, name = ''): AddAgentRouteProp { + return {name, key: '', params} as unknown as AddAgentRouteProp; } describe('AddAgentPage', () => { @@ -252,7 +252,7 @@ describe('AddAgentPage', () => { // response lands. render( , );