From 7316a47f7e78d2bf7a0a4e5f8abeda21ee637a19 Mon Sep 17 00:00:00 2001 From: jakubstec Date: Tue, 25 Aug 2026 20:46:45 +0200 Subject: [PATCH 01/23] feat: Replace vacation delegate policy diff warning with RHP invite step --- src/ROUTES.ts | 1 + src/SCREENS.ts | 1 + src/languages/de.ts | 11 +- src/languages/el.ts | 11 +- src/languages/en.ts | 11 +- src/languages/es.ts | 11 +- src/languages/fr.ts | 11 +- src/languages/it.ts | 11 +- src/languages/ja.ts | 11 +- src/languages/nl.ts | 11 +- src/languages/pl.ts | 11 +- src/languages/pt-BR.ts | 11 +- src/languages/zh-hans.ts | 9 +- .../parameters/SetVacationDelegateParams.ts | 1 + src/libs/API/types.ts | 2 + .../ModalStackNavigators/index.tsx | 2 + .../RELATIONS/SETTINGS_TO_RHP.ts | 1 + src/libs/Navigation/linkingConfig/config.ts | 4 + src/libs/Navigation/types.ts | 1 + src/libs/actions/VacationDelegate.ts | 43 ++- .../VacationDelegateMissingWorkspacesPage.tsx | 227 +++++++++++++++ .../CustomStatus/VacationDelegatePage.tsx | 40 +-- src/types/onyx/Response.ts | 4 + src/types/onyx/VacationDelegate.ts | 14 +- src/types/onyx/index.ts | 2 + tests/actions/VacationDelegateTest.ts | 160 +++++++++++ ...ationDelegateMissingWorkspacesPageTest.tsx | 262 ++++++++++++++++++ 27 files changed, 823 insertions(+), 61 deletions(-) create mode 100644 src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx create mode 100644 tests/actions/VacationDelegateTest.ts create mode 100644 tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx diff --git a/src/ROUTES.ts b/src/ROUTES.ts index f8e54cd28e95..39523872bc0f 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2358,6 +2358,7 @@ const ROUTES = { SETTINGS_STATUS_CLEAR_AFTER_DATE: 'settings/profile/status/clear-after/date', SETTINGS_STATUS_CLEAR_AFTER_TIME: 'settings/profile/status/clear-after/time', SETTINGS_VACATION_DELEGATE: 'settings/profile/status/vacation-delegate', + SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES: 'settings/profile/status/vacation-delegate/missing-workspaces', SETTINGS_TROUBLESHOOT: 'settings/troubleshoot', SETTINGS_HELP: 'settings/help', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 014a36128658..b61428ca9ff5 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -109,6 +109,7 @@ const SCREENS = { STATUS_CLEAR_AFTER_DATE: 'Settings_Status_Clear_After_Date', STATUS_CLEAR_AFTER_TIME: 'Settings_Status_Clear_After_Time', VACATION_DELEGATE: 'Settings_Status_Vacation_Delegate', + VACATION_DELEGATE_MISSING_WORKSPACES: 'Settings_Status_Vacation_Delegate_Missing_Workspaces', STATUS: 'Settings_Status', PRONOUNS: 'Settings_Pronouns', TIMEZONE: 'Settings_Timezone', diff --git a/src/languages/de.ts b/src/languages/de.ts index e73cf917bfb1..8a7430017610 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -3720,8 +3720,15 @@ ${amount} für ${merchant} – ${date}`, vacationDelegateError: 'Beim Aktualisieren Ihrer Vertretung im Urlaub ist ein Fehler aufgetreten.', asVacationDelegate: (nameOrEmail: string) => `als Urlaubsvertretung von ${nameOrEmail}`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `an ${submittedToName} als Urlaubsvertretung für ${vacationDelegateName}`, - vacationDelegateWarning: (nameOrEmail: string) => - `Sie weisen ${nameOrEmail} als Ihre Urlaubsvertretung zu. Diese Person ist noch nicht in all Ihren Arbeitsbereichen. Wenn Sie fortfahren, wird eine E-Mail an alle Admins Ihrer Arbeitsbereiche gesendet, damit sie hinzugefügt wird.`, + vacationDelegate: { + notAMemberAdminsWillBeAsked: (email: string) => + `${email} ist kein Mitglied dieser Arbeitsbereiche. Admins der Arbeitsbereiche, die Sie nicht verwalten, werden gebeten, die Person hinzuzufügen.`, + notAMemberInviteThemNow: (email: string) => `${email} ist kein Mitglied dieser Arbeitsbereiche. Möchten Sie die Person jetzt einladen?`, + notAMemberMixed: (email: string) => + `${email} ist kein Mitglied dieser Arbeitsbereiche. Admins der Arbeitsbereiche, die Sie nicht verwalten, werden gebeten, die Person hinzuzufügen. Möchten Sie die Person jetzt zu den Arbeitsbereichen einladen, in denen Sie Admin sind?`, + youAreAMemberOf: 'Sie sind Mitglied dieser Arbeitsbereiche:', + youAreAnAdminOf: 'Sie sind Admin dieser Arbeitsbereiche:', + }, }, stepCounter: (step: number, total?: number, text?: string) => { let result = `Schritt ${step}`; diff --git a/src/languages/el.ts b/src/languages/el.ts index 2170bc7f567d..449bf80462b7 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -3775,8 +3775,15 @@ ${amount} για ${merchant} - ${date}`, vacationDelegateError: 'Παρουσιάστηκε σφάλμα κατά την ενημέρωση του αναπληρωτή σας για την άδεια.', asVacationDelegate: (nameOrEmail: string) => `ως εκπρόσωπος αδειών του/της ${nameOrEmail}`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `στον/στη ${submittedToName} ως αναπληρωτής/τρια αδειούχου για τον/τη ${vacationDelegateName}`, - vacationDelegateWarning: (nameOrEmail: string) => - `Ορίζετε τον/την ${nameOrEmail} ως αναπληρωτή/τριά σας κατά τις διακοπές. Δεν συμμετέχει ακόμη σε όλους τους χώρους εργασίας σας. Αν επιλέξετε να συνεχίσετε, θα σταλεί email σε όλους τους διαχειριστές των χώρων εργασίας σας για να τον/την προσθέσουν.`, + vacationDelegate: { + notAMemberAdminsWillBeAsked: (email: string) => + `Ο/Η ${email} δεν είναι μέλος αυτών των χώρων εργασίας. Θα ζητηθεί από τους διαχειριστές των χώρων εργασίας που δεν διαχειρίζεστε να τον/την προσθέσουν.`, + notAMemberInviteThemNow: (email: string) => `Ο/Η ${email} δεν είναι μέλος αυτών των χώρων εργασίας. Θέλετε να τον/την προσκαλέσετε τώρα;`, + notAMemberMixed: (email: string) => + `Ο/Η ${email} δεν είναι μέλος αυτών των χώρων εργασίας. Θα ζητηθεί από τους διαχειριστές των χώρων εργασίας που δεν διαχειρίζεστε να τον/την προσθέσουν. Θέλετε να τον/την προσκαλέσετε τώρα σε αυτούς που διαχειρίζεστε ως διαχειριστής;`, + youAreAMemberOf: 'Είστε μέλος αυτών των χώρων εργασίας:', + youAreAnAdminOf: 'Είστε διαχειριστής αυτών των χώρων εργασίας:', + }, }, stepCounter: (step, total, text) => { let result = `Βήμα ${step}`; diff --git a/src/languages/en.ts b/src/languages/en.ts index 5f4dd01605c9..f6e6f44f84e5 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3812,8 +3812,15 @@ const translations = { vacationDelegateError: 'There was an error updating your vacation delegate.', asVacationDelegate: (nameOrEmail: string) => `as ${nameOrEmail}'s vacation delegate`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `to ${submittedToName} as vacation delegate for ${vacationDelegateName}`, - vacationDelegateWarning: (nameOrEmail: string) => - `You're assigning ${nameOrEmail} as your vacation delegate. They're not on all your workspaces yet. If you choose to continue, an email will be sent to all your workspace admins to add them.`, + vacationDelegate: { + notAMemberAdminsWillBeAsked: (email: string) => + `${email} is not a member of these workspaces. Admins of the workspaces you don't manage will be asked to add them.`, + notAMemberInviteThemNow: (email: string) => `${email} is not a member of these workspaces. Do you want to invite them now?`, + notAMemberMixed: (email: string) => + `${email} isn't a member of these workspaces. Admins of the workspaces you don't manage will be asked to add them. Do you want to invite them to the ones you are an admin of now?`, + youAreAMemberOf: 'You are a member of these workspaces:', + youAreAnAdminOf: 'You are an admin of these workspaces:', + }, }, stepCounter: (step: number, total?: number, text?: string) => { let result = `Step ${step}`; diff --git a/src/languages/es.ts b/src/languages/es.ts index 8c18c47572d9..0f5d1bea8986 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3640,8 +3640,15 @@ ${amount} para ${merchant} - ${date}`, vacationDelegateError: 'Hubo un error al actualizar tu delegado de vacaciones.', asVacationDelegate: (managerName) => `como delegado de vacaciones de ${managerName}`, toAsVacationDelegate: (submittedToName, vacationDelegateName) => `a ${submittedToName} como delegado de vacaciones de ${vacationDelegateName}`, - vacationDelegateWarning: (nameOrEmail) => - `Está asignando a ${nameOrEmail} como su delegado de vacaciones. Aún no está en todos sus espacios de trabajo. Si decide continuar, se enviará un correo electrónico a todos los administradores de sus espacios de trabajo para agregarlo.`, + vacationDelegate: { + notAMemberAdminsWillBeAsked: (email: string) => + `${email} no es miembro de estos espacios de trabajo. Se pedirá a los administradores de los espacios de trabajo que no gestiona que lo agreguen.`, + notAMemberInviteThemNow: (email: string) => `${email} no es miembro de estos espacios de trabajo. ¿Desea invitarlo ahora?`, + notAMemberMixed: (email: string) => + `${email} no es miembro de estos espacios de trabajo. Se pedirá a los administradores de los espacios de trabajo que no gestiona que lo agreguen. ¿Desea invitarlo ahora a los espacios de trabajo en los que es administrador?`, + youAreAMemberOf: 'Usted es miembro de estos espacios de trabajo:', + youAreAnAdminOf: 'Usted es administrador de estos espacios de trabajo:', + }, }, stepCounter: (step, total, text) => { let result = `Paso ${step}`; diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 4a4a08ceff37..6bd81483b740 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -3728,8 +3728,15 @@ ${amount} pour ${merchant} - ${date}`, vacationDelegateError: 'Une erreur s’est produite lors de la mise à jour de votre remplaçant de congés.', asVacationDelegate: (nameOrEmail: string) => `en tant que délégué de vacances de ${nameOrEmail}`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `à ${submittedToName} en tant que délégué de congés pour ${vacationDelegateName}`, - vacationDelegateWarning: (nameOrEmail: string) => - `Vous assignez ${nameOrEmail} comme remplaçant pendant vos congés. Cette personne n’est pas encore présente dans tous vos espaces de travail. Si vous choisissez de continuer, un e-mail sera envoyé aux administrateurs de tous vos espaces de travail pour l’ajouter.`, + vacationDelegate: { + notAMemberAdminsWillBeAsked: (email: string) => + `${email} n'est pas membre de ces espaces de travail. Les administrateurs des espaces de travail que vous ne gérez pas seront invités à l'ajouter.`, + notAMemberInviteThemNow: (email: string) => `${email} n'est pas membre de ces espaces de travail. Voulez-vous l'inviter maintenant ?`, + notAMemberMixed: (email: string) => + `${email} n'est pas membre de ces espaces de travail. Les administrateurs des espaces de travail que vous ne gérez pas seront invités à l'ajouter. Voulez-vous l'inviter dès maintenant à ceux dont vous êtes administrateur ?`, + youAreAMemberOf: 'Vous êtes membre de ces espaces de travail :', + youAreAnAdminOf: 'Vous êtes administrateur de ces espaces de travail :', + }, }, stepCounter: (step: number, total?: number, text?: string) => { let result = `Étape ${step}`; diff --git a/src/languages/it.ts b/src/languages/it.ts index 99ea29448aa5..86945005d29f 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -3705,8 +3705,15 @@ ${amount} per ${merchant} - ${date}`, vacationDelegateError: 'Si è verificato un errore durante l’aggiornamento del tuo delegato per le ferie.', asVacationDelegate: (nameOrEmail: string) => `come delegato per le ferie di ${nameOrEmail}`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `a ${submittedToName} come delegato ferie per ${vacationDelegateName}`, - vacationDelegateWarning: (nameOrEmail: string) => - `Stai assegnando ${nameOrEmail} come tuo delegato per le ferie. Non fa ancora parte di tutti i tuoi spazi di lavoro. Se scegli di continuare, verrà inviata un’email a tutti gli amministratori dei tuoi spazi di lavoro per aggiungerlo.`, + vacationDelegate: { + notAMemberAdminsWillBeAsked: (email: string) => + `${email} non è membro di questi spazi di lavoro. Agli amministratori degli spazi di lavoro che non gestisci verrà chiesto di aggiungerlo.`, + notAMemberInviteThemNow: (email: string) => `${email} non è membro di questi spazi di lavoro. Vuoi invitarlo ora?`, + notAMemberMixed: (email: string) => + `${email} non è membro di questi spazi di lavoro. Agli amministratori degli spazi di lavoro che non gestisci verrà chiesto di aggiungerlo. Vuoi invitarlo ora in quelli di cui sei amministratore?`, + youAreAMemberOf: 'Sei membro di questi spazi di lavoro:', + youAreAnAdminOf: 'Sei amministratore di questi spazi di lavoro:', + }, }, stepCounter: (step: number, total?: number, text?: string) => { let result = `Passaggio ${step}`; diff --git a/src/languages/ja.ts b/src/languages/ja.ts index ef1e68828911..3296b33e8eb3 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -3667,8 +3667,15 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' vacationDelegateError: '休暇の代理人を更新中にエラーが発生しました。', asVacationDelegate: (nameOrEmail: string) => `${nameOrEmail} さんの休暇代理として`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `${vacationDelegateName} の休暇代理人として ${submittedToName} に`, - vacationDelegateWarning: (nameOrEmail: string) => - `${nameOrEmail} さんをあなたの休暇代理人に指定しようとしています。この人は、まだすべてのワークスペースに参加していません。続行すると、すべてのワークスペース管理者に、この人を追加するようメールが送信されます。`, + vacationDelegate: { + notAMemberAdminsWillBeAsked: (email: string) => + `${email}はこれらのワークスペースのメンバーではありません。あなたが管理していないワークスペースの管理者に追加を依頼します。`, + notAMemberInviteThemNow: (email: string) => `${email}はこれらのワークスペースのメンバーではありません。今すぐ招待しますか?`, + notAMemberMixed: (email: string) => + `${email}はこれらのワークスペースのメンバーではありません。あなたが管理していないワークスペースの管理者に追加を依頼します。あなたが管理者になっているワークスペースには今すぐ招待しますか?`, + youAreAMemberOf: 'あなたはこれらのワークスペースのメンバーです:', + youAreAnAdminOf: 'あなたはこれらのワークスペースの管理者です:', + }, }, stepCounter: (step: number, total?: number, text?: string) => { let result = `ステップ ${step}`; diff --git a/src/languages/nl.ts b/src/languages/nl.ts index ab7962de3e4a..8e9a901febe8 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -3704,8 +3704,15 @@ ${amount} voor ${merchant} - ${date}`, vacationDelegateError: 'Er is een fout opgetreden bij het bijwerken van je vervanger tijdens vakantie.', asVacationDelegate: (nameOrEmail: string) => `als vakantiewaarnemer van ${nameOrEmail}`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `aan ${submittedToName} als vakantieplaatsvervanger voor ${vacationDelegateName}`, - vacationDelegateWarning: (nameOrEmail: string) => - `Je wijst ${nameOrEmail} aan als jouw vervang(st)er tijdens afwezigheid. Diegene zit nog niet in al je werkruimtes. Als je doorgaat, wordt er een e-mail naar alle beheerders van je werkruimtes gestuurd om diegene toe te voegen.`, + vacationDelegate: { + notAMemberAdminsWillBeAsked: (email: string) => + `${email} is geen lid van deze werkruimtes. Beheerders van de werkruimtes die je niet beheert, wordt gevraagd diegene toe te voegen.`, + notAMemberInviteThemNow: (email: string) => `${email} is geen lid van deze werkruimtes. Wil je diegene nu uitnodigen?`, + notAMemberMixed: (email: string) => + `${email} is geen lid van deze werkruimtes. Beheerders van de werkruimtes die je niet beheert, wordt gevraagd diegene toe te voegen. Wil je diegene nu uitnodigen voor de werkruimtes waar je beheerder van bent?`, + youAreAMemberOf: 'Je bent lid van deze werkruimtes:', + youAreAnAdminOf: 'Je bent beheerder van deze werkruimtes:', + }, }, stepCounter: (step: number, total?: number, text?: string) => { let result = `Stap ${step}`; diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 49e667a97f36..be1252f28d67 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -3731,8 +3731,15 @@ ${amount} dla ${merchant} - ${date}`, vacationDelegateError: 'Wystąpił błąd podczas aktualizowania Twojego zastępcy urlopowego.', asVacationDelegate: (nameOrEmail: string) => `jako osoba zastępująca ${nameOrEmail} podczas urlopu`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `do ${submittedToName} jako zastępca urlopowy dla ${vacationDelegateName}`, - vacationDelegateWarning: (nameOrEmail: string) => - `Przydzielasz ${nameOrEmail} jako osobę zastępującą Cię podczas urlopu. Nie jest ona jeszcze we wszystkich Twoich przestrzeniach roboczych. Jeśli zdecydujesz się kontynuować, do wszystkich administratorów Twoich przestrzeni roboczych zostanie wysłany e-mail z prośbą o dodanie jej.`, + vacationDelegate: { + notAMemberAdminsWillBeAsked: (email: string) => + `${email} nie jest członkiem tych przestrzeni roboczych. Administratorzy przestrzeni roboczych, którymi nie zarządzasz, zostaną poproszeni o dodanie tej osoby.`, + notAMemberInviteThemNow: (email: string) => `${email} nie jest członkiem tych przestrzeni roboczych. Czy chcesz zaprosić tę osobę teraz?`, + notAMemberMixed: (email: string) => + `${email} nie jest członkiem tych przestrzeni roboczych. Administratorzy przestrzeni roboczych, którymi nie zarządzasz, zostaną poproszeni o dodanie tej osoby. Czy chcesz zaprosić tę osobę do przestrzeni, w których jesteś administratorem?`, + youAreAMemberOf: 'Jesteś członkiem tych przestrzeni roboczych:', + youAreAnAdminOf: 'Jesteś administratorem tych przestrzeni roboczych:', + }, }, stepCounter: (step: number, total?: number, text?: string) => { let result = `Krok ${step}`; diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index cd968008ff92..867401a46633 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -3693,8 +3693,15 @@ ${amount} para ${merchant} - ${date}`, vacationDelegateError: 'Ocorreu um erro ao atualizar seu delegado de férias.', asVacationDelegate: (nameOrEmail: string) => `como delegado de férias de ${nameOrEmail}`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `para ${submittedToName} como delegado(a) de férias de ${vacationDelegateName}`, - vacationDelegateWarning: (nameOrEmail: string) => - `Você está atribuindo ${nameOrEmail} como seu delegado de férias. Elu ainda não está em todos os seus espaços de trabalho. Se você decidir continuar, será enviado um e-mail a todos os admins dos seus espaços de trabalho para que elu seja adicionado.`, + vacationDelegate: { + notAMemberAdminsWillBeAsked: (email: string) => + `${email} não é membro desses workspaces. Os admins dos workspaces que você não gerencia serão solicitados a adicioná-lo(a).`, + notAMemberInviteThemNow: (email: string) => `${email} não é membro desses workspaces. Deseja convidá-lo(a) agora?`, + notAMemberMixed: (email: string) => + `${email} não é membro desses workspaces. Os admins dos workspaces que você não gerencia serão solicitados a adicioná-lo(a). Deseja convidá-lo(a) agora para os workspaces em que você é admin?`, + youAreAMemberOf: 'Você é membro destes workspaces:', + youAreAnAdminOf: 'Você é admin destes workspaces:', + }, }, stepCounter: (step: number, total?: number, text?: string) => { let result = `Etapa ${step}`; diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 5f8214eae1e5..23375c10ac53 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -3582,8 +3582,13 @@ ${amount},商户:${merchant} - 日期:${date}`, vacationDelegateError: '更新你的休假代理时出错。', asVacationDelegate: (nameOrEmail: string) => `作为 ${nameOrEmail} 的休假代理`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `作为 ${vacationDelegateName} 的休假代理人提交给 ${submittedToName}`, - vacationDelegateWarning: (nameOrEmail: string) => - `您正在将 ${nameOrEmail} 设为您的休假代理人。TA 还未加入您所有的工作区。如果继续操作,将会向您所有工作区的管理员发送一封邮件,请他们将 TA 添加进来。`, + vacationDelegate: { + notAMemberAdminsWillBeAsked: (email: string) => `${email} 不是这些工作区的成员。系统会请求你未管理的工作区的管理员将其添加进来。`, + notAMemberInviteThemNow: (email: string) => `${email} 不是这些工作区的成员。你要现在邀请他们吗?`, + notAMemberMixed: (email: string) => `${email} 不是这些工作区的成员。系统会请求你未管理的工作区的管理员将其添加进来。你要现在邀请他们加入你管理的工作区吗?`, + youAreAMemberOf: '你是这些工作区的成员:', + youAreAnAdminOf: '你是这些工作区的管理员:', + }, }, stepCounter: (step: number, total?: number, text?: string) => { let result = `步骤 ${step}`; diff --git a/src/libs/API/parameters/SetVacationDelegateParams.ts b/src/libs/API/parameters/SetVacationDelegateParams.ts index d149f370d5c5..abf087c6bfd7 100644 --- a/src/libs/API/parameters/SetVacationDelegateParams.ts +++ b/src/libs/API/parameters/SetVacationDelegateParams.ts @@ -4,6 +4,7 @@ type SetVacationDelegateParams = { vacationDelegateEmail: string; overridePolicyDiffWarning: boolean; domainAccountID?: number; + skipPolicyInviteEmails?: boolean; }; export default SetVacationDelegateParams; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 80fdc6d879b7..ec209a5f33ea 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -637,6 +637,7 @@ const WRITE_COMMANDS = { ENABLE_GLOBAL_REIMBURSEMENTS_FOR_USD_BANK_ACCOUNT: 'EnableGlobalReimbursementsForUSDBankAccount', REOPEN_REPORT: 'ReopenReport', TRAVEL_SIGNUP_REQUEST: 'RequestTravelAccess', + SET_VACATION_DELEGATE: 'SetVacationDelegate', DELETE_VACATION_DELEGATE: 'DeleteVacationDelegate', IMPORT_PLAID_ACCOUNTS: 'ImportPlaidAccounts', ADD_PERSONAL_PLAID_CARD: 'AddPersonalPlaidCard', @@ -1108,6 +1109,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.DISABLE_POLICY_COMMUTER_EXCLUSIONS]: Parameters.DisablePolicyCommuterExclusionsParams; [WRITE_COMMANDS.RETRACT_REPORT]: Parameters.RetractReportParams; [WRITE_COMMANDS.FINISH_CORPAY_BANK_ACCOUNT_ONBOARDING]: Parameters.FinishCorpayBankAccountOnboardingParams; + [WRITE_COMMANDS.SET_VACATION_DELEGATE]: Parameters.SetVacationDelegateParams; [WRITE_COMMANDS.DELETE_VACATION_DELEGATE]: Parameters.DeleteVacationDelegateParams | null; [WRITE_COMMANDS.ENABLE_GLOBAL_REIMBURSEMENTS_FOR_USD_BANK_ACCOUNT]: Parameters.EnableGlobalReimbursementsForUSDBankAccountParams; [WRITE_COMMANDS.REOPEN_REPORT]: Parameters.ReopenReportParams; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index efc70173d067..d11ad4e86695 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -505,6 +505,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Profile/CustomStatus/SetDatePage').default, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: () => require('../../../../pages/settings/Profile/CustomStatus/SetTimePage').default, [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE]: () => require('../../../../pages/settings/Profile/CustomStatus/VacationDelegatePage').default, + [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE_MISSING_WORKSPACES]: () => + require('../../../../pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage').default, [SCREENS.SETTINGS.SUBSCRIPTION.SIZE]: () => require('../../../../pages/settings/Subscription/SubscriptionSize').default, [SCREENS.SETTINGS.SUBSCRIPTION.EXPENSIFY_CODE]: () => require('../../../../pages/settings/Subscription/ExpensifyCodePage').default, [SCREENS.SETTINGS.SUBSCRIPTION.SETTINGS_DETAILS]: () => require('../../../../pages/settings/Subscription/SubscriptionSettings').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts index 0b4b8646a872..8f24b4c8c8fe 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts @@ -16,6 +16,7 @@ const SETTINGS_TO_RHP: Partial['config'] = { path: ROUTES.SETTINGS_VACATION_DELEGATE, exact: true, }, + [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE_MISSING_WORKSPACES]: { + path: ROUTES.SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES, + exact: true, + }, [SCREENS.SETTINGS.SUBSCRIPTION.SIZE]: { path: ROUTES.SETTINGS_SUBSCRIPTION_SIZE.route, parse: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 02da79612e4d..63c2db87a871 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -316,6 +316,7 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: undefined; [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: undefined; [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE]: undefined; + [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE_MISSING_WORKSPACES]: undefined; [SCREENS.WORKSPACE.CURRENCY]: { isForcedToChangeCurrency?: boolean; }; diff --git a/src/libs/actions/VacationDelegate.ts b/src/libs/actions/VacationDelegate.ts index 5398ff068dba..b2470bae2c4f 100644 --- a/src/libs/actions/VacationDelegate.ts +++ b/src/libs/actions/VacationDelegate.ts @@ -12,7 +12,15 @@ import type {OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -function setVacationDelegate(creator: string, delegate: string, shouldOverridePolicyDiffWarning = false, currentDelegate?: string) { +type SetVacationDelegateOptions = { + creator: string; + delegate: string; + currentDelegate?: string; + shouldOverridePolicyDiffWarning?: boolean; + shouldSkipPolicyInviteEmails?: boolean; +}; + +async function setVacationDelegate({creator, delegate, currentDelegate, shouldOverridePolicyDiffWarning = false, shouldSkipPolicyInviteEmails}: SetVacationDelegateOptions) { const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -23,6 +31,7 @@ function setVacationDelegate(creator: string, delegate: string, shouldOverridePo errors: null, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, previousDelegate: currentDelegate, + policyDiff: null, }, }, ]; @@ -35,6 +44,7 @@ function setVacationDelegate(creator: string, delegate: string, shouldOverridePo errors: null, pendingAction: null, previousDelegate: null, + policyDiff: null, }, }, ]; @@ -45,6 +55,7 @@ function setVacationDelegate(creator: string, delegate: string, shouldOverridePo key: ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, value: { errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('statusPage.vacationDelegateError'), + pendingAction: null, }, }, ]; @@ -53,11 +64,32 @@ function setVacationDelegate(creator: string, delegate: string, shouldOverridePo creator, vacationDelegateEmail: delegate, overridePolicyDiffWarning: shouldOverridePolicyDiffWarning, + skipPolicyInviteEmails: shouldSkipPolicyInviteEmails, }; - // We need to read the API response for showing a warning if there is a policy diff warning. - // eslint-disable-next-line rulesdir/no-api-side-effects-method - return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, parameters, {optimisticData, successData, failureData}); + // Once the policy diff warning has been overridden there is nothing left to read from the response, so use a persisted write. + // That keeps this request in the sequential queue behind any workspace invitations sent alongside it, so going offline + // can no longer drop the delegate while the invites are replayed on reconnect. + if (shouldOverridePolicyDiffWarning) { + API.write(WRITE_COMMANDS.SET_VACATION_DELEGATE, parameters, {optimisticData, successData, failureData}); + return; + } + + // We need to read the API response for capturing a policy diff warning. This is the other half of the branch above, not a chained call. + // No failureData: the API layer treats the 305 policy diff warning as a failure, and any error written for this request lights up a red brick + // road on the profile page, which reads as something being broken. The caller reports every outcome from the returned response instead. + // eslint-disable-next-line rulesdir/no-api-side-effects-method, rulesdir/no-multiple-api-calls + const response = await API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, parameters, {optimisticData, successData}); + + if (response?.jsonCode === CONST.JSON_CODE.POLICY_DIFF_WARNING && response.data?.policyDiff) { + // Keep the optimistic delegate so the flow can continue into the missing workspaces step. + await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { + policyDiff: response.data.policyDiff, + pendingAction: null, + }); + } + + return response; } function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { @@ -107,11 +139,12 @@ function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { } function clearVacationDelegateError(previousDelegate?: string) { - Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { + return Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { errors: null, pendingAction: null, delegate: previousDelegate ?? null, previousDelegate: null, + policyDiff: null, }); } diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx new file mode 100644 index 000000000000..49c7f7d91feb --- /dev/null +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx @@ -0,0 +1,227 @@ +import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; +import Button from '@components/ButtonComposed'; +import FixedFooter from '@components/FixedFooter'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import RenderHTML from '@components/RenderHTML'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import Section from '@components/Section'; + +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {addMembersToWorkspace} from '@libs/actions/Policy/Member'; +import {clearVacationDelegateError, setVacationDelegate} from '@libs/actions/VacationDelegate'; +import Navigation from '@libs/Navigation/Navigation'; +import {getAccountIDsByLogins, getNewAccountIDsAndLogins, getPersonalDetailsOnyxDataForOptimisticUsers} from '@libs/PersonalDetailsUtils'; +import {getMemberAccountIDsForWorkspace} from '@libs/PolicyUtils'; +import {getAllPolicyExpenseChatReportActions} from '@libs/ReportUtils'; + +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {VacationDelegatePolicyDiff} from '@src/types/onyx'; + +import React, {useEffect, useRef, useState} from 'react'; +import {View} from 'react-native'; + +/** Everything this screen renders from */ +type ScreenInput = { + delegate: string; + policyDiff: VacationDelegatePolicyDiff; +}; + +function VacationDelegateMissingWorkspacesPage() { + const styles = useThemeStyles(); + const {translate, formatPhoneNumber} = useLocalize(); + const {isOffline} = useNetwork(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const currentUser = { + accountID: currentUserPersonalDetails.accountID, + displayName: currentUserPersonalDetails.displayName, + email: currentUserPersonalDetails.email, + avatar: currentUserPersonalDetails.avatar, + }; + + const [vacationDelegate] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [allPersonalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); + + const vacationDelegateRef = useRef(vacationDelegate); + useEffect(() => { + vacationDelegateRef.current = vacationDelegate; + }, [vacationDelegate]); + + // Leaving without submitting rolls the delegate back. Doing it here rather than in the back handler keeps every exit + // (back button, swipe, dismissing the RHP) on one path, and keeps the flow state intact while this screen is still mounted. + const isSubmittingRef = useRef(false); + useEffect( + () => () => { + if (isSubmittingRef.current) { + return; + } + clearVacationDelegateError(vacationDelegateRef.current?.previousDelegate); + }, + [], + ); + + // Submitting nulls the flow state in Onyx while Navigation is still waiting on the transition to pop this screen, so render from + // what was submitted for the rest of its life. Otherwise NotFoundPage takes over and the copy flips to the previous delegate's email. + const [submittedInput, setSubmittedInput] = useState(); + + const creator = currentUserPersonalDetails.login ?? ''; + const delegate = submittedInput?.delegate ?? vacationDelegate?.delegate ?? ''; + const previousDelegate = vacationDelegate?.previousDelegate; + const policyDiff = submittedInput?.policyDiff ?? vacationDelegate?.policyDiff; + const adminPolicies = policyDiff?.adminPolicies ?? []; + const nonAdminPolicies = policyDiff?.nonAdminPolicies ?? []; + const canInvite = adminPolicies.length > 0; + + if (!policyDiff) { + return ; + } + + const getMenuItemsForPolicies = (policyIDs: string[]) => + policyIDs.map((policyID, index) => ({ + key: policyID, + title: policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.name, + avatarID: policyID, + icon: policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.avatarURL, + iconType: CONST.ICON_TYPE_WORKSPACE, + description: translate('workspace.common.workspace'), + interactive: false, + wrapperStyle: [styles.ph4, index < policyIDs.length - 1 ? styles.borderBottom : undefined], + })); + + const goBackToStatus = () => { + Navigation.goBack(ROUTES.SETTINGS_STATUS); + }; + + const submit = (shouldSkipPolicyInviteEmails: boolean) => { + isSubmittingRef.current = true; + setSubmittedInput({delegate, policyDiff}); + setVacationDelegate({creator, delegate, currentDelegate: previousDelegate, shouldOverridePolicyDiffWarning: true, shouldSkipPolicyInviteEmails}); + goBackToStatus(); + }; + + const invite = () => { + // The delegate may have been picked from the selector without existing in personal details yet, so fall back to an optimistic accountID. + const [delegateAccountID] = getAccountIDsByLogins([delegate]); + const invitedEmailsToAccountIDs = {[delegate]: delegateAccountID}; + const {newAccountIDs, newLogins} = getNewAccountIDsAndLogins(invitedEmailsToAccountIDs, allPersonalDetails); + const personalDetailsOnyxData = getPersonalDetailsOnyxDataForOptimisticUsers(newLogins, newAccountIDs, formatPhoneNumber); + const reportActionsList = getAllPolicyExpenseChatReportActions(allReports, allReportActions); + + for (const policyID of adminPolicies) { + const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + addMembersToWorkspace( + invitedEmailsToAccountIDs, + personalDetailsOnyxData, + `# ${currentUserPersonalDetails.displayName ?? ''} invited you to ${policy?.name ?? ''}\n\n${translate('workspace.common.welcomeNote')}`, + policy, + Object.values(getMemberAccountIDsForWorkspace(policy?.employeeList, false, false)), + CONST.POLICY.ROLE.USER, + currentUser, + reportActionsList, + ); + } + + // Workspaces the current user does not administer are untouched here and still need their own admins emailed. + submit(false); + }; + + let introCopy: string; + if (!canInvite) { + introCopy = translate('statusPage.vacationDelegate.notAMemberAdminsWillBeAsked', delegate); + } else if (nonAdminPolicies.length > 0) { + introCopy = translate('statusPage.vacationDelegate.notAMemberMixed', delegate); + } else { + introCopy = translate('statusPage.vacationDelegate.notAMemberInviteThemNow', delegate); + } + + const policySections = ( + + {nonAdminPolicies.length > 0 && ( +
+ )} + {adminPolicies.length > 0 && ( +
0 ? styles.mt5 : undefined]} + menuItems={getMenuItemsForPolicies(adminPolicies)} + /> + )} + + ); + + return ( + + Navigation.goBack(ROUTES.SETTINGS_VACATION_DELEGATE)} + /> + + + + + + {policySections} + + + + {canInvite ? ( + <> + + + + ) : ( + + )} + + + ); +} + +VacationDelegateMissingWorkspacesPage.displayName = 'VacationDelegateMissingWorkspacesPage'; + +export default VacationDelegateMissingWorkspacesPage; diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx index b1eeda215028..f29b61953f82 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx @@ -1,5 +1,4 @@ import BaseVacationDelegateSelectionComponent from '@components/BaseVacationDelegateSelectionComponent'; -import {ModalActions} from '@components/Modal/Global/ModalContext'; import ScreenWrapper from '@components/ScreenWrapper'; import useConfirmModal from '@hooks/useConfirmModal'; @@ -39,27 +38,6 @@ function VacationDelegatePage() { clearVacationDelegateError(vacationDelegateRef.current?.previousDelegate); }; - const showWarningModal = useCallback( - async (delegateLogin: string, delegateDisplayName: string | undefined) => { - const result = await showConfirmModal({ - title: translate('common.headsUp'), - prompt: translate('statusPage.vacationDelegateWarning', delegateDisplayName ?? delegateLogin), - confirmText: translate('common.confirm'), - cancelText: translate('common.cancel'), - shouldShowCancelButton: true, - }); - - if (result.action === ModalActions.CONFIRM) { - await setVacationDelegate(currentUserLogin, delegateLogin, true, vacationDelegateRef.current?.previousDelegate); - Navigation.goBack(ROUTES.SETTINGS_STATUS); - return; - } - - clearVacationDelegateError(vacationDelegateRef.current?.previousDelegate); - }, - [showConfirmModal, translate, currentUserLogin], - ); - const onSelectRow = useCallback( (option: Participant) => { if (option?.login === vacationDelegate?.delegate) { @@ -68,26 +46,22 @@ function VacationDelegatePage() { return; } - setVacationDelegate(currentUserLogin, option?.login ?? '', false, vacationDelegate?.delegate).then((response) => { - if (!response?.jsonCode) { - Navigation.goBack(ROUTES.SETTINGS_STATUS); - return; - } - - if (response.jsonCode === CONST.JSON_CODE.EXP_ERROR) { - showErrorModal(response.message); + setVacationDelegate({creator: currentUserLogin, delegate: option?.login ?? '', currentDelegate: vacationDelegate?.delegate}).then((response) => { + if (response?.jsonCode === CONST.JSON_CODE.POLICY_DIFF_WARNING) { + Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES); return; } - if (response.jsonCode === CONST.JSON_CODE.POLICY_DIFF_WARNING) { - showWarningModal(option?.login ?? '', option.text); + // The request writes no error of its own, so this modal is the only feedback for a failure. Dismissing it restores the previous delegate. + if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { + showErrorModal(response?.jsonCode === CONST.JSON_CODE.EXP_ERROR ? response.message : undefined); return; } Navigation.goBack(ROUTES.SETTINGS_STATUS); }); }, - [currentUserLogin, vacationDelegate, showWarningModal, showErrorModal], + [currentUserLogin, vacationDelegate, showErrorModal], ); return ( diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts index 1c4d1f18fef4..1d78bcd00ab7 100644 --- a/src/types/onyx/Response.ts +++ b/src/types/onyx/Response.ts @@ -3,6 +3,7 @@ import type {MultifactorAuthenticationChallengeObject} from '@libs/MultifactorAu import type {OnyxKey, OnyxUpdate} from 'react-native-onyx'; import type TransactionsPending3DSReview from './TransactionsPending3DSReview'; +import type {VacationDelegatePolicyDiff} from './VacationDelegate'; /** Model of commands data */ type Data = { @@ -11,6 +12,9 @@ type Data = { /** Collection of auth write requests */ authWriteCommands: string[]; + + /** Workspaces the vacation delegate is missing from, returned with jsonCode 305 by SetVacationDelegate */ + policyDiff?: VacationDelegatePolicyDiff; }; /** Model of server response */ diff --git a/src/types/onyx/VacationDelegate.ts b/src/types/onyx/VacationDelegate.ts index 63e84d55dce8..de4e9fd1116d 100644 --- a/src/types/onyx/VacationDelegate.ts +++ b/src/types/onyx/VacationDelegate.ts @@ -1,5 +1,14 @@ import type * as OnyxCommon from './OnyxCommon'; +/** Workspaces the vacation delegate is missing from, captured from a SetVacationDelegate 305 response */ +type VacationDelegatePolicyDiff = { + /** Policy IDs the current user administers, but the delegate is not a member of */ + adminPolicies: string[]; + + /** Policy IDs the current user is a non-admin member of, but the delegate is not a member of */ + nonAdminPolicies: string[]; +}; + /** Base vacation delegate information */ type BaseVacationDelegate = { /** Email of the user that set the vacation delegate */ @@ -13,6 +22,9 @@ type BaseVacationDelegate = { /** Previous delegate for rollback on failure */ previousDelegate?: string; + + /** Workspaces the delegate is missing from, captured from the 305 response. Client-only. */ + policyDiff?: VacationDelegatePolicyDiff; }; /** Information about vacation delegate with offline feedback */ @@ -24,4 +36,4 @@ type VacationDelegate = OnyxCommon.OnyxValueWithOfflineFeedback< >; export default VacationDelegate; -export type {BaseVacationDelegate}; +export type {BaseVacationDelegate, VacationDelegatePolicyDiff}; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 38641c2358a8..52ac6a9df2b7 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -199,6 +199,7 @@ import type UserLocation from './UserLocation'; import type UserMetadata from './UserMetadata'; import type UserWallet from './UserWallet'; import type VacationDelegate from './VacationDelegate'; +import type {VacationDelegatePolicyDiff} from './VacationDelegate'; import type ValidateDomainTwoFactorCode from './ValidateDomainTwoFactorCode'; import type ValidateUserAndGetAccessiblePolicies from './ValidateUserAndGetAccessiblePolicies'; import type VerifyValidateCodeAction from './VerifyValidateCodeAction'; @@ -425,6 +426,7 @@ export type { ScheduleCallDraft, ValidateUserAndGetAccessiblePolicies, VacationDelegate, + VacationDelegatePolicyDiff, BillingReceiptDetails, ExportTemplate, HybridApp, diff --git a/tests/actions/VacationDelegateTest.ts b/tests/actions/VacationDelegateTest.ts new file mode 100644 index 000000000000..5c9d93cf7081 --- /dev/null +++ b/tests/actions/VacationDelegateTest.ts @@ -0,0 +1,160 @@ +import {clearVacationDelegateError, setVacationDelegate} from '@libs/actions/VacationDelegate'; +import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; + +import CONST from '@src/CONST'; +import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +OnyxUpdateManager(); +describe('actions/VacationDelegate', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(() => { + return Onyx.clear().then(waitForBatchedUpdates); + }); + + describe('setVacationDelegate', () => { + it('sends SetVacationDelegate with the mapped params and clears policyDiff optimistically', () => { + const apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve()); + + setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', currentDelegate: 'old@test.com', shouldSkipPolicyInviteEmails: true}); + + expect(apiSideEffectSpy).toHaveBeenCalledWith( + SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, + { + creator: 'admin@test.com', + vacationDelegateEmail: 'delegate@test.com', + overridePolicyDiffWarning: false, + skipPolicyInviteEmails: true, + }, + { + optimisticData: expect.arrayContaining([ + expect.objectContaining({ + key: ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, + value: expect.objectContaining({policyDiff: null, previousDelegate: 'old@test.com'}), + }), + ]), + successData: expect.arrayContaining([ + expect.objectContaining({ + key: ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, + value: expect.objectContaining({policyDiff: null}), + }), + ]), + }, + ); + + // The 305 policy diff warning is a non-200 response, so passing failureData would make the request pipeline + // write an error we immediately have to clear, flashing a red brick road. The action applies it from the response instead. + expect(apiSideEffectSpy.mock.calls.at(0)?.at(2)).not.toHaveProperty('failureData'); + + apiSideEffectSpy.mockRestore(); + }); + + it('sends a persisted write instead of a side effect request once the policy diff warning is overridden', () => { + const apiWriteSpy = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => undefined); + const apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve()); + + setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', shouldOverridePolicyDiffWarning: true}); + + expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.SET_VACATION_DELEGATE, expect.objectContaining({overridePolicyDiffWarning: true}), expect.anything()); + expect(apiSideEffectSpy).not.toHaveBeenCalled(); + + jest.restoreAllMocks(); + }); + + it('merges the policyDiff from a 305 response into the NVP without ever surfacing an error', async () => { + const policyDiff = {adminPolicies: ['1'], nonAdminPolicies: ['2']}; + const errorStates: Array = []; + const connection = Onyx.connectWithoutView({ + key: ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, + callback: (value) => errorStates.push(value?.errors), + }); + + jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(async () => { + // The optimistic data is applied before the response resolves. + await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { + creator: 'admin@test.com', + delegate: 'delegate@test.com', + previousDelegate: 'old@test.com', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + errors: null, + }); + return {jsonCode: CONST.JSON_CODE.POLICY_DIFF_WARNING, data: {policyDiff}}; + }); + + await setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', currentDelegate: 'old@test.com'}); + await waitForBatchedUpdates(); + Onyx.disconnect(connection); + + const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + expect(vacationDelegate?.policyDiff).toEqual(policyDiff); + expect(vacationDelegate?.pendingAction).toBeFalsy(); + expect(vacationDelegate?.delegate).toBe('delegate@test.com'); + expect(vacationDelegate?.previousDelegate).toBe('old@test.com'); + + // An error at any point would flash a red brick road on the profile page, so it must never be written at all. + expect(errorStates.every(isEmptyObject)).toBe(true); + + jest.restoreAllMocks(); + }); + + it('writes no error on a failed response, leaving the caller to report it', async () => { + const response = {jsonCode: CONST.JSON_CODE.EXP_ERROR, message: 'Nope'}; + jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve(response)); + + // An error here would light up a red brick road on the profile page, which reads as something being broken. + await expect(setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com'})).resolves.toEqual(response); + await waitForBatchedUpdates(); + + const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + expect(vacationDelegate?.errors).toBeFalsy(); + }); + + it('does not merge a policyDiff on a successful (200) response', async () => { + jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve({jsonCode: 200})); + + await setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com'}); + await waitForBatchedUpdates(); + + const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + expect(vacationDelegate?.policyDiff).toBeFalsy(); + + jest.restoreAllMocks(); + }); + }); + + describe('clearVacationDelegateError', () => { + it('clears errors, pendingAction, and policyDiff, and restores the previous delegate', async () => { + const timestamp = 123; + await Onyx.set(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { + creator: 'admin@test.com', + delegate: 'delegate@test.com', + previousDelegate: 'old@test.com', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + errors: {[timestamp]: 'Some error'}, + policyDiff: {adminPolicies: ['1'], nonAdminPolicies: []}, + }); + + clearVacationDelegateError('old@test.com'); + await waitForBatchedUpdates(); + + const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + expect(vacationDelegate?.delegate).toBe('old@test.com'); + expect(vacationDelegate?.previousDelegate).toBeFalsy(); + expect(vacationDelegate?.errors).toBeFalsy(); + expect(vacationDelegate?.pendingAction).toBeFalsy(); + expect(vacationDelegate?.policyDiff).toBeFalsy(); + }); + }); +}); diff --git a/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx new file mode 100644 index 000000000000..a4ac3f563ee7 --- /dev/null +++ b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx @@ -0,0 +1,262 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; +import HTMLEngineProvider from '@components/HTMLEngineProvider'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; + +import VacationDelegateMissingWorkspacesPage from '@pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +import type {VacationDelegatePolicyDiff} from '@src/types/onyx/VacationDelegate'; + +import {PortalProvider} from '@gorhom/portal'; +import {NavigationContainer} from '@react-navigation/native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../utils/getOnyxValue'; +import * as LHNTestUtils from '../utils/LHNTestUtils'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const CREATOR_ACCOUNT_ID = 1; +const CREATOR_EMAIL = 'creator@example.com'; +const DELEGATE_EMAIL = 'delegate@example.com'; +const PREVIOUS_DELEGATE_EMAIL = 'previous@example.com'; + +const MEMBER_POLICY_ID = 'memberPolicy'; +const ADMIN_POLICY_ID = 'adminPolicy'; + +const Stack = createPlatformStackNavigator(); + +function renderPage() { + return render( + + + + + + + + + , + ); +} + +async function seedVacationDelegate(policyDiff?: VacationDelegatePolicyDiff) { + const timestamp = 123; + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { + creator: CREATOR_EMAIL, + delegate: DELEGATE_EMAIL, + previousDelegate: PREVIOUS_DELEGATE_EMAIL, + errors: {[timestamp]: "Vacation delegate is not part of all of vacationer's policies."}, + policyDiff, + }); + }); +} + +describe('VacationDelegateMissingWorkspacesPage', () => { + let apiSideEffectSpy: jest.SpyInstance; + let apiWriteSpy: jest.SpyInstance; + + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); + }); + await TestHelper.signInWithTestUser(CREATOR_ACCOUNT_ID, CREATOR_EMAIL); + + // Mocked only after signing in: TestHelper.signInWithTestUser relies on the real + // API.write/makeRequestWithSideEffects to apply the SESSION/PERSONAL_DETAILS_LIST onyxData + // from its mocked XHR responses. Mocking them earlier silently no-ops that Onyx application, + // leaving useCurrentUserPersonalDetails() stuck on its default (unauthenticated) value. + apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve()); + apiWriteSpy = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); + + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${MEMBER_POLICY_ID}`, { + ...LHNTestUtils.getFakePolicy(MEMBER_POLICY_ID, 'Member Workspace'), + employeeList: {[CREATOR_EMAIL]: {email: CREATOR_EMAIL, role: CONST.POLICY.ROLE.USER}}, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${ADMIN_POLICY_ID}`, { + ...LHNTestUtils.getFakePolicy(ADMIN_POLICY_ID, 'Admin Workspace'), + employeeList: {[CREATOR_EMAIL]: {email: CREATOR_EMAIL, role: CONST.POLICY.ROLE.ADMIN}}, + }); + }); + await waitForBatchedUpdatesWithAct(); + }); + + afterEach(async () => { + jest.restoreAllMocks(); + await act(async () => { + await Onyx.clear(); + }); + await waitForBatchedUpdatesWithAct(); + }); + + it('renders the not found page when there is no policy diff', async () => { + await seedVacationDelegate(undefined); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText(TestHelper.translateLocal('notFound.notHere'))).toBeOnTheScreen(); + }); + + it('shows only the member-of section and a Confirm button when the delegate is admin of none', async () => { + await seedVacationDelegate({adminPolicies: [], nonAdminPolicies: [MEMBER_POLICY_ID]}); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText('Member Workspace')).toBeOnTheScreen(); + expect(screen.queryByText('Admin Workspace')).not.toBeOnTheScreen(); + expect(screen.getByRole('button', {name: TestHelper.translateLocal('common.confirm')})).toBeOnTheScreen(); + expect(screen.queryByRole('button', {name: TestHelper.translateLocal('common.invite')})).not.toBeOnTheScreen(); + + fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.confirm')})); + await waitForBatchedUpdatesWithAct(); + + expect(apiWriteSpy).toHaveBeenCalledWith( + WRITE_COMMANDS.SET_VACATION_DELEGATE, + expect.objectContaining({creator: CREATOR_EMAIL, overridePolicyDiffWarning: true, skipPolicyInviteEmails: false}), + expect.anything(), + ); + expect(apiWriteSpy).not.toHaveBeenCalledWith(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, expect.anything(), expect.anything()); + }); + + it('shows only the admin-of section and Invite/Skip buttons when the delegate is admin of all, and Skip sends no invites', async () => { + await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID], nonAdminPolicies: []}); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText('Admin Workspace')).toBeOnTheScreen(); + expect(screen.queryByText('Member Workspace')).not.toBeOnTheScreen(); + expect(screen.getByRole('button', {name: TestHelper.translateLocal('common.invite')})).toBeOnTheScreen(); + expect(screen.getByRole('button', {name: TestHelper.translateLocal('common.skip')})).toBeOnTheScreen(); + + fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.skip')})); + await waitForBatchedUpdatesWithAct(); + + expect(apiWriteSpy).toHaveBeenCalledWith( + WRITE_COMMANDS.SET_VACATION_DELEGATE, + expect.objectContaining({overridePolicyDiffWarning: true, skipPolicyInviteEmails: true}), + expect.anything(), + ); + expect(apiWriteSpy).not.toHaveBeenCalledWith(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, expect.anything(), expect.anything()); + }); + + it('sends one AddMembersToWorkspace call per admin policy and then SetVacationDelegate when Invite is pressed', async () => { + await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID], nonAdminPolicies: []}); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.invite')})); + await waitForBatchedUpdatesWithAct(); + + expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, expect.objectContaining({policyID: ADMIN_POLICY_ID}), expect.anything()); + + // Both requests go through the persisted queue, so going offline mid-flow cannot invite the delegate without setting them. + expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.SET_VACATION_DELEGATE, expect.objectContaining({overridePolicyDiffWarning: true}), expect.anything()); + expect(apiSideEffectSpy).not.toHaveBeenCalledWith(SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, expect.anything(), expect.anything()); + }); + + it('invites a delegate that has no personal details entry yet using an optimistic accountID', async () => { + await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID], nonAdminPolicies: []}); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.invite')})); + await waitForBatchedUpdatesWithAct(); + + // Both params are keyed off the invited accountID, so they stay empty when the delegate cannot be resolved to one. + expect(apiWriteSpy).toHaveBeenCalledWith( + WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, + // require('@libs/API') is untyped (any), which taints the inferred type of these matchers; the assertion itself is fine. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.objectContaining({employees: expect.stringContaining(DELEGATE_EMAIL), reportCreationData: expect.stringContaining(DELEGATE_EMAIL)}), + expect.anything(), + ); + }); + + it('shows both sections, member-of before admin-of, for a mixed diff', async () => { + await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID], nonAdminPolicies: [MEMBER_POLICY_ID]}); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText('Member Workspace')).toBeOnTheScreen(); + expect(screen.getByText('Admin Workspace')).toBeOnTheScreen(); + expect(screen.getByRole('button', {name: TestHelper.translateLocal('common.invite')})).toBeOnTheScreen(); + expect(screen.getByRole('button', {name: TestHelper.translateLocal('common.skip')})).toBeOnTheScreen(); + }); + + it('clears the error and policyDiff, and restores the previous delegate, on unmount without pressing a button', async () => { + await seedVacationDelegate({adminPolicies: [], nonAdminPolicies: [MEMBER_POLICY_ID]}); + const {unmount} = renderPage(); + await waitForBatchedUpdatesWithAct(); + + unmount(); + await waitForBatchedUpdatesWithAct(); + + const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + expect(vacationDelegate?.delegate).toBe(PREVIOUS_DELEGATE_EMAIL); + expect(vacationDelegate?.errors).toBeFalsy(); + expect(vacationDelegate?.policyDiff).toBeFalsy(); + }); + + it('keeps rendering what was submitted when the flow state is cleared underneath it', async () => { + jest.spyOn(Navigation, 'goBack').mockImplementation(() => {}); + await seedVacationDelegate({adminPolicies: [], nonAdminPolicies: [MEMBER_POLICY_ID]}); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.confirm')})); + await waitForBatchedUpdatesWithAct(); + + // Navigation only pops this screen once the transition finishes, so the submitted request nulls the flow state while it is + // still mounted. That must not turn it into the not found page or flip the copy to the previous delegate. + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, {delegate: PREVIOUS_DELEGATE_EMAIL, policyDiff: null}); + }); + await waitForBatchedUpdatesWithAct(); + + expect(screen.queryByText(TestHelper.translateLocal('notFound.notHere'))).not.toBeOnTheScreen(); + expect(screen.getByText('Member Workspace')).toBeOnTheScreen(); + expect(screen.getByText(DELEGATE_EMAIL)).toBeOnTheScreen(); + expect(screen.queryByText(PREVIOUS_DELEGATE_EMAIL)).not.toBeOnTheScreen(); + }); + + it('navigates back to the delegate selection step without touching the flow state before unmounting', async () => { + const goBackSpy = jest.spyOn(Navigation, 'goBack').mockImplementation(() => {}); + await seedVacationDelegate({adminPolicies: [], nonAdminPolicies: [MEMBER_POLICY_ID]}); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByLabelText(TestHelper.translateLocal('common.back'))); + await waitForBatchedUpdatesWithAct(); + + expect(goBackSpy).toHaveBeenCalledWith(ROUTES.SETTINGS_VACATION_DELEGATE); + + // The rollback happens on unmount instead, so nothing can flash while Navigation is still waiting on the transition. + const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + expect(vacationDelegate?.delegate).toBe(DELEGATE_EMAIL); + expect(vacationDelegate?.policyDiff).not.toBeFalsy(); + expect(screen.queryByText(TestHelper.translateLocal('notFound.notHere'))).not.toBeOnTheScreen(); + expect(screen.getByText(DELEGATE_EMAIL)).toBeOnTheScreen(); + }); +}); From d6a29c2b390fc8af0766c223d3d18288e2ce1782 Mon Sep 17 00:00:00 2001 From: jakubstec Date: Tue, 1 Sep 2026 11:16:13 +0200 Subject: [PATCH 02/23] fix: Guard vacation delegate RHP flow against races and stale UI - Debounce rapid delegate-row taps so overlapping SET_VACATION_DELEGATE requests can't race and navigate on a stale response - Keep the missing-workspaces screen rendering from the submitted input instead of Onyx once submit clears the flow state, so it no longer flashes NotFoundPage/the previous delegate while the RHP is closing - Only roll back the delegate on unmount if this screen ever saw an active policy-diff flow, so a stray visit after the flow ended can't wipe out an unrelated, already-saved delegate - Fall back to a placeholder name for a workspace missing from Onyx --- .../VacationDelegateMissingWorkspacesPage.tsx | 28 ++-- .../CustomStatus/VacationDelegatePage.tsx | 36 +++-- tests/ui/VacationDelegatePageTest.tsx | 134 ++++++++++++++++++ 3 files changed, 175 insertions(+), 23 deletions(-) create mode 100644 tests/ui/VacationDelegatePageTest.tsx diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx index 49c7f7d91feb..d09cb353b3cd 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx @@ -59,27 +59,33 @@ function VacationDelegateMissingWorkspacesPage() { vacationDelegateRef.current = vacationDelegate; }, [vacationDelegate]); + const [submittedInput, setSubmittedInput] = useState(); + + const creator = currentUserPersonalDetails.login ?? ''; + const delegate = submittedInput?.delegate ?? vacationDelegate?.delegate ?? ''; + const previousDelegate = vacationDelegate?.previousDelegate; + const policyDiff = submittedInput?.policyDiff ?? vacationDelegate?.policyDiff; + + const hasActiveFlowRef = useRef(false); + useEffect(() => { + if (!policyDiff) { + return; + } + hasActiveFlowRef.current = true; + }, [policyDiff]); + // Leaving without submitting rolls the delegate back. Doing it here rather than in the back handler keeps every exit // (back button, swipe, dismissing the RHP) on one path, and keeps the flow state intact while this screen is still mounted. const isSubmittingRef = useRef(false); useEffect( () => () => { - if (isSubmittingRef.current) { + if (isSubmittingRef.current || !hasActiveFlowRef.current) { return; } clearVacationDelegateError(vacationDelegateRef.current?.previousDelegate); }, [], ); - - // Submitting nulls the flow state in Onyx while Navigation is still waiting on the transition to pop this screen, so render from - // what was submitted for the rest of its life. Otherwise NotFoundPage takes over and the copy flips to the previous delegate's email. - const [submittedInput, setSubmittedInput] = useState(); - - const creator = currentUserPersonalDetails.login ?? ''; - const delegate = submittedInput?.delegate ?? vacationDelegate?.delegate ?? ''; - const previousDelegate = vacationDelegate?.previousDelegate; - const policyDiff = submittedInput?.policyDiff ?? vacationDelegate?.policyDiff; const adminPolicies = policyDiff?.adminPolicies ?? []; const nonAdminPolicies = policyDiff?.nonAdminPolicies ?? []; const canInvite = adminPolicies.length > 0; @@ -91,7 +97,7 @@ function VacationDelegateMissingWorkspacesPage() { const getMenuItemsForPolicies = (policyIDs: string[]) => policyIDs.map((policyID, index) => ({ key: policyID, - title: policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.name, + title: policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.name ?? translate('workspace.common.unavailable'), avatarID: policyID, icon: policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.avatarURL, iconType: CONST.ICON_TYPE_WORKSPACE, diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx index f29b61953f82..5ef3fe8cf140 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx @@ -27,6 +27,8 @@ function VacationDelegatePage() { vacationDelegateRef.current = vacationDelegate; }, [vacationDelegate]); + const isSelectingRef = useRef(false); + const showErrorModal = async (message?: string) => { await showConfirmModal({ title: translate('statusPage.addVacationDelegate'), @@ -40,26 +42,36 @@ function VacationDelegatePage() { const onSelectRow = useCallback( (option: Participant) => { + if (isSelectingRef.current) { + return; + } + if (option?.login === vacationDelegate?.delegate) { + isSelectingRef.current = true; deleteVacationDelegate(vacationDelegate); Navigation.goBack(ROUTES.SETTINGS_STATUS); return; } - setVacationDelegate({creator: currentUserLogin, delegate: option?.login ?? '', currentDelegate: vacationDelegate?.delegate}).then((response) => { - if (response?.jsonCode === CONST.JSON_CODE.POLICY_DIFF_WARNING) { - Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES); - return; - } + isSelectingRef.current = true; + setVacationDelegate({creator: currentUserLogin, delegate: option?.login ?? '', currentDelegate: vacationDelegate?.delegate}) + .then((response) => { + if (response?.jsonCode === CONST.JSON_CODE.POLICY_DIFF_WARNING) { + Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES); + return; + } - // The request writes no error of its own, so this modal is the only feedback for a failure. Dismissing it restores the previous delegate. - if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { - showErrorModal(response?.jsonCode === CONST.JSON_CODE.EXP_ERROR ? response.message : undefined); - return; - } + // The request writes no error of its own, so this modal is the only feedback for a failure. Dismissing it restores the previous delegate. + if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { + showErrorModal(response?.jsonCode === CONST.JSON_CODE.EXP_ERROR ? response.message : undefined); + return; + } - Navigation.goBack(ROUTES.SETTINGS_STATUS); - }); + Navigation.goBack(ROUTES.SETTINGS_STATUS); + }) + .finally(() => { + isSelectingRef.current = false; + }); }, [currentUserLogin, vacationDelegate, showErrorModal], ); diff --git a/tests/ui/VacationDelegatePageTest.tsx b/tests/ui/VacationDelegatePageTest.tsx new file mode 100644 index 000000000000..dc5d312aa7a4 --- /dev/null +++ b/tests/ui/VacationDelegatePageTest.tsx @@ -0,0 +1,134 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; +import Navigation from '@libs/Navigation/Navigation'; + +import VacationDelegatePage from '@pages/settings/Profile/CustomStatus/VacationDelegatePage'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; + +import type * as ReactNavigation from '@react-navigation/native'; +// eslint-disable-next-line no-restricted-imports -- React Native Pressable/Text are required only to type the actual Jest module export; this does not import them at runtime. +import type {Pressable as ReactNativePressable, Text as ReactNativeText} from 'react-native'; + +import {NavigationContainer} from '@react-navigation/native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const CREATOR_ACCOUNT_ID = 1; +const CREATOR_EMAIL = 'creator@example.com'; +const DELEGATE_A_EMAIL = 'delegateA@example.com'; + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + goBack: jest.fn(), + getActiveRoute: jest.fn(() => ''), + getActiveRouteWithoutParams: jest.fn(() => ''), + isNavigationReady: jest.fn(() => Promise.resolve()), +})); + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useRoute: jest.fn(() => ({params: {}})), + usePreventRemove: jest.fn(), + }; +}); + +// Replaces the real, personal-details-backed selection list with two plain pressable rows so tests can +// simulate rapid row taps on VacationDelegatePage's onSelectRow without driving the full list UI. +jest.mock('@components/BaseVacationDelegateSelectionComponent', () => { + const ReactMock = jest.requireActual('react'); + const {Pressable, Text} = jest.requireActual<{Pressable: typeof ReactNativePressable; Text: typeof ReactNativeText}>('react-native'); + + return ({onSelectRow}: {onSelectRow: (option: {login: string}) => void}) => + ReactMock.createElement( + ReactMock.Fragment, + null, + ReactMock.createElement(Pressable, {testID: 'select-delegate-a', onPress: () => onSelectRow({login: 'delegateA@example.com'})}, ReactMock.createElement(Text, null, 'select-a')), + ReactMock.createElement(Pressable, {testID: 'select-delegate-b', onPress: () => onSelectRow({login: 'delegateB@example.com'})}, ReactMock.createElement(Text, null, 'select-b')), + ); +}); + +function renderPage() { + return render( + + + + + , + ); +} + +describe('VacationDelegatePage', () => { + let apiSideEffectSpy: jest.SpyInstance; + + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); + }); + await TestHelper.signInWithTestUser(CREATOR_ACCOUNT_ID, CREATOR_EMAIL); + await waitForBatchedUpdatesWithAct(); + }); + + afterEach(async () => { + jest.restoreAllMocks(); + await act(async () => { + await Onyx.clear(); + }); + await waitForBatchedUpdatesWithAct(); + }); + + it('calls setVacationDelegate once for a single selection', async () => { + apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve({jsonCode: CONST.JSON_CODE.SUCCESS})); + + renderPage(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByTestId('select-delegate-a')); + await waitForBatchedUpdatesWithAct(); + + expect(apiSideEffectSpy).toHaveBeenCalledTimes(1); + expect(apiSideEffectSpy).toHaveBeenCalledWith( + SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, + expect.objectContaining({creator: CREATOR_EMAIL, vacationDelegateEmail: DELEGATE_A_EMAIL}), + expect.anything(), + ); + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.SETTINGS_STATUS); + }); + + it('ignores a second row selection while the first request is still pending', async () => { + // Never resolves, so the request stays "in flight" for the duration of the test. + apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => new Promise(() => {})); + + renderPage(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByTestId('select-delegate-a')); + fireEvent.press(screen.getByTestId('select-delegate-b')); + await waitForBatchedUpdatesWithAct(); + + expect(apiSideEffectSpy).toHaveBeenCalledTimes(1); + expect(apiSideEffectSpy).toHaveBeenCalledWith( + SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, + expect.objectContaining({vacationDelegateEmail: DELEGATE_A_EMAIL}), + expect.anything(), + ); + }); +}); From e57d9006d60b97e88378aa711269a7d2c91f4ef6 Mon Sep 17 00:00:00 2001 From: jakubstec Date: Tue, 1 Sep 2026 12:19:59 +0200 Subject: [PATCH 03/23] fix: guard invite/skip/confirm against double press, rollback optimistic delegate when request rejects, disable invite until every admin policy that is missing is loaded, foramt sms delegate logins as phone numbers, add selectors to onyx subscriptions, switch setDomainVacationDelegate to API.write --- src/libs/actions/Domain.ts | 4 +- .../VacationDelegateMissingWorkspacesPage.tsx | 82 +++++++++++-------- .../CustomStatus/VacationDelegatePage.tsx | 2 + src/selectors/Report.ts | 12 +++ tests/actions/DomainTest.ts | 18 ++-- ...ationDelegateMissingWorkspacesPageTest.tsx | 37 +++++++-- tests/ui/VacationDelegatePageTest.tsx | 25 ++++++ tests/unit/ReportSelectorTest.ts | 47 ++++++++++- 8 files changed, 172 insertions(+), 55 deletions(-) diff --git a/src/libs/actions/Domain.ts b/src/libs/actions/Domain.ts index 301cc3f02148..b2a93b61db30 100644 --- a/src/libs/actions/Domain.ts +++ b/src/libs/actions/Domain.ts @@ -1373,9 +1373,7 @@ function setDomainVacationDelegate(domainAccountID: number, domainMemberAccountI domainAccountID, }; - // We don't use the side effect here but `SetVacationDelegate` command is declared as side effect command - // eslint-disable-next-line rulesdir/no-api-side-effects-method - API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, parameters, {optimisticData, successData, failureData}); + API.write(WRITE_COMMANDS.SET_VACATION_DELEGATE, parameters, {optimisticData, successData, failureData}); } function deleteDomainVacationDelegate(domainAccountID: number, domainMemberAccountID: number, vacationer: string, vacationDelegate: BaseVacationDelegate) { diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx index d09cb353b3cd..afc15434f9a3 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx @@ -9,7 +9,6 @@ import Section from '@components/Section'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -25,12 +24,13 @@ import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import {policyExpenseChatReportsSelector} from '@src/selectors/Report'; import type {VacationDelegatePolicyDiff} from '@src/types/onyx'; +import {Str} from 'expensify-common'; import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; -/** Everything this screen renders from */ type ScreenInput = { delegate: string; policyDiff: VacationDelegatePolicyDiff; @@ -39,7 +39,6 @@ type ScreenInput = { function VacationDelegateMissingWorkspacesPage() { const styles = useThemeStyles(); const {translate, formatPhoneNumber} = useLocalize(); - const {isOffline} = useNetwork(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUser = { accountID: currentUserPersonalDetails.accountID, @@ -51,8 +50,10 @@ function VacationDelegateMissingWorkspacesPage() { const [vacationDelegate] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [allPersonalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); - const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); - const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: policyExpenseChatReportsSelector}); + const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { + selector: (reportActions) => getAllPolicyExpenseChatReportActions(allReports, reportActions), + }); const vacationDelegateRef = useRef(vacationDelegate); useEffect(() => { @@ -89,6 +90,7 @@ function VacationDelegateMissingWorkspacesPage() { const adminPolicies = policyDiff?.adminPolicies ?? []; const nonAdminPolicies = policyDiff?.nonAdminPolicies ?? []; const canInvite = adminPolicies.length > 0; + const hasUnresolvedAdminPolicy = adminPolicies.some((policyID) => !policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.id); if (!policyDiff) { return ; @@ -111,6 +113,10 @@ function VacationDelegateMissingWorkspacesPage() { }; const submit = (shouldSkipPolicyInviteEmails: boolean) => { + if (isSubmittingRef.current) { + return; + } + isSubmittingRef.current = true; setSubmittedInput({delegate, policyDiff}); setVacationDelegate({creator, delegate, currentDelegate: previousDelegate, shouldOverridePolicyDiffWarning: true, shouldSkipPolicyInviteEmails}); @@ -118,12 +124,15 @@ function VacationDelegateMissingWorkspacesPage() { }; const invite = () => { + if (isSubmittingRef.current || hasUnresolvedAdminPolicy) { + return; + } + // The delegate may have been picked from the selector without existing in personal details yet, so fall back to an optimistic accountID. const [delegateAccountID] = getAccountIDsByLogins([delegate]); const invitedEmailsToAccountIDs = {[delegate]: delegateAccountID}; const {newAccountIDs, newLogins} = getNewAccountIDsAndLogins(invitedEmailsToAccountIDs, allPersonalDetails); const personalDetailsOnyxData = getPersonalDetailsOnyxDataForOptimisticUsers(newLogins, newAccountIDs, formatPhoneNumber); - const reportActionsList = getAllPolicyExpenseChatReportActions(allReports, allReportActions); for (const policyID of adminPolicies) { const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; @@ -135,7 +144,7 @@ function VacationDelegateMissingWorkspacesPage() { Object.values(getMemberAccountIDsForWorkspace(policy?.employeeList, false, false)), CONST.POLICY.ROLE.USER, currentUser, - reportActionsList, + allReportActions, ); } @@ -143,13 +152,15 @@ function VacationDelegateMissingWorkspacesPage() { submit(false); }; + // Format SMS delegates as phone numbers rather than raw @expensify.sms logins, and escape the result since this copy is rendered as HTML. + const escapedDelegate = Str.htmlEncode(formatPhoneNumber(delegate)); let introCopy: string; if (!canInvite) { - introCopy = translate('statusPage.vacationDelegate.notAMemberAdminsWillBeAsked', delegate); + introCopy = translate('statusPage.vacationDelegate.notAMemberAdminsWillBeAsked', escapedDelegate); } else if (nonAdminPolicies.length > 0) { - introCopy = translate('statusPage.vacationDelegate.notAMemberMixed', delegate); + introCopy = translate('statusPage.vacationDelegate.notAMemberMixed', escapedDelegate); } else { - introCopy = translate('statusPage.vacationDelegate.notAMemberInviteThemNow', delegate); + introCopy = translate('statusPage.vacationDelegate.notAMemberInviteThemNow', escapedDelegate); } const policySections = ( @@ -193,37 +204,36 @@ function VacationDelegateMissingWorkspacesPage() { {policySections} - - - {canInvite ? ( - <> + + {canInvite ? ( + <> + + + + ) : ( - - - ) : ( - - )} - + )} + + ); } diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx index 5ef3fe8cf140..a7b9a9dc39c3 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx @@ -50,6 +50,7 @@ function VacationDelegatePage() { isSelectingRef.current = true; deleteVacationDelegate(vacationDelegate); Navigation.goBack(ROUTES.SETTINGS_STATUS); + isSelectingRef.current = false; return; } @@ -69,6 +70,7 @@ function VacationDelegatePage() { Navigation.goBack(ROUTES.SETTINGS_STATUS); }) + .catch(() => showErrorModal()) .finally(() => { isSelectingRef.current = false; }); diff --git a/src/selectors/Report.ts b/src/selectors/Report.ts index 27f14d45a0e3..36cc6fe055c5 100644 --- a/src/selectors/Report.ts +++ b/src/selectors/Report.ts @@ -139,6 +139,17 @@ function canShowReportRecipientLocalTimeSelector(report: OnyxEntry, acco return (personalDetailsList: OnyxEntry) => canShowReportRecipientLocalTime(personalDetailsList, report, accountID); } +function policyExpenseChatReportsSelector(reports: OnyxCollection): OnyxCollection { + const filtered: OnyxCollection = {}; + for (const [key, report] of Object.entries(reports ?? {})) { + if (!report?.reportID || !isPolicyExpenseChat(report) || isThread(report)) { + continue; + } + filtered[key] = report; + } + return filtered; +} + type ValidReportKeys> = T; /** @@ -280,6 +291,7 @@ export { policyIDsWithEmptyReportsSelector, canShowReportRecipientLocalTimeSelector, policyChatRoomsSelector, + policyExpenseChatReportsSelector, createMoveExpenseReportNVPSelector, openExpenseReportIDsSelector, getStableReportSelector, diff --git a/tests/actions/DomainTest.ts b/tests/actions/DomainTest.ts index 02a2616a62a3..4609b8b931d7 100644 --- a/tests/actions/DomainTest.ts +++ b/tests/actions/DomainTest.ts @@ -24,7 +24,7 @@ import { setTwoFactorAuthExemptEmailForDomain, updateDomainSecurityGroup, } from '@libs/actions/Domain'; -import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {WRITE_COMMANDS} from '@libs/API/types'; import {generateAccountID} from '@libs/UserUtils'; import CONST from '@src/CONST'; @@ -417,7 +417,7 @@ describe('actions/Domain', () => { describe('setDomainVacationDelegate', () => { it('sends SET_VACATION_DELEGATE request with ADD pending action when no existing delegate', () => { - const apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve()); + const apiWriteSpy = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); const domainAccountID = 123; const domainMemberAccountID = 456; const creator = 'admin@test.com'; @@ -428,8 +428,8 @@ describe('actions/Domain', () => { setDomainVacationDelegate(domainAccountID, domainMemberAccountID, creator, vacationer, delegate); - expect(apiSideEffectSpy).toHaveBeenCalledWith( - SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, + expect(apiWriteSpy).toHaveBeenCalledWith( + WRITE_COMMANDS.SET_VACATION_DELEGATE, {creator, vacationerEmail: vacationer, vacationDelegateEmail: delegate, overridePolicyDiffWarning: true, domainAccountID}, { optimisticData: expect.arrayContaining([ @@ -474,11 +474,11 @@ describe('actions/Domain', () => { }, ); - apiSideEffectSpy.mockRestore(); + apiWriteSpy.mockRestore(); }); it('uses UPDATE pending action when existing delegate is present', () => { - const apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve()); + const apiWriteSpy = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); const domainAccountID = 123; const domainMemberAccountID = 456; const creator = 'admin@test.com'; @@ -488,8 +488,8 @@ describe('actions/Domain', () => { setDomainVacationDelegate(domainAccountID, domainMemberAccountID, creator, vacationer, delegate, existingVacationDelegate); - expect(apiSideEffectSpy).toHaveBeenCalledWith( - SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, + expect(apiWriteSpy).toHaveBeenCalledWith( + WRITE_COMMANDS.SET_VACATION_DELEGATE, expect.any(Object), expect.objectContaining({ optimisticData: expect.arrayContaining([ @@ -501,7 +501,7 @@ describe('actions/Domain', () => { }), ); - apiSideEffectSpy.mockRestore(); + apiWriteSpy.mockRestore(); }); }); diff --git a/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx index a4ac3f563ee7..d1f7ba7352f7 100644 --- a/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx +++ b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx @@ -7,6 +7,7 @@ import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; @@ -56,14 +57,14 @@ function renderPage() { ); } -async function seedVacationDelegate(policyDiff?: VacationDelegatePolicyDiff) { - const timestamp = 123; +async function seedVacationDelegate(policyDiff?: VacationDelegatePolicyDiff, delegate: string = DELEGATE_EMAIL) { + // The real 305 policy-diff-warning response never writes NVP errors (see VacationDelegate.ts), so this + // page is only ever reached with errors already null. await act(async () => { await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { creator: CREATOR_EMAIL, - delegate: DELEGATE_EMAIL, + delegate, previousDelegate: PREVIOUS_DELEGATE_EMAIL, - errors: {[timestamp]: "Vacation delegate is not part of all of vacationer's policies."}, policyDiff, }); }); @@ -140,6 +141,16 @@ describe('VacationDelegateMissingWorkspacesPage', () => { expect(apiWriteSpy).not.toHaveBeenCalledWith(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, expect.anything(), expect.anything()); }); + it('formats an SMS delegate login as a phone number in the intro copy instead of the raw @expensify.sms address', async () => { + const smsDelegate = `+15005550006${CONST.SMS.DOMAIN}`; + await seedVacationDelegate({adminPolicies: [], nonAdminPolicies: [MEMBER_POLICY_ID]}, smsDelegate); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.queryByText(smsDelegate)).not.toBeOnTheScreen(); + expect(screen.getByText(formatPhoneNumber(smsDelegate))).toBeOnTheScreen(); + }); + it('shows only the admin-of section and Invite/Skip buttons when the delegate is admin of all, and Skip sends no invites', async () => { await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID], nonAdminPolicies: []}); renderPage(); @@ -194,6 +205,21 @@ describe('VacationDelegateMissingWorkspacesPage', () => { ); }); + it('disables Invite and never sends AddMembersToWorkspace when an admin policy has not loaded', async () => { + const UNAVAILABLE_POLICY_ID = 'unavailablePolicy'; + await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID, UNAVAILABLE_POLICY_ID], nonAdminPolicies: []}); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText(TestHelper.translateLocal('workspace.common.unavailable'))).toBeOnTheScreen(); + + fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.invite')})); + await waitForBatchedUpdatesWithAct(); + + expect(apiWriteSpy).not.toHaveBeenCalledWith(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, expect.anything(), expect.anything()); + expect(apiWriteSpy).not.toHaveBeenCalledWith(WRITE_COMMANDS.SET_VACATION_DELEGATE, expect.anything(), expect.anything()); + }); + it('shows both sections, member-of before admin-of, for a mixed diff', async () => { await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID], nonAdminPolicies: [MEMBER_POLICY_ID]}); renderPage(); @@ -205,7 +231,7 @@ describe('VacationDelegateMissingWorkspacesPage', () => { expect(screen.getByRole('button', {name: TestHelper.translateLocal('common.skip')})).toBeOnTheScreen(); }); - it('clears the error and policyDiff, and restores the previous delegate, on unmount without pressing a button', async () => { + it('clears the policyDiff and restores the previous delegate, on unmount without pressing a button', async () => { await seedVacationDelegate({adminPolicies: [], nonAdminPolicies: [MEMBER_POLICY_ID]}); const {unmount} = renderPage(); await waitForBatchedUpdatesWithAct(); @@ -215,7 +241,6 @@ describe('VacationDelegateMissingWorkspacesPage', () => { const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); expect(vacationDelegate?.delegate).toBe(PREVIOUS_DELEGATE_EMAIL); - expect(vacationDelegate?.errors).toBeFalsy(); expect(vacationDelegate?.policyDiff).toBeFalsy(); }); diff --git a/tests/ui/VacationDelegatePageTest.tsx b/tests/ui/VacationDelegatePageTest.tsx index dc5d312aa7a4..e0e0689b9037 100644 --- a/tests/ui/VacationDelegatePageTest.tsx +++ b/tests/ui/VacationDelegatePageTest.tsx @@ -22,6 +22,7 @@ import {NavigationContainer} from '@react-navigation/native'; import React from 'react'; import Onyx from 'react-native-onyx'; +import getOnyxValue from '../utils/getOnyxValue'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -131,4 +132,28 @@ describe('VacationDelegatePage', () => { expect.anything(), ); }); + + it('rolls back the optimistic delegate instead of leaving a stuck pending row when the request rejects', async () => { + // Simulates a transport failure (e.g. connection dropped after the tap), which rejects rather than resolving with a jsonCode. + apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.reject(new Error('Failed to fetch'))); + // jest.mock's factory functions (unlike jest.spyOn) are not reset by jest.restoreAllMocks() in afterEach, so call counts otherwise leak across tests in this file. + jest.mocked(Navigation.goBack).mockClear(); + + renderPage(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByTestId('select-delegate-a')); + await waitForBatchedUpdatesWithAct(); + + expect(Navigation.goBack).not.toHaveBeenCalled(); + const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + expect(vacationDelegate?.pendingAction).toBeFalsy(); + expect(vacationDelegate?.delegate).toBeFalsy(); + expect(vacationDelegate?.errors).toBeFalsy(); + + // A second tap must not be ignored as "still pending" once the failed request has settled. + fireEvent.press(screen.getByTestId('select-delegate-a')); + await waitForBatchedUpdatesWithAct(); + expect(apiSideEffectSpy).toHaveBeenCalledTimes(2); + }); }); diff --git a/tests/unit/ReportSelectorTest.ts b/tests/unit/ReportSelectorTest.ts index 86c8d51a3e67..6587f83902c8 100644 --- a/tests/unit/ReportSelectorTest.ts +++ b/tests/unit/ReportSelectorTest.ts @@ -1,6 +1,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {createMoveExpenseReportNVPSelector, getStableReportSelector, policyChatRoomsSelector} from '@src/selectors/Report'; +import {createMoveExpenseReportNVPSelector, getStableReportSelector, policyChatRoomsSelector, policyExpenseChatReportsSelector} from '@src/selectors/Report'; import type {Report} from '@src/types/onyx'; describe('policyChatRoomsSelector', () => { @@ -128,6 +128,51 @@ describe('createMoveExpenseReportNVPSelector', () => { }); }); +describe('policyExpenseChatReportsSelector', () => { + const REPORT_KEY_PREFIX = ONYXKEYS.COLLECTION.REPORT; + const policyID = 'policy1'; + + const policyExpenseChat = {reportID: '1', policyID, chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT} as Report; + const policyExpenseChatThread = { + reportID: '2', + policyID, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + parentReportID: '1', + parentReportActionID: '1', + } as Report; + const policyRoom = {reportID: '3', policyID, chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM} as Report; + + it('returns an empty object when reports is undefined', () => { + expect(policyExpenseChatReportsSelector(undefined)).toEqual({}); + }); + + it('includes policy expense chats', () => { + const reports = {[`${REPORT_KEY_PREFIX}1`]: policyExpenseChat}; + expect(policyExpenseChatReportsSelector(reports)).toEqual(reports); + }); + + it('excludes threads of policy expense chats', () => { + const reports = { + [`${REPORT_KEY_PREFIX}1`]: policyExpenseChat, + [`${REPORT_KEY_PREFIX}2`]: policyExpenseChatThread, + }; + expect(policyExpenseChatReportsSelector(reports)).toEqual({[`${REPORT_KEY_PREFIX}1`]: policyExpenseChat}); + }); + + it('excludes reports that are not policy expense chats', () => { + const reports = {[`${REPORT_KEY_PREFIX}3`]: policyRoom}; + expect(policyExpenseChatReportsSelector(reports)).toEqual({}); + }); + + it('skips missing entries in the collection', () => { + const reports = { + [`${REPORT_KEY_PREFIX}1`]: policyExpenseChat, + [`${REPORT_KEY_PREFIX}_missing`]: undefined, + }; + expect(policyExpenseChatReportsSelector(reports)).toEqual({[`${REPORT_KEY_PREFIX}1`]: policyExpenseChat}); + }); +}); + describe('getStableReportSelector', () => { const {READ, WRITE, SHARE} = CONST.REPORT.PERMISSIONS; From 58c4eba403f94bb236ef208acecd0e31abb1a20a Mon Sep 17 00:00:00 2001 From: jakubstec Date: Tue, 1 Sep 2026 12:50:34 +0200 Subject: [PATCH 04/23] fix: race condition causing not found page appearing on the rhp when selecting a delegate --- .../VacationDelegateMissingWorkspacesPage.tsx | 37 +++---- .../CustomStatus/VacationDelegatePage.tsx | 76 +++++++------- ...ationDelegateMissingWorkspacesPageTest.tsx | 98 ++++++++++++++++--- 3 files changed, 134 insertions(+), 77 deletions(-) diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx index afc15434f9a3..a66d3b33405b 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx @@ -27,8 +27,11 @@ import ROUTES from '@src/ROUTES'; import {policyExpenseChatReportsSelector} from '@src/selectors/Report'; import type {VacationDelegatePolicyDiff} from '@src/types/onyx'; +import type {NavigationAction} from '@react-navigation/native'; + +import {useNavigation, usePreventRemove} from '@react-navigation/native'; import {Str} from 'expensify-common'; -import React, {useEffect, useRef, useState} from 'react'; +import React, {useRef, useState} from 'react'; import {View} from 'react-native'; type ScreenInput = { @@ -39,6 +42,7 @@ type ScreenInput = { function VacationDelegateMissingWorkspacesPage() { const styles = useThemeStyles(); const {translate, formatPhoneNumber} = useLocalize(); + const navigation = useNavigation(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUser = { accountID: currentUserPersonalDetails.accountID, @@ -55,11 +59,6 @@ function VacationDelegateMissingWorkspacesPage() { selector: (reportActions) => getAllPolicyExpenseChatReportActions(allReports, reportActions), }); - const vacationDelegateRef = useRef(vacationDelegate); - useEffect(() => { - vacationDelegateRef.current = vacationDelegate; - }, [vacationDelegate]); - const [submittedInput, setSubmittedInput] = useState(); const creator = currentUserPersonalDetails.login ?? ''; @@ -67,26 +66,16 @@ function VacationDelegateMissingWorkspacesPage() { const previousDelegate = vacationDelegate?.previousDelegate; const policyDiff = submittedInput?.policyDiff ?? vacationDelegate?.policyDiff; - const hasActiveFlowRef = useRef(false); - useEffect(() => { - if (!policyDiff) { - return; + const isSubmittingRef = useRef(false); + + usePreventRemove(!!policyDiff, ({data}: {data: {action: NavigationAction}}) => { + if (!isSubmittingRef.current && policyDiff) { + setSubmittedInput({delegate, policyDiff}); + clearVacationDelegateError(previousDelegate); } - hasActiveFlowRef.current = true; - }, [policyDiff]); - // Leaving without submitting rolls the delegate back. Doing it here rather than in the back handler keeps every exit - // (back button, swipe, dismissing the RHP) on one path, and keeps the flow state intact while this screen is still mounted. - const isSubmittingRef = useRef(false); - useEffect( - () => () => { - if (isSubmittingRef.current || !hasActiveFlowRef.current) { - return; - } - clearVacationDelegateError(vacationDelegateRef.current?.previousDelegate); - }, - [], - ); + navigation.dispatch(data.action); + }); const adminPolicies = policyDiff?.adminPolicies ?? []; const nonAdminPolicies = policyDiff?.nonAdminPolicies ?? []; const canInvite = adminPolicies.length > 0; diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx index a7b9a9dc39c3..3d7ce70bd45f 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx @@ -14,7 +14,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Participant} from '@src/types/onyx/IOU'; -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useRef} from 'react'; function VacationDelegatePage() { const {translate} = useLocalize(); @@ -22,14 +22,10 @@ function VacationDelegatePage() { const {showConfirmModal} = useConfirmModal(); const [vacationDelegate] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); - const vacationDelegateRef = useRef(vacationDelegate); - useEffect(() => { - vacationDelegateRef.current = vacationDelegate; - }, [vacationDelegate]); const isSelectingRef = useRef(false); - const showErrorModal = async (message?: string) => { + const showErrorModal = async (delegateToRestore?: string, message?: string) => { await showConfirmModal({ title: translate('statusPage.addVacationDelegate'), prompt: message ?? translate('statusPage.vacationDelegateError'), @@ -37,46 +33,44 @@ function VacationDelegatePage() { shouldShowCancelButton: false, }); - clearVacationDelegateError(vacationDelegateRef.current?.previousDelegate); + clearVacationDelegateError(delegateToRestore); }; - const onSelectRow = useCallback( - (option: Participant) => { - if (isSelectingRef.current) { - return; - } + const onSelectRow = (option: Participant) => { + if (isSelectingRef.current) { + return; + } + + if (option?.login === vacationDelegate?.delegate) { + isSelectingRef.current = true; + deleteVacationDelegate(vacationDelegate); + Navigation.goBack(ROUTES.SETTINGS_STATUS); + isSelectingRef.current = false; + return; + } + + isSelectingRef.current = true; + const currentDelegate = vacationDelegate?.delegate; + setVacationDelegate({creator: currentUserLogin, delegate: option?.login ?? '', currentDelegate}) + .then((response) => { + if (response?.data?.policyDiff) { + Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES); + return; + } + + // The request writes no error of its own, so this modal is the only feedback for a failure. Dismissing it restores the previous delegate. + if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { + showErrorModal(currentDelegate, response?.jsonCode === CONST.JSON_CODE.EXP_ERROR ? response.message : undefined); + return; + } - if (option?.login === vacationDelegate?.delegate) { - isSelectingRef.current = true; - deleteVacationDelegate(vacationDelegate); Navigation.goBack(ROUTES.SETTINGS_STATUS); + }) + .catch(() => showErrorModal(currentDelegate)) + .finally(() => { isSelectingRef.current = false; - return; - } - - isSelectingRef.current = true; - setVacationDelegate({creator: currentUserLogin, delegate: option?.login ?? '', currentDelegate: vacationDelegate?.delegate}) - .then((response) => { - if (response?.jsonCode === CONST.JSON_CODE.POLICY_DIFF_WARNING) { - Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES); - return; - } - - // The request writes no error of its own, so this modal is the only feedback for a failure. Dismissing it restores the previous delegate. - if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { - showErrorModal(response?.jsonCode === CONST.JSON_CODE.EXP_ERROR ? response.message : undefined); - return; - } - - Navigation.goBack(ROUTES.SETTINGS_STATUS); - }) - .catch(() => showErrorModal()) - .finally(() => { - isSelectingRef.current = false; - }); - }, - [currentUserLogin, vacationDelegate, showErrorModal], - ); + }); + }; return ( (); +let mockPreventRemoveCallback: Parameters[1] | undefined; + +jest.mock('@react-navigation/native', () => { + const actualNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualNavigation, + usePreventRemove: (preventRemove: boolean, callback: Parameters[1]) => { + if (!preventRemove) { + return; + } + mockPreventRemoveCallback = callback; + }, + }; +}); + +function getFakePolicy(id: string, name: string): Policy { + return { + id, + name, + isFromFullPolicy: false, + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.TEAM, + owner: CREATOR_EMAIL, + outputCurrency: CONST.CURRENCY.USD, + avatarURL: '', + employeeList: {}, + isPolicyExpenseChatEnabled: true, + lastModified: '1697323926777105', + autoReporting: true, + autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE, + harvesting: {enabled: true}, + autoReportingOffset: 1, + preventSelfApproval: true, + defaultBillable: false, + disabledFields: {defaultBillable: true, reimbursable: false}, + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + }; +} + +function VacationDelegateSelectionPage() { + return Delegate selection; +} + +function renderPage(shouldIncludeBackRoute = false) { + const initialState = shouldIncludeBackRoute + ? { + index: 1, + routes: [{name: SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE}, {name: SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE_MISSING_WORKSPACES}], + } + : undefined; -function renderPage() { return render( - + + { }); beforeEach(async () => { + mockPreventRemoveCallback = undefined; await act(async () => { await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); }); @@ -93,11 +153,11 @@ describe('VacationDelegateMissingWorkspacesPage', () => { await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${MEMBER_POLICY_ID}`, { - ...LHNTestUtils.getFakePolicy(MEMBER_POLICY_ID, 'Member Workspace'), + ...getFakePolicy(MEMBER_POLICY_ID, 'Member Workspace'), employeeList: {[CREATOR_EMAIL]: {email: CREATOR_EMAIL, role: CONST.POLICY.ROLE.USER}}, }); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${ADMIN_POLICY_ID}`, { - ...LHNTestUtils.getFakePolicy(ADMIN_POLICY_ID, 'Admin Workspace'), + ...getFakePolicy(ADMIN_POLICY_ID, 'Admin Workspace'), employeeList: {[CREATOR_EMAIL]: {email: CREATOR_EMAIL, role: CONST.POLICY.ROLE.ADMIN}}, }); }); @@ -231,17 +291,31 @@ describe('VacationDelegateMissingWorkspacesPage', () => { expect(screen.getByRole('button', {name: TestHelper.translateLocal('common.skip')})).toBeOnTheScreen(); }); - it('clears the policyDiff and restores the previous delegate, on unmount without pressing a button', async () => { + it('finishes rolling back an abandoned flow before exposing the delegate selection page', async () => { await seedVacationDelegate({adminPolicies: [], nonAdminPolicies: [MEMBER_POLICY_ID]}); - const {unmount} = renderPage(); + renderPage(true); await waitForBatchedUpdatesWithAct(); - unmount(); - await waitForBatchedUpdatesWithAct(); + expect(mockPreventRemoveCallback).toBeDefined(); + await act(async () => { + mockPreventRemoveCallback?.({data: {action: StackActions.pop()}}); + await waitForBatchedUpdatesWithAct(); + }); + expect(screen.getByText('Delegate selection')).toBeOnTheScreen(); + expect(screen.queryByText('Member Workspace')).not.toBeOnTheScreen(); const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); expect(vacationDelegate?.delegate).toBe(PREVIOUS_DELEGATE_EMAIL); expect(vacationDelegate?.policyDiff).toBeFalsy(); + + // Starting a new selection after the picker is visible must not be overwritten by cleanup from the route that just closed. + const nextPolicyDiff = {adminPolicies: [ADMIN_POLICY_ID], nonAdminPolicies: []}; + await seedVacationDelegate(nextPolicyDiff, 'next@example.com'); + await waitForBatchedUpdatesWithAct(); + + const nextVacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + expect(nextVacationDelegate?.delegate).toBe('next@example.com'); + expect(nextVacationDelegate?.policyDiff).toEqual(nextPolicyDiff); }); it('keeps rendering what was submitted when the flow state is cleared underneath it', async () => { @@ -266,7 +340,7 @@ describe('VacationDelegateMissingWorkspacesPage', () => { expect(screen.queryByText(PREVIOUS_DELEGATE_EMAIL)).not.toBeOnTheScreen(); }); - it('navigates back to the delegate selection step without touching the flow state before unmounting', async () => { + it('keeps rendering the current flow while a back navigation action has not been handled', async () => { const goBackSpy = jest.spyOn(Navigation, 'goBack').mockImplementation(() => {}); await seedVacationDelegate({adminPolicies: [], nonAdminPolicies: [MEMBER_POLICY_ID]}); renderPage(); @@ -277,7 +351,7 @@ describe('VacationDelegateMissingWorkspacesPage', () => { expect(goBackSpy).toHaveBeenCalledWith(ROUTES.SETTINGS_VACATION_DELEGATE); - // The rollback happens on unmount instead, so nothing can flash while Navigation is still waiting on the transition. + // The rollback runs from the route-removal handler, not from the back press, so nothing can flash while Navigation is still waiting on the transition. const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); expect(vacationDelegate?.delegate).toBe(DELEGATE_EMAIL); expect(vacationDelegate?.policyDiff).not.toBeFalsy(); From b7c0064c3572a6b53f629bebd9c9d56038b2b600 Mon Sep 17 00:00:00 2001 From: jakubstec Date: Tue, 1 Sep 2026 14:25:07 +0200 Subject: [PATCH 05/23] fix: follow-up on useOnyx subscription optimalization, use already existing selector instead of newly created policyExpenseChatReportsSelector, build the policy expense chat report actions at invite time instead of in a per render useOnyx selector, remove no-op guard from delete branch, skip setVacationDelegate response navigation when the screen has lost focus, show loading indicator while vacation delegate is still hydrating --- .../VacationDelegateMissingWorkspacesPage.tsx | 49 ++++++++++++------- .../CustomStatus/VacationDelegatePage.tsx | 12 +++-- src/selectors/Policy.ts | 24 +++++++++ src/selectors/Report.ts | 12 ----- ...ationDelegateMissingWorkspacesPageTest.tsx | 48 +++++++++++++++++- tests/ui/VacationDelegatePageTest.tsx | 33 +++++++++++++ tests/unit/PolicySelectorTest.ts | 39 +++++++++++++++ tests/unit/ReportSelectorTest.ts | 47 +----------------- 8 files changed, 183 insertions(+), 81 deletions(-) diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx index a66d3b33405b..0f1a93fd0219 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx @@ -1,6 +1,7 @@ import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; import Button from '@components/ButtonComposed'; import FixedFooter from '@components/FixedFooter'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -24,8 +25,9 @@ import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import {policyExpenseChatReportsSelector} from '@src/selectors/Report'; +import {createPoliciesByIDsSelector} from '@src/selectors/Policy'; import type {VacationDelegatePolicyDiff} from '@src/types/onyx'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type {NavigationAction} from '@react-navigation/native'; @@ -51,13 +53,7 @@ function VacationDelegateMissingWorkspacesPage() { avatar: currentUserPersonalDetails.avatar, }; - const [vacationDelegate] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); - const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const [allPersonalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); - const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: policyExpenseChatReportsSelector}); - const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, { - selector: (reportActions) => getAllPolicyExpenseChatReportActions(allReports, reportActions), - }); + const [vacationDelegate, vacationDelegateMetadata] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); const [submittedInput, setSubmittedInput] = useState(); @@ -65,6 +61,13 @@ function VacationDelegateMissingWorkspacesPage() { const delegate = submittedInput?.delegate ?? vacationDelegate?.delegate ?? ''; const previousDelegate = vacationDelegate?.previousDelegate; const policyDiff = submittedInput?.policyDiff ?? vacationDelegate?.policyDiff; + const adminPolicies = policyDiff?.adminPolicies ?? []; + const nonAdminPolicies = policyDiff?.nonAdminPolicies ?? []; + + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createPoliciesByIDsSelector([...adminPolicies, ...nonAdminPolicies])}); + const [allPersonalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); const isSubmittingRef = useRef(false); @@ -76,11 +79,13 @@ function VacationDelegateMissingWorkspacesPage() { navigation.dispatch(data.action); }); - const adminPolicies = policyDiff?.adminPolicies ?? []; - const nonAdminPolicies = policyDiff?.nonAdminPolicies ?? []; const canInvite = adminPolicies.length > 0; const hasUnresolvedAdminPolicy = adminPolicies.some((policyID) => !policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.id); + if (!submittedInput && isLoadingOnyxValue(vacationDelegateMetadata)) { + return ; + } + if (!policyDiff) { return ; } @@ -102,14 +107,18 @@ function VacationDelegateMissingWorkspacesPage() { }; const submit = (shouldSkipPolicyInviteEmails: boolean) => { + setSubmittedInput({delegate, policyDiff}); + setVacationDelegate({creator, delegate, currentDelegate: previousDelegate, shouldOverridePolicyDiffWarning: true, shouldSkipPolicyInviteEmails}); + goBackToStatus(); + }; + + const submitOnce = (shouldSkipPolicyInviteEmails: boolean) => { if (isSubmittingRef.current) { return; } isSubmittingRef.current = true; - setSubmittedInput({delegate, policyDiff}); - setVacationDelegate({creator, delegate, currentDelegate: previousDelegate, shouldOverridePolicyDiffWarning: true, shouldSkipPolicyInviteEmails}); - goBackToStatus(); + submit(shouldSkipPolicyInviteEmails); }; const invite = () => { @@ -117,23 +126,27 @@ function VacationDelegateMissingWorkspacesPage() { return; } + isSubmittingRef.current = true; + // The delegate may have been picked from the selector without existing in personal details yet, so fall back to an optimistic accountID. const [delegateAccountID] = getAccountIDsByLogins([delegate]); const invitedEmailsToAccountIDs = {[delegate]: delegateAccountID}; const {newAccountIDs, newLogins} = getNewAccountIDsAndLogins(invitedEmailsToAccountIDs, allPersonalDetails); const personalDetailsOnyxData = getPersonalDetailsOnyxDataForOptimisticUsers(newLogins, newAccountIDs, formatPhoneNumber); + const policyExpenseChatReportActions = getAllPolicyExpenseChatReportActions(allReports, allReportActions); - for (const policyID of adminPolicies) { + for (const [index, policyID] of adminPolicies.entries()) { const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const isLastInvite = index === adminPolicies.length - 1; addMembersToWorkspace( invitedEmailsToAccountIDs, - personalDetailsOnyxData, + isLastInvite ? personalDetailsOnyxData : {optimisticData: personalDetailsOnyxData.optimisticData}, `# ${currentUserPersonalDetails.displayName ?? ''} invited you to ${policy?.name ?? ''}\n\n${translate('workspace.common.welcomeNote')}`, policy, Object.values(getMemberAccountIDsForWorkspace(policy?.employeeList, false, false)), CONST.POLICY.ROLE.USER, currentUser, - allReportActions, + policyExpenseChatReportActions, ); } @@ -207,7 +220,7 @@ function VacationDelegateMissingWorkspacesPage() { @@ -216,7 +229,7 @@ function VacationDelegateMissingWorkspacesPage() { diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx index 3d7ce70bd45f..7635db12915f 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx @@ -13,13 +13,16 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Participant} from '@src/types/onyx/IOU'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import {useNavigation} from '@react-navigation/native'; import React, {useRef} from 'react'; function VacationDelegatePage() { const {translate} = useLocalize(); const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); const {showConfirmModal} = useConfirmModal(); + const navigation = useNavigation(); const [vacationDelegate] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); @@ -42,17 +45,20 @@ function VacationDelegatePage() { } if (option?.login === vacationDelegate?.delegate) { - isSelectingRef.current = true; deleteVacationDelegate(vacationDelegate); Navigation.goBack(ROUTES.SETTINGS_STATUS); - isSelectingRef.current = false; return; } isSelectingRef.current = true; - const currentDelegate = vacationDelegate?.delegate; + const hasUnconfirmedChange = !!vacationDelegate?.pendingAction || !isEmptyObject(vacationDelegate?.errors) || !!vacationDelegate?.policyDiff; + const currentDelegate = hasUnconfirmedChange ? vacationDelegate?.previousDelegate : vacationDelegate?.delegate; setVacationDelegate({creator: currentUserLogin, delegate: option?.login ?? '', currentDelegate}) .then((response) => { + if (!navigation.isFocused()) { + return; + } + if (response?.data?.policyDiff) { Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES); return; diff --git a/src/selectors/Policy.ts b/src/selectors/Policy.ts index d4cee0003629..a9a07d09a029 100644 --- a/src/selectors/Policy.ts +++ b/src/selectors/Policy.ts @@ -269,6 +269,29 @@ const createPoliciesForDomainCardsSelector = (domainNames: string[]) => { }; }; +/** + * Creates a selector returning only the policies for the given IDs, so a consumer interested in a + * known handful of workspaces doesn't re-render when unrelated policies change. + */ +const createPoliciesByIDsSelector = (policyIDs: string[]) => { + const policyKeys = new Set(policyIDs.map((policyID) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`)); + + return (policies: OnyxCollection): NonNullable> => { + if (policyKeys.size === 0) { + return {}; + } + + const filtered: NonNullable> = {}; + for (const key of policyKeys) { + const policy = policies?.[key]; + if (policy) { + filtered[key] = policy; + } + } + return filtered; + }; +}; + const policyTimeTrackingSelector = (policy: OnyxEntry) => policy && { outputCurrency: policy.outputCurrency, @@ -515,6 +538,7 @@ export { createTimeSensitiveAdminPoliciesSelector, createHasWorkspaceToSubmitToSelector, createPoliciesForDomainCardsSelector, + createPoliciesByIDsSelector, policyTimeTrackingSelector, createIOURequestStartPoliciesSelector, policyMapper, diff --git a/src/selectors/Report.ts b/src/selectors/Report.ts index efcee2ff7e45..f39b57423190 100644 --- a/src/selectors/Report.ts +++ b/src/selectors/Report.ts @@ -194,17 +194,6 @@ function canShowReportRecipientLocalTimeSelector(report: OnyxEntry, acco return (personalDetailsList: OnyxEntry) => canShowReportRecipientLocalTime(personalDetailsList, report, accountID); } -function policyExpenseChatReportsSelector(reports: OnyxCollection): OnyxCollection { - const filtered: OnyxCollection = {}; - for (const [key, report] of Object.entries(reports ?? {})) { - if (!report?.reportID || !isPolicyExpenseChat(report) || isThread(report)) { - continue; - } - filtered[key] = report; - } - return filtered; -} - type ValidReportKeys> = T; /** @@ -348,7 +337,6 @@ export { canShowReportRecipientLocalTimeSelector, policyChatRoomsSelector, reportAvatarKindSelector, - policyExpenseChatReportsSelector, createMoveExpenseReportNVPSelector, openExpenseReportIDsSelector, getStableReportSelector, diff --git a/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx index dbc2dd235094..6d90cb5ce9b9 100644 --- a/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx +++ b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx @@ -40,6 +40,7 @@ const PREVIOUS_DELEGATE_EMAIL = 'previous@example.com'; const MEMBER_POLICY_ID = 'memberPolicy'; const ADMIN_POLICY_ID = 'adminPolicy'; +const SECOND_ADMIN_POLICY_ID = 'secondAdminPolicy'; const Stack = createPlatformStackNavigator(); let mockPreventRemoveCallback: Parameters[1] | undefined; @@ -80,6 +81,22 @@ function getFakePolicy(id: string, name: string): Policy { }; } +type InviteOnyxData = { + optimisticData?: Array<{key: string}>; + successData?: Array<{key: string}>; +}; + +// Only the shape this test asserts on; API.write is spied through an untyped require, so the calls need a type to read. +type ApiWriteCall = [command: string, params: unknown, onyxData: InviteOnyxData]; + +function getInviteOnyxData(calls: ApiWriteCall[]): InviteOnyxData[] { + return calls.filter(([command]) => command === WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE).map(([, , onyxData]) => onyxData); +} + +function hasPersonalDetailsUpdate(updates: Array<{key: string}> | undefined) { + return !!updates?.some((update) => update.key === ONYXKEYS.PERSONAL_DETAILS_LIST); +} + function VacationDelegateSelectionPage() { return Delegate selection; } @@ -130,7 +147,7 @@ async function seedVacationDelegate(policyDiff?: VacationDelegatePolicyDiff, del describe('VacationDelegateMissingWorkspacesPage', () => { let apiSideEffectSpy: jest.SpyInstance; - let apiWriteSpy: jest.SpyInstance; + let apiWriteSpy: jest.SpyInstance, ApiWriteCall>; beforeAll(() => { Onyx.init({keys: ONYXKEYS}); @@ -148,7 +165,9 @@ describe('VacationDelegateMissingWorkspacesPage', () => { // from its mocked XHR responses. Mocking them earlier silently no-ops that Onyx application, // leaving useCurrentUserPersonalDetails() stuck on its default (unauthenticated) value. apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve()); - apiWriteSpy = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); + // require('@libs/API') is untyped (any), so the spy has to be re-typed here for mock.calls to be readable. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + apiWriteSpy = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()) as jest.SpyInstance, ApiWriteCall>; await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${MEMBER_POLICY_ID}`, { @@ -159,6 +178,10 @@ describe('VacationDelegateMissingWorkspacesPage', () => { ...getFakePolicy(ADMIN_POLICY_ID, 'Admin Workspace'), employeeList: {[CREATOR_EMAIL]: {email: CREATOR_EMAIL, role: CONST.POLICY.ROLE.ADMIN}}, }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${SECOND_ADMIN_POLICY_ID}`, { + ...getFakePolicy(SECOND_ADMIN_POLICY_ID, 'Second Admin Workspace'), + employeeList: {[CREATOR_EMAIL]: {email: CREATOR_EMAIL, role: CONST.POLICY.ROLE.ADMIN}}, + }); }); await waitForBatchedUpdatesWithAct(); }); @@ -200,6 +223,7 @@ describe('VacationDelegateMissingWorkspacesPage', () => { expect(apiWriteSpy).not.toHaveBeenCalledWith(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, expect.anything(), expect.anything()); }); + // Bug #89578 - this page replaced the warning modal that first carried the fix, so it owns the guarantee now. it('formats an SMS delegate login as a phone number in the intro copy instead of the raw @expensify.sms address', async () => { const smsDelegate = `+15005550006${CONST.SMS.DOMAIN}`; await seedVacationDelegate({adminPolicies: [], nonAdminPolicies: [MEMBER_POLICY_ID]}, smsDelegate); @@ -264,6 +288,26 @@ describe('VacationDelegateMissingWorkspacesPage', () => { ); }); + it('lets only the last queued invite clean up the delegate optimistic personal details', async () => { + await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID, SECOND_ADMIN_POLICY_ID], nonAdminPolicies: []}); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.invite')})); + await waitForBatchedUpdatesWithAct(); + + const inviteCalls = getInviteOnyxData(apiWriteSpy.mock.calls); + expect(inviteCalls).toHaveLength(2); + + // Every invite still seeds the optimistic delegate, so each policy expense chat renders them... + expect(inviteCalls.every((onyxData) => hasPersonalDetailsUpdate(onyxData.optimisticData))).toBe(true); + + // ...but only the last one tears them down. Writes resolve in queue order, so a cleanup on the first invite + // would wipe the delegate out from under the invites still in flight. + expect(hasPersonalDetailsUpdate(inviteCalls.at(0)?.successData)).toBe(false); + expect(hasPersonalDetailsUpdate(inviteCalls.at(-1)?.successData)).toBe(true); + }); + it('disables Invite and never sends AddMembersToWorkspace when an admin policy has not loaded', async () => { const UNAVAILABLE_POLICY_ID = 'unavailablePolicy'; await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID, UNAVAILABLE_POLICY_ID], nonAdminPolicies: []}); diff --git a/tests/ui/VacationDelegatePageTest.tsx b/tests/ui/VacationDelegatePageTest.tsx index e0e0689b9037..1c62b70c1a8d 100644 --- a/tests/ui/VacationDelegatePageTest.tsx +++ b/tests/ui/VacationDelegatePageTest.tsx @@ -6,6 +6,7 @@ import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; +import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import VacationDelegatePage from '@pages/settings/Profile/CustomStatus/VacationDelegatePage'; @@ -29,6 +30,8 @@ import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct' const CREATOR_ACCOUNT_ID = 1; const CREATOR_EMAIL = 'creator@example.com'; const DELEGATE_A_EMAIL = 'delegateA@example.com'; +const DELEGATE_B_EMAIL = 'delegateB@example.com'; +const ORIGINAL_DELEGATE_EMAIL = 'original@example.com'; jest.mock('@libs/Navigation/Navigation', () => ({ navigate: jest.fn(), @@ -156,4 +159,34 @@ describe('VacationDelegatePage', () => { await waitForBatchedUpdatesWithAct(); expect(apiSideEffectSpy).toHaveBeenCalledTimes(2); }); + + it('rolls back to the last confirmed delegate, not to an unconfirmed one, when a previous change is still unresolved', async () => { + // Never resolves, so the optimistic write for the second selection stays in place for the assertion. + apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => new Promise(() => {})); + + // What a failed change the user has not dismissed yet leaves behind: delegateA is shown but was never saved. + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { + creator: CREATOR_EMAIL, + delegate: DELEGATE_A_EMAIL, + previousDelegate: ORIGINAL_DELEGATE_EMAIL, + errors: getMicroSecondOnyxErrorWithTranslationKey('statusPage.vacationDelegateError'), + }); + }); + + renderPage(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByTestId('select-delegate-b')); + await waitForBatchedUpdatesWithAct(); + + // The API call is mocked out, so the optimistic data it was handed is where the rollback target is visible. + expect(apiSideEffectSpy).toHaveBeenLastCalledWith( + SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, + expect.objectContaining({vacationDelegateEmail: DELEGATE_B_EMAIL}), + expect.objectContaining({ + optimisticData: [expect.objectContaining({value: expect.objectContaining({delegate: DELEGATE_B_EMAIL, previousDelegate: ORIGINAL_DELEGATE_EMAIL})})], + }), + ); + }); }); diff --git a/tests/unit/PolicySelectorTest.ts b/tests/unit/PolicySelectorTest.ts index 98505ee69277..0f3eee42b93c 100644 --- a/tests/unit/PolicySelectorTest.ts +++ b/tests/unit/PolicySelectorTest.ts @@ -7,6 +7,7 @@ import { createAdminPoliciesSelector, createCopySettingsEligibleTargetsSelector, createIOURequestStartPoliciesSelector, + createPoliciesByIDsSelector, createWorkspaceListPoliciesSelector, isAdminForPolicyByIDSelector, lastWorkspaceNumberSelector, @@ -138,6 +139,44 @@ describe('createAdminPoliciesSelector', () => { }); }); +describe('createPoliciesByIDsSelector', () => { + const P = ONYXKEYS.COLLECTION.POLICY; + + const policy1 = buildPolicy({id: '1', name: 'Workspace 1'}); + const policy2 = buildPolicy({id: '2', name: 'Workspace 2'}); + const policy3 = buildPolicy({id: '3', name: 'Workspace 3'}); + const allPolicies = { + [`${P}1`]: policy1, + [`${P}2`]: policy2, + [`${P}3`]: policy3, + }; + + it('returns an empty object for an empty ID list without touching the collection', () => { + expect(createPoliciesByIDsSelector([])(allPolicies)).toEqual({}); + }); + + it('returns an empty object when the collection is undefined', () => { + expect(createPoliciesByIDsSelector(['1', '2'])(undefined)).toEqual({}); + }); + + it('returns only the requested keys and drops policies that were not requested', () => { + const result = createPoliciesByIDsSelector(['1', '3'])(allPolicies); + expect(Object.keys(result).sort()).toEqual([`${P}1`, `${P}3`]); + }); + + it('omits a requested ID that has no policy in the collection', () => { + const result = createPoliciesByIDsSelector(['1', 'missing'])(allPolicies); + expect(Object.keys(result)).toEqual([`${P}1`]); + expect(`${P}missing` in result).toBe(false); + }); + + it('returns the same object references as the input, without copying or narrowing', () => { + const result = createPoliciesByIDsSelector(['1', '2'])(allPolicies); + expect(result[`${P}1`]).toBe(policy1); + expect(result[`${P}2`]).toBe(policy2); + }); +}); + describe('isAdminForPolicyByIDSelector', () => { const P = ONYXKEYS.COLLECTION.POLICY; diff --git a/tests/unit/ReportSelectorTest.ts b/tests/unit/ReportSelectorTest.ts index 6587f83902c8..86c8d51a3e67 100644 --- a/tests/unit/ReportSelectorTest.ts +++ b/tests/unit/ReportSelectorTest.ts @@ -1,6 +1,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {createMoveExpenseReportNVPSelector, getStableReportSelector, policyChatRoomsSelector, policyExpenseChatReportsSelector} from '@src/selectors/Report'; +import {createMoveExpenseReportNVPSelector, getStableReportSelector, policyChatRoomsSelector} from '@src/selectors/Report'; import type {Report} from '@src/types/onyx'; describe('policyChatRoomsSelector', () => { @@ -128,51 +128,6 @@ describe('createMoveExpenseReportNVPSelector', () => { }); }); -describe('policyExpenseChatReportsSelector', () => { - const REPORT_KEY_PREFIX = ONYXKEYS.COLLECTION.REPORT; - const policyID = 'policy1'; - - const policyExpenseChat = {reportID: '1', policyID, chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT} as Report; - const policyExpenseChatThread = { - reportID: '2', - policyID, - chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, - parentReportID: '1', - parentReportActionID: '1', - } as Report; - const policyRoom = {reportID: '3', policyID, chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM} as Report; - - it('returns an empty object when reports is undefined', () => { - expect(policyExpenseChatReportsSelector(undefined)).toEqual({}); - }); - - it('includes policy expense chats', () => { - const reports = {[`${REPORT_KEY_PREFIX}1`]: policyExpenseChat}; - expect(policyExpenseChatReportsSelector(reports)).toEqual(reports); - }); - - it('excludes threads of policy expense chats', () => { - const reports = { - [`${REPORT_KEY_PREFIX}1`]: policyExpenseChat, - [`${REPORT_KEY_PREFIX}2`]: policyExpenseChatThread, - }; - expect(policyExpenseChatReportsSelector(reports)).toEqual({[`${REPORT_KEY_PREFIX}1`]: policyExpenseChat}); - }); - - it('excludes reports that are not policy expense chats', () => { - const reports = {[`${REPORT_KEY_PREFIX}3`]: policyRoom}; - expect(policyExpenseChatReportsSelector(reports)).toEqual({}); - }); - - it('skips missing entries in the collection', () => { - const reports = { - [`${REPORT_KEY_PREFIX}1`]: policyExpenseChat, - [`${REPORT_KEY_PREFIX}_missing`]: undefined, - }; - expect(policyExpenseChatReportsSelector(reports)).toEqual({[`${REPORT_KEY_PREFIX}1`]: policyExpenseChat}); - }); -}); - describe('getStableReportSelector', () => { const {READ, WRITE, SHARE} = CONST.REPORT.PERMISSIONS; From 457fd187b67782428593e901f2c0f5506de67cb4 Mon Sep 17 00:00:00 2001 From: jakubstec Date: Thu, 3 Sep 2026 16:09:56 +0200 Subject: [PATCH 06/23] chore: align sending emails to non-admin workspace owners behavior to OldDot's --- .../parameters/SetVacationDelegateParams.ts | 1 - src/libs/actions/VacationDelegate.ts | 4 +-- .../VacationDelegateMissingWorkspacesPage.tsx | 14 +++++------ tests/actions/VacationDelegateTest.ts | 3 +-- ...ationDelegateMissingWorkspacesPageTest.tsx | 25 +++++++++++-------- 5 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/libs/API/parameters/SetVacationDelegateParams.ts b/src/libs/API/parameters/SetVacationDelegateParams.ts index abf087c6bfd7..d149f370d5c5 100644 --- a/src/libs/API/parameters/SetVacationDelegateParams.ts +++ b/src/libs/API/parameters/SetVacationDelegateParams.ts @@ -4,7 +4,6 @@ type SetVacationDelegateParams = { vacationDelegateEmail: string; overridePolicyDiffWarning: boolean; domainAccountID?: number; - skipPolicyInviteEmails?: boolean; }; export default SetVacationDelegateParams; diff --git a/src/libs/actions/VacationDelegate.ts b/src/libs/actions/VacationDelegate.ts index b2470bae2c4f..313bd5545df6 100644 --- a/src/libs/actions/VacationDelegate.ts +++ b/src/libs/actions/VacationDelegate.ts @@ -17,10 +17,9 @@ type SetVacationDelegateOptions = { delegate: string; currentDelegate?: string; shouldOverridePolicyDiffWarning?: boolean; - shouldSkipPolicyInviteEmails?: boolean; }; -async function setVacationDelegate({creator, delegate, currentDelegate, shouldOverridePolicyDiffWarning = false, shouldSkipPolicyInviteEmails}: SetVacationDelegateOptions) { +async function setVacationDelegate({creator, delegate, currentDelegate, shouldOverridePolicyDiffWarning = false}: SetVacationDelegateOptions) { const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -64,7 +63,6 @@ async function setVacationDelegate({creator, delegate, currentDelegate, shouldOv creator, vacationDelegateEmail: delegate, overridePolicyDiffWarning: shouldOverridePolicyDiffWarning, - skipPolicyInviteEmails: shouldSkipPolicyInviteEmails, }; // Once the policy diff warning has been overridden there is nothing left to read from the response, so use a persisted write. diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx index 0f1a93fd0219..25d2196c0f9a 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx @@ -106,19 +106,19 @@ function VacationDelegateMissingWorkspacesPage() { Navigation.goBack(ROUTES.SETTINGS_STATUS); }; - const submit = (shouldSkipPolicyInviteEmails: boolean) => { + const submit = () => { setSubmittedInput({delegate, policyDiff}); - setVacationDelegate({creator, delegate, currentDelegate: previousDelegate, shouldOverridePolicyDiffWarning: true, shouldSkipPolicyInviteEmails}); + setVacationDelegate({creator, delegate, currentDelegate: previousDelegate, shouldOverridePolicyDiffWarning: true}); goBackToStatus(); }; - const submitOnce = (shouldSkipPolicyInviteEmails: boolean) => { + const submitOnce = () => { if (isSubmittingRef.current) { return; } isSubmittingRef.current = true; - submit(shouldSkipPolicyInviteEmails); + submit(); }; const invite = () => { @@ -151,7 +151,7 @@ function VacationDelegateMissingWorkspacesPage() { } // Workspaces the current user does not administer are untouched here and still need their own admins emailed. - submit(false); + submit(); }; // Format SMS delegates as phone numbers rather than raw @expensify.sms logins, and escape the result since this copy is rendered as HTML. @@ -220,7 +220,7 @@ function VacationDelegateMissingWorkspacesPage() { @@ -229,7 +229,7 @@ function VacationDelegateMissingWorkspacesPage() { diff --git a/tests/actions/VacationDelegateTest.ts b/tests/actions/VacationDelegateTest.ts index 5c9d93cf7081..e33b7b5c5f47 100644 --- a/tests/actions/VacationDelegateTest.ts +++ b/tests/actions/VacationDelegateTest.ts @@ -28,7 +28,7 @@ describe('actions/VacationDelegate', () => { it('sends SetVacationDelegate with the mapped params and clears policyDiff optimistically', () => { const apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve()); - setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', currentDelegate: 'old@test.com', shouldSkipPolicyInviteEmails: true}); + setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', currentDelegate: 'old@test.com'}); expect(apiSideEffectSpy).toHaveBeenCalledWith( SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, @@ -36,7 +36,6 @@ describe('actions/VacationDelegate', () => { creator: 'admin@test.com', vacationDelegateEmail: 'delegate@test.com', overridePolicyDiffWarning: false, - skipPolicyInviteEmails: true, }, { optimisticData: expect.arrayContaining([ diff --git a/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx index 6d90cb5ce9b9..6f0500d13332 100644 --- a/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx +++ b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx @@ -215,11 +215,7 @@ describe('VacationDelegateMissingWorkspacesPage', () => { fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.confirm')})); await waitForBatchedUpdatesWithAct(); - expect(apiWriteSpy).toHaveBeenCalledWith( - WRITE_COMMANDS.SET_VACATION_DELEGATE, - expect.objectContaining({creator: CREATOR_EMAIL, overridePolicyDiffWarning: true, skipPolicyInviteEmails: false}), - expect.anything(), - ); + expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.SET_VACATION_DELEGATE, expect.objectContaining({creator: CREATOR_EMAIL, overridePolicyDiffWarning: true}), expect.anything()); expect(apiWriteSpy).not.toHaveBeenCalledWith(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, expect.anything(), expect.anything()); }); @@ -247,11 +243,7 @@ describe('VacationDelegateMissingWorkspacesPage', () => { fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.skip')})); await waitForBatchedUpdatesWithAct(); - expect(apiWriteSpy).toHaveBeenCalledWith( - WRITE_COMMANDS.SET_VACATION_DELEGATE, - expect.objectContaining({overridePolicyDiffWarning: true, skipPolicyInviteEmails: true}), - expect.anything(), - ); + expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.SET_VACATION_DELEGATE, expect.objectContaining({overridePolicyDiffWarning: true}), expect.anything()); expect(apiWriteSpy).not.toHaveBeenCalledWith(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, expect.anything(), expect.anything()); }); @@ -334,6 +326,19 @@ describe('VacationDelegateMissingWorkspacesPage', () => { expect(screen.getByRole('button', {name: TestHelper.translateLocal('common.skip')})).toBeOnTheScreen(); }); + // Skip only skips the invites the user controls: the owners of the workspaces they don't administer are still emailed, matching Classic. + it('still asks the backend to email the non-admin workspaces when Skip is pressed on a mixed diff', async () => { + await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID], nonAdminPolicies: [MEMBER_POLICY_ID]}); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.skip')})); + await waitForBatchedUpdatesWithAct(); + + expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.SET_VACATION_DELEGATE, expect.objectContaining({overridePolicyDiffWarning: true}), expect.anything()); + expect(apiWriteSpy).not.toHaveBeenCalledWith(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, expect.anything(), expect.anything()); + }); + it('finishes rolling back an abandoned flow before exposing the delegate selection page', async () => { await seedVacationDelegate({adminPolicies: [], nonAdminPolicies: [MEMBER_POLICY_ID]}); renderPage(true); From 32a13a3c603f3d76a61de5a5b140536af38941e9 Mon Sep 17 00:00:00 2001 From: jakubstec Date: Thu, 3 Sep 2026 16:34:50 +0200 Subject: [PATCH 07/23] fix: pre-existing bug causing vacation delegate row to disappear after clearing up the selected delegate --- src/components/VacationDelegateMenuItem.tsx | 1 + .../VacationDelegateMenuItemTest.tsx | 20 +++++++++++-------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/components/VacationDelegateMenuItem.tsx b/src/components/VacationDelegateMenuItem.tsx index cf6e8ed9f328..4bf308915ac5 100644 --- a/src/components/VacationDelegateMenuItem.tsx +++ b/src/components/VacationDelegateMenuItem.tsx @@ -58,6 +58,7 @@ function VacationDelegateMenuItem({vacationDelegate, errors, pendingAction, onCl errorRowStyles={styles.mh5} onClose={onCloseError} style={hasVacationDelegate && styles.mt4} + shouldHideOnDelete={false} > {hasVacationDelegate ? ( ({ jest.mock('@hooks/useVacationDelegatePersonalDetails', () => jest.fn(() => undefined)); -jest.mock('@components/OfflineWithFeedback', () => { - function MockOfflineWithFeedback({children}: {children: React.ReactNode}) { - return children; - } - return MockOfflineWithFeedback; -}); - // Capture the props passed to the MenuItemAvater so the test can assert against `avatarID` directly // (rather than re-encoding through `toJSON()`). const capturedAvatarProps: Array> = []; @@ -86,10 +81,11 @@ describe('VacationDelegateMenuItem', () => { mockUseVacationDelegatePersonalDetails.mockReturnValue(undefined); }); - function renderMenuItem(delegate?: string) { + function renderMenuItem(delegate?: string, pendingAction?: PendingAction) { render( , @@ -212,4 +208,12 @@ describe('VacationDelegateMenuItem', () => { expect(screen.getByText('common.vacationDelegate')).toBeTruthy(); expect(capturedAvatarProps).toHaveLength(0); }); + + // OfflineWithFeedback hides a row that is pending deletion, which blanked the whole field for the length of the + // request. Removing the delegate empties this field, it does not remove the row, so the empty state has to show at once. + it('shows the empty state immediately while the delegate removal is in flight', () => { + renderMenuItem(undefined, CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + + expect(screen.getByText('common.vacationDelegate')).toBeTruthy(); + }); }); From 49bfd30d7b269b497220bf0fc6e9784cecfabc5d Mon Sep 17 00:00:00 2001 From: jakubstec Date: Fri, 4 Sep 2026 08:36:30 +0200 Subject: [PATCH 08/23] fix: request race condition causing not found page bug --- src/libs/actions/VacationDelegate.ts | 6 +++ tests/actions/VacationDelegateTest.ts | 43 +++++++++++++++++-- .../VacationDelegateMenuItemTest.tsx | 2 +- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/libs/actions/VacationDelegate.ts b/src/libs/actions/VacationDelegate.ts index 313bd5545df6..3c8fb3d16d51 100644 --- a/src/libs/actions/VacationDelegate.ts +++ b/src/libs/actions/VacationDelegate.ts @@ -1,4 +1,5 @@ import * as API from '@libs/API'; +import {waitForWrites} from '@libs/API'; import type {SetVacationDelegateParams} from '@libs/API/parameters'; import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; @@ -73,6 +74,11 @@ async function setVacationDelegate({creator, delegate, currentDelegate, shouldOv return; } + // A SetVacationDelegate write from the invite step, and the workspace invitations queued with it, can still be in flight. + // Its success data clears policyDiff and previousDelegate on this same NVP, so letting it settle first keeps it from + // overwriting the optimistic delegate and the policy diff this request is about to capture. + await waitForWrites(SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE); + // We need to read the API response for capturing a policy diff warning. This is the other half of the branch above, not a chained call. // No failureData: the API layer treats the 305 policy diff warning as a failure, and any error written for this request lights up a red brick // road on the profile page, which reads as something being broken. The caller reports every outcome from the returned response instead. diff --git a/tests/actions/VacationDelegateTest.ts b/tests/actions/VacationDelegateTest.ts index e33b7b5c5f47..ae71f88dd083 100644 --- a/tests/actions/VacationDelegateTest.ts +++ b/tests/actions/VacationDelegateTest.ts @@ -10,6 +10,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import Onyx from 'react-native-onyx'; import getOnyxValue from '../utils/getOnyxValue'; +import {createGlobalFetchMock, getFetchMockCalls} from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; OnyxUpdateManager(); @@ -25,10 +26,10 @@ describe('actions/VacationDelegate', () => { }); describe('setVacationDelegate', () => { - it('sends SetVacationDelegate with the mapped params and clears policyDiff optimistically', () => { + it('sends SetVacationDelegate with the mapped params and clears policyDiff optimistically', async () => { const apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve()); - setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', currentDelegate: 'old@test.com'}); + await setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', currentDelegate: 'old@test.com'}); expect(apiSideEffectSpy).toHaveBeenCalledWith( SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, @@ -60,11 +61,11 @@ describe('actions/VacationDelegate', () => { apiSideEffectSpy.mockRestore(); }); - it('sends a persisted write instead of a side effect request once the policy diff warning is overridden', () => { + it('sends a persisted write instead of a side effect request once the policy diff warning is overridden', async () => { const apiWriteSpy = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => undefined); const apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve()); - setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', shouldOverridePolicyDiffWarning: true}); + await setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', shouldOverridePolicyDiffWarning: true}); expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.SET_VACATION_DELEGATE, expect.objectContaining({overridePolicyDiffWarning: true}), expect.anything()); expect(apiSideEffectSpy).not.toHaveBeenCalled(); @@ -72,6 +73,40 @@ describe('actions/VacationDelegate', () => { jest.restoreAllMocks(); }); + it('waits for queued writes to settle before starting, so their responses cannot clear the policy diff it captures', async () => { + const mockFetch = createGlobalFetchMock(); + global.fetch = mockFetch; + await Onyx.set(ONYXKEYS.SESSION, {email: 'admin@test.com', accountID: 1, authToken: 'testAuthToken'}); + await waitForBatchedUpdates(); + + const policyDiff = {adminPolicies: ['1'], nonAdminPolicies: ['2']}; + mockFetch.mockAPICommand(WRITE_COMMANDS.SET_VACATION_DELEGATE, (params) => + params.vacationDelegateEmail === 'second@test.com' + ? {jsonCode: CONST.JSON_CODE.POLICY_DIFF_WARNING, data: {policyDiff, phpCommandName: 'SetVacationDelegate', authWriteCommands: []}} + : {jsonCode: CONST.JSON_CODE.SUCCESS}, + ); + + // The write sent by the invite step is still sitting in the sequential queue when the next delegate is picked. + mockFetch.pause(); + setVacationDelegate({creator: 'admin@test.com', delegate: 'first@test.com', shouldOverridePolicyDiffWarning: true}); + await waitForBatchedUpdates(); + + const request = setVacationDelegate({creator: 'admin@test.com', delegate: 'second@test.com', currentDelegate: 'first@test.com'}); + await waitForBatchedUpdates(); + + // Nothing of the second selection may reach the network or the NVP while the queued write can still respond over it. + expect(getFetchMockCalls(WRITE_COMMANDS.SET_VACATION_DELEGATE).length).toBe(1); + expect((await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE))?.delegate).toBe('first@test.com'); + + await mockFetch.resume(); + await request; + await waitForBatchedUpdates(); + + const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + expect(vacationDelegate?.delegate).toBe('second@test.com'); + expect(vacationDelegate?.policyDiff).toEqual(policyDiff); + }); + it('merges the policyDiff from a 305 response into the NVP without ever surfacing an error', async () => { const policyDiff = {adminPolicies: ['1'], nonAdminPolicies: ['2']}; const errorStates: Array = []; diff --git a/tests/unit/components/VacationDelegateMenuItemTest.tsx b/tests/unit/components/VacationDelegateMenuItemTest.tsx index 881d643339eb..f9b4b0c0631f 100644 --- a/tests/unit/components/VacationDelegateMenuItemTest.tsx +++ b/tests/unit/components/VacationDelegateMenuItemTest.tsx @@ -51,7 +51,7 @@ jest.mock('@hooks/useLazyAsset', () => ({ jest.mock('@hooks/useVacationDelegatePersonalDetails', () => jest.fn(() => undefined)); -// Capture the props passed to the MenuItemAvater so the test can assert against `avatarID` directly +// Capture the props passed to the MenuItemAvatar so the test can assert against `avatarID` directly // (rather than re-encoding through `toJSON()`). const capturedAvatarProps: Array> = []; From bea066694606112c1fd5fbf05d87d2a1f15df5f7 Mon Sep 17 00:00:00 2001 From: jakubstec Date: Mon, 7 Sep 2026 09:41:57 +0200 Subject: [PATCH 09/23] fix: handle request failure properly, refactor to the new MenuItems API and align the styling --- src/libs/actions/VacationDelegate.ts | 7 +++ .../VacationDelegateMissingWorkspacesPage.tsx | 58 ++++++++++++------- src/styles/utils/spacing.ts | 4 ++ 3 files changed, 49 insertions(+), 20 deletions(-) diff --git a/src/libs/actions/VacationDelegate.ts b/src/libs/actions/VacationDelegate.ts index 3c8fb3d16d51..ebaa05963c8d 100644 --- a/src/libs/actions/VacationDelegate.ts +++ b/src/libs/actions/VacationDelegate.ts @@ -91,6 +91,13 @@ async function setVacationDelegate({creator, delegate, currentDelegate, shouldOv policyDiff: response.data.policyDiff, pendingAction: null, }); + } else if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { + await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { + delegate: currentDelegate ?? null, + previousDelegate: null, + pendingAction: null, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('statusPage.vacationDelegateError'), + }); } return response; diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx index 25d2196c0f9a..79447b8c97a8 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx @@ -1,8 +1,10 @@ +import WorkspaceAvatar from '@components/Avatar/WorkspaceAvatar'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; import Button from '@components/ButtonComposed'; import FixedFooter from '@components/FixedFooter'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItem from '@components/MenuItem'; import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -90,26 +92,40 @@ function VacationDelegateMissingWorkspacesPage() { return ; } - const getMenuItemsForPolicies = (policyIDs: string[]) => - policyIDs.map((policyID, index) => ({ - key: policyID, - title: policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.name ?? translate('workspace.common.unavailable'), - avatarID: policyID, - icon: policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.avatarURL, - iconType: CONST.ICON_TYPE_WORKSPACE, - description: translate('workspace.common.workspace'), - interactive: false, - wrapperStyle: [styles.ph4, index < policyIDs.length - 1 ? styles.borderBottom : undefined], - })); - - const goBackToStatus = () => { - Navigation.goBack(ROUTES.SETTINGS_STATUS); - }; + const renderPolicyMenuItems = (policyIDs: string[]) => + policyIDs.map((policyID, index) => { + const title = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.name ?? translate('workspace.common.unavailable'); + return ( + + {/* MenuItem.Root's built-in padding is ph5 (20px); mhn1 pulls it back to ph4 (16px) to align with the section title */} + + + + + + + + {title} + {translate('workspace.common.workspace')} + + + + + + ); + }); const submit = () => { setSubmittedInput({delegate, policyDiff}); setVacationDelegate({creator, delegate, currentDelegate: previousDelegate, shouldOverridePolicyDiffWarning: true}); - goBackToStatus(); + Navigation.goBack(ROUTES.SETTINGS_STATUS); }; const submitOnce = () => { @@ -172,16 +188,18 @@ function VacationDelegateMissingWorkspacesPage() { title={translate('statusPage.vacationDelegate.youAreAMemberOf')} titleStyles={[styles.sectionTitle, styles.ph4, styles.w100, styles.borderBottom]} containerStyles={[styles.p0, styles.mh0]} - menuItems={getMenuItemsForPolicies(nonAdminPolicies)} - /> + > + {renderPolicyMenuItems(nonAdminPolicies)} +
)} {adminPolicies.length > 0 && (
0 ? styles.mt5 : undefined]} - menuItems={getMenuItemsForPolicies(adminPolicies)} - /> + > + {renderPolicyMenuItems(adminPolicies)} +
)} ); diff --git a/src/styles/utils/spacing.ts b/src/styles/utils/spacing.ts index 7437eee3ca23..941a26738619 100644 --- a/src/styles/utils/spacing.ts +++ b/src/styles/utils/spacing.ts @@ -75,6 +75,10 @@ export default { marginHorizontal: 32, }, + mhn1: { + marginHorizontal: -4, + }, + mhn2: { marginHorizontal: -8, }, From d9e1a0748b5b2f9dbfe2a48d289c1e88ccc26f99 Mon Sep 17 00:00:00 2001 From: jakubstec Date: Mon, 7 Sep 2026 09:45:08 +0200 Subject: [PATCH 10/23] fix: update tests --- tests/actions/VacationDelegateTest.ts | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/tests/actions/VacationDelegateTest.ts b/tests/actions/VacationDelegateTest.ts index ae71f88dd083..5671d23f834b 100644 --- a/tests/actions/VacationDelegateTest.ts +++ b/tests/actions/VacationDelegateTest.ts @@ -143,16 +143,32 @@ describe('actions/VacationDelegate', () => { jest.restoreAllMocks(); }); - it('writes no error on a failed response, leaving the caller to report it', async () => { + it('reconciles the NVP itself on a failed response, since the caller may have already navigated away', async () => { const response = {jsonCode: CONST.JSON_CODE.EXP_ERROR, message: 'Nope'}; - jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve(response)); + jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(async () => { + // The optimistic data is applied before the response resolves. + await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { + creator: 'admin@test.com', + delegate: 'delegate@test.com', + previousDelegate: 'old@test.com', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + errors: null, + }); + return response; + }); - // An error here would light up a red brick road on the profile page, which reads as something being broken. - await expect(setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com'})).resolves.toEqual(response); + // There is no failureData attached to the request (see the first test above), so the action must undo the optimistic + // update and surface the error itself instead of relying on a caller that may no longer be mounted to do it. + await expect(setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', currentDelegate: 'old@test.com'})).resolves.toEqual(response); await waitForBatchedUpdates(); const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); - expect(vacationDelegate?.errors).toBeFalsy(); + expect(vacationDelegate?.delegate).toBe('old@test.com'); + expect(vacationDelegate?.previousDelegate).toBeFalsy(); + expect(vacationDelegate?.pendingAction).toBeFalsy(); + expect(vacationDelegate?.errors).toBeTruthy(); + + jest.restoreAllMocks(); }); it('does not merge a policyDiff on a successful (200) response', async () => { From 7735a50755b4c7bfa5ec722ab547aa04426056e8 Mon Sep 17 00:00:00 2001 From: jakubstec Date: Mon, 7 Sep 2026 09:54:10 +0200 Subject: [PATCH 11/23] fix: consistency comments --- .../CustomStatus/VacationDelegateMissingWorkspacesPage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx index 79447b8c97a8..7f3e39229bc6 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx @@ -1,3 +1,7 @@ +/** + * RHP step shown when a vacation delegate is missing from workspaces: lets the user invite them into the + * workspaces they admin, skip, or just confirm the delegate. + */ import WorkspaceAvatar from '@components/Avatar/WorkspaceAvatar'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; import Button from '@components/ButtonComposed'; @@ -100,7 +104,7 @@ function VacationDelegateMissingWorkspacesPage() { key={policyID} style={index < policyIDs.length - 1 ? styles.borderBottom : undefined} > - {/* MenuItem.Root's built-in padding is ph5 (20px); mhn1 pulls it back to ph4 (16px) to align with the section title */} + {/* MenuItem.Root's built-in padding is ph5 (20px). mhn1 pulls it back to ph4 (16px) to align with the section title */} From 111ba044df9b142d58f3e3858958a75fac2cd89c Mon Sep 17 00:00:00 2001 From: jakubstec Date: Mon, 7 Sep 2026 10:51:54 +0200 Subject: [PATCH 12/23] fix: apply review changes --- src/libs/actions/VacationDelegate.ts | 11 +-- .../CustomStatus/VacationDelegatePage.tsx | 6 +- tests/actions/VacationDelegateTest.ts | 40 +++++++++-- tests/ui/VacationDelegatePageTest.tsx | 68 ++++++++++++++++++- 4 files changed, 110 insertions(+), 15 deletions(-) diff --git a/src/libs/actions/VacationDelegate.ts b/src/libs/actions/VacationDelegate.ts index ebaa05963c8d..6d4d3eab51b9 100644 --- a/src/libs/actions/VacationDelegate.ts +++ b/src/libs/actions/VacationDelegate.ts @@ -80,8 +80,8 @@ async function setVacationDelegate({creator, delegate, currentDelegate, shouldOv await waitForWrites(SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE); // We need to read the API response for capturing a policy diff warning. This is the other half of the branch above, not a chained call. - // No failureData: the API layer treats the 305 policy diff warning as a failure, and any error written for this request lights up a red brick - // road on the profile page, which reads as something being broken. The caller reports every outcome from the returned response instead. + // No failureData: the API layer treats the 305 policy diff warning as a failure, so attaching it would light up a red brick road on the + // profile page for what is really just the next step of this flow. The branches below apply it by hand for the failures that are real. // eslint-disable-next-line rulesdir/no-api-side-effects-method, rulesdir/no-multiple-api-calls const response = await API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, parameters, {optimisticData, successData}); @@ -92,12 +92,7 @@ async function setVacationDelegate({creator, delegate, currentDelegate, shouldOv pendingAction: null, }); } else if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { - await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { - delegate: currentDelegate ?? null, - previousDelegate: null, - pendingAction: null, - errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('statusPage.vacationDelegateError'), - }); + await Onyx.update(failureData); } return response; diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx index 7635db12915f..4f1698958689 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx @@ -56,6 +56,9 @@ function VacationDelegatePage() { setVacationDelegate({creator: currentUserLogin, delegate: option?.login ?? '', currentDelegate}) .then((response) => { if (!navigation.isFocused()) { + if (response?.data?.policyDiff) { + clearVacationDelegateError(currentDelegate); + } return; } @@ -64,7 +67,8 @@ function VacationDelegatePage() { return; } - // The request writes no error of its own, so this modal is the only feedback for a failure. Dismissing it restores the previous delegate. + // The action leaves the failure on the NVP for the profile page's red brick road, but the user is still on this screen, + // so report it where they are. Dismissing the modal restores the previous delegate, exactly as dismissing that error would. if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { showErrorModal(currentDelegate, response?.jsonCode === CONST.JSON_CODE.EXP_ERROR ? response.message : undefined); return; diff --git a/tests/actions/VacationDelegateTest.ts b/tests/actions/VacationDelegateTest.ts index 5671d23f834b..629f8180558c 100644 --- a/tests/actions/VacationDelegateTest.ts +++ b/tests/actions/VacationDelegateTest.ts @@ -143,7 +143,7 @@ describe('actions/VacationDelegate', () => { jest.restoreAllMocks(); }); - it('reconciles the NVP itself on a failed response, since the caller may have already navigated away', async () => { + it('applies the failureData it could not attach when the response fails, since the caller may have already navigated away', async () => { const response = {jsonCode: CONST.JSON_CODE.EXP_ERROR, message: 'Nope'}; jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(async () => { // The optimistic data is applied before the response resolves. @@ -157,17 +157,47 @@ describe('actions/VacationDelegate', () => { return response; }); - // There is no failureData attached to the request (see the first test above), so the action must undo the optimistic - // update and surface the error itself instead of relying on a caller that may no longer be mounted to do it. + // There is no failureData attached to the request (see the first test above), so the action has to apply it itself + // instead of relying on a caller that may no longer be mounted to report the failure. await expect(setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', currentDelegate: 'old@test.com'})).resolves.toEqual(response); await waitForBatchedUpdates(); const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); - expect(vacationDelegate?.delegate).toBe('old@test.com'); - expect(vacationDelegate?.previousDelegate).toBeFalsy(); expect(vacationDelegate?.pendingAction).toBeFalsy(); expect(vacationDelegate?.errors).toBeTruthy(); + // Rolling back belongs to whoever dismisses the error, so both delegate fields have to survive for it to roll back to. + expect(vacationDelegate?.delegate).toBe('delegate@test.com'); + expect(vacationDelegate?.previousDelegate).toBe('old@test.com'); + + jest.restoreAllMocks(); + }); + + it('leaves a failed response in a state where dismissing the error restores the last confirmed delegate', async () => { + jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(async () => { + await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { + creator: 'admin@test.com', + delegate: 'delegate@test.com', + previousDelegate: 'old@test.com', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + errors: null, + }); + return {jsonCode: CONST.JSON_CODE.EXP_ERROR, message: 'Nope'}; + }); + + await setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', currentDelegate: 'old@test.com'}); + await waitForBatchedUpdates(); + + // What the profile page does when the red brick road is dismissed. It reads previousDelegate back out of Onyx, so a failure + // that cleared it would delete the delegate the user actually has instead of restoring it. + const failed = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + clearVacationDelegateError(failed?.previousDelegate); + await waitForBatchedUpdates(); + + const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + expect(vacationDelegate?.delegate).toBe('old@test.com'); + expect(vacationDelegate?.errors).toBeFalsy(); + jest.restoreAllMocks(); }); diff --git a/tests/ui/VacationDelegatePageTest.tsx b/tests/ui/VacationDelegatePageTest.tsx index 1c62b70c1a8d..c591f9e688b4 100644 --- a/tests/ui/VacationDelegatePageTest.tsx +++ b/tests/ui/VacationDelegatePageTest.tsx @@ -19,7 +19,8 @@ import type * as ReactNavigation from '@react-navigation/native'; // eslint-disable-next-line no-restricted-imports -- React Native Pressable/Text are required only to type the actual Jest module export; this does not import them at runtime. import type {Pressable as ReactNativePressable, Text as ReactNativeText} from 'react-native'; -import {NavigationContainer} from '@react-navigation/native'; +import {createNavigationContainerRef, NavigationContainer} from '@react-navigation/native'; +import {createStackNavigator} from '@react-navigation/stack'; import React from 'react'; import Onyx from 'react-native-onyx'; @@ -76,6 +77,34 @@ function renderPage() { ); } +// A real Stack.Navigator so pushing "Other" genuinely blurs VacationDelegatePage's own useNavigation().isFocused(), +// unlike renderPage() above where the page is the sole, permanently-focused screen. +type TestParamList = { + VacationDelegate: undefined; + Other: undefined; +}; +const Stack = createStackNavigator(); +const testNavigationRef = createNavigationContainerRef(); + +function renderPageWithStack() { + return render( + + + + + null} + /> + + + , + ); +} + describe('VacationDelegatePage', () => { let apiSideEffectSpy: jest.SpyInstance; @@ -189,4 +218,41 @@ describe('VacationDelegatePage', () => { }), ); }); + + it('rolls back the optimistic delegate instead of leaving an unconfirmed policy diff behind when the screen loses focus before the response resolves', async () => { + let resolveSideEffect: (response: {jsonCode: number; data?: {policyDiff: unknown}}) => void = () => {}; + apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation( + () => + new Promise((resolve) => { + resolveSideEffect = resolve; + }), + ); + jest.mocked(Navigation.navigate).mockClear(); + + renderPageWithStack(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByTestId('select-delegate-a')); + await waitForBatchedUpdatesWithAct(); + + // Simulates the user swiping the RHP away (or otherwise navigating off this screen) before the request settles. + await act(async () => { + testNavigationRef.current?.navigate('Other'); + await waitForBatchedUpdatesWithAct(); + }); + + await act(async () => { + resolveSideEffect({jsonCode: CONST.JSON_CODE.POLICY_DIFF_WARNING, data: {policyDiff: {adminPolicies: [], nonAdminPolicies: []}}}); + await waitForBatchedUpdatesWithAct(); + }); + + // There's no longer a screen to carry the user into the missing-workspaces step, so it must not be pushed onto whatever they navigated to instead. + expect(Navigation.navigate).not.toHaveBeenCalledWith(ROUTES.SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES); + + const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + expect(vacationDelegate?.delegate).toBeFalsy(); + expect(vacationDelegate?.policyDiff).toBeFalsy(); + expect(vacationDelegate?.pendingAction).toBeFalsy(); + expect(vacationDelegate?.errors).toBeFalsy(); + }); }); From bc23313528e2e2452092cc8f5a51b41b85ec6973 Mon Sep 17 00:00:00 2001 From: jakubstec Date: Wed, 9 Sep 2026 16:14:27 +0200 Subject: [PATCH 13/23] fix: address review comments - refactor VacationDelegateMissingWorkspacesPage component, change the copy message, minor performance issues, code clean-up --- src/languages/de.ts | 6 +- src/languages/el.ts | 6 +- src/languages/en.ts | 6 +- src/languages/es.ts | 6 +- src/languages/fr.ts | 6 +- src/languages/it.ts | 6 +- src/languages/ja.ts | 6 +- src/languages/nl.ts | 6 +- src/languages/pl.ts | 6 +- src/languages/pt-BR.ts | 6 +- src/languages/zh-hans.ts | 6 +- src/libs/actions/VacationDelegate.ts | 79 +++++- .../VacationDelegateMissingWorkspacesPage.tsx | 267 ------------------ .../MissingWorkspacesFooter.tsx | 65 +++++ .../MissingWorkspacesIntro.tsx | 45 +++ .../WorkspaceRow.tsx | 52 ++++ .../WorkspaceSection.tsx | 50 ++++ .../index.tsx | 166 +++++++++++ .../CustomStatus/VacationDelegatePage.tsx | 9 +- src/styles/utils/spacing.ts | 4 - ...ationDelegateMissingWorkspacesPageTest.tsx | 53 ++++ 21 files changed, 545 insertions(+), 311 deletions(-) delete mode 100644 src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx create mode 100644 src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesFooter.tsx create mode 100644 src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesIntro.tsx create mode 100644 src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/WorkspaceRow.tsx create mode 100644 src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/WorkspaceSection.tsx create mode 100644 src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx diff --git a/src/languages/de.ts b/src/languages/de.ts index 96fe7d10837d..decb91673617 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -3756,10 +3756,10 @@ ${amount} für ${merchant} – ${date}`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `an ${submittedToName} als Urlaubsvertretung für ${vacationDelegateName}`, vacationDelegate: { notAMemberAdminsWillBeAsked: (email: string) => - `${email} ist kein Mitglied dieser Arbeitsbereiche. Admins der Arbeitsbereiche, die Sie nicht verwalten, werden gebeten, die Person hinzuzufügen.`, - notAMemberInviteThemNow: (email: string) => `${email} ist kein Mitglied dieser Arbeitsbereiche. Möchten Sie die Person jetzt einladen?`, + `${email} ist kein Mitglied der unten aufgeführten Arbeitsbereiche. Admins der Arbeitsbereiche, die Sie nicht verwalten, werden gebeten, die Person hinzuzufügen.`, + notAMemberInviteThemNow: (email: string) => `${email} ist kein Mitglied der unten aufgeführten Arbeitsbereiche. Möchten Sie die Person jetzt einladen?`, notAMemberMixed: (email: string) => - `${email} ist kein Mitglied dieser Arbeitsbereiche. Admins der Arbeitsbereiche, die Sie nicht verwalten, werden gebeten, die Person hinzuzufügen. Möchten Sie die Person jetzt zu den Arbeitsbereichen einladen, in denen Sie Admin sind?`, + `${email} ist kein Mitglied der unten aufgeführten Arbeitsbereiche. Admins der Arbeitsbereiche, die Sie nicht verwalten, werden gebeten, die Person hinzuzufügen. Möchten Sie die Person jetzt zu den Arbeitsbereichen einladen, in denen Sie Admin sind?`, youAreAMemberOf: 'Sie sind Mitglied dieser Arbeitsbereiche:', youAreAnAdminOf: 'Sie sind Admin dieser Arbeitsbereiche:', }, diff --git a/src/languages/el.ts b/src/languages/el.ts index 21a5802ac02f..b9164f9e4a5a 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -3811,10 +3811,10 @@ ${amount} για ${merchant} - ${date}`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `στον/στη ${submittedToName} ως αναπληρωτής/τρια αδειούχου για τον/τη ${vacationDelegateName}`, vacationDelegate: { notAMemberAdminsWillBeAsked: (email: string) => - `Ο/Η ${email} δεν είναι μέλος αυτών των χώρων εργασίας. Θα ζητηθεί από τους διαχειριστές των χώρων εργασίας που δεν διαχειρίζεστε να τον/την προσθέσουν.`, - notAMemberInviteThemNow: (email: string) => `Ο/Η ${email} δεν είναι μέλος αυτών των χώρων εργασίας. Θέλετε να τον/την προσκαλέσετε τώρα;`, + `Ο/Η ${email} δεν είναι μέλος των παρακάτω χώρων εργασίας. Θα ζητηθεί από τους διαχειριστές των χώρων εργασίας που δεν διαχειρίζεστε να τον/την προσθέσουν.`, + notAMemberInviteThemNow: (email: string) => `Ο/Η ${email} δεν είναι μέλος των παρακάτω χώρων εργασίας. Θέλετε να τον/την προσκαλέσετε τώρα;`, notAMemberMixed: (email: string) => - `Ο/Η ${email} δεν είναι μέλος αυτών των χώρων εργασίας. Θα ζητηθεί από τους διαχειριστές των χώρων εργασίας που δεν διαχειρίζεστε να τον/την προσθέσουν. Θέλετε να τον/την προσκαλέσετε τώρα σε αυτούς που διαχειρίζεστε ως διαχειριστής;`, + `Ο/Η ${email} δεν είναι μέλος των παρακάτω χώρων εργασίας. Θα ζητηθεί από τους διαχειριστές των χώρων εργασίας που δεν διαχειρίζεστε να τον/την προσθέσουν. Θέλετε να τον/την προσκαλέσετε τώρα σε αυτούς που διαχειρίζεστε ως διαχειριστής;`, youAreAMemberOf: 'Είστε μέλος αυτών των χώρων εργασίας:', youAreAnAdminOf: 'Είστε διαχειριστής αυτών των χώρων εργασίας:', }, diff --git a/src/languages/en.ts b/src/languages/en.ts index 40ac93b30eb4..7ed7172b5e54 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3861,10 +3861,10 @@ const translations = { toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `to ${submittedToName} as vacation delegate for ${vacationDelegateName}`, vacationDelegate: { notAMemberAdminsWillBeAsked: (email: string) => - `${email} is not a member of these workspaces. Admins of the workspaces you don't manage will be asked to add them.`, - notAMemberInviteThemNow: (email: string) => `${email} is not a member of these workspaces. Do you want to invite them now?`, + `${email} is not a member of the workspaces below. Admins of the workspaces you don't manage will be asked to add them.`, + notAMemberInviteThemNow: (email: string) => `${email} is not a member of the workspaces below. Do you want to invite them now?`, notAMemberMixed: (email: string) => - `${email} isn't a member of these workspaces. Admins of the workspaces you don't manage will be asked to add them. Do you want to invite them to the ones you are an admin of now?`, + `${email} isn't a member of the workspaces below. Admins of the workspaces you don't manage will be asked to add them. Do you want to invite them to the ones you are an admin of now?`, youAreAMemberOf: 'You are a member of these workspaces:', youAreAnAdminOf: 'You are an admin of these workspaces:', }, diff --git a/src/languages/es.ts b/src/languages/es.ts index 76fd5d34a74f..93a7fb9b613b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3681,10 +3681,10 @@ ${amount} para ${merchant} - ${date}`, toAsVacationDelegate: (submittedToName, vacationDelegateName) => `a ${submittedToName} como delegado de vacaciones de ${vacationDelegateName}`, vacationDelegate: { notAMemberAdminsWillBeAsked: (email: string) => - `${email} no es miembro de estos espacios de trabajo. Se pedirá a los administradores de los espacios de trabajo que no gestiona que lo agreguen.`, - notAMemberInviteThemNow: (email: string) => `${email} no es miembro de estos espacios de trabajo. ¿Desea invitarlo ahora?`, + `${email} no es miembro de los siguientes espacios de trabajo. Se pedirá a los administradores de los espacios de trabajo que no gestiona que lo agreguen.`, + notAMemberInviteThemNow: (email: string) => `${email} no es miembro de los siguientes espacios de trabajo. ¿Desea invitarlo ahora?`, notAMemberMixed: (email: string) => - `${email} no es miembro de estos espacios de trabajo. Se pedirá a los administradores de los espacios de trabajo que no gestiona que lo agreguen. ¿Desea invitarlo ahora a los espacios de trabajo en los que es administrador?`, + `${email} no es miembro de los siguientes espacios de trabajo. Se pedirá a los administradores de los espacios de trabajo que no gestiona que lo agreguen. ¿Desea invitarlo ahora a los espacios de trabajo en los que es administrador?`, youAreAMemberOf: 'Usted es miembro de estos espacios de trabajo:', youAreAnAdminOf: 'Usted es administrador de estos espacios de trabajo:', }, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 348e08903522..a4fafcce48e1 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -3764,10 +3764,10 @@ ${amount} pour ${merchant} - ${date}`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `à ${submittedToName} en tant que délégué de congés pour ${vacationDelegateName}`, vacationDelegate: { notAMemberAdminsWillBeAsked: (email: string) => - `${email} n'est pas membre de ces espaces de travail. Les administrateurs des espaces de travail que vous ne gérez pas seront invités à l'ajouter.`, - notAMemberInviteThemNow: (email: string) => `${email} n'est pas membre de ces espaces de travail. Voulez-vous l'inviter maintenant ?`, + `${email} n'est pas membre des espaces de travail ci-dessous. Les administrateurs des espaces de travail que vous ne gérez pas seront invités à l'ajouter.`, + notAMemberInviteThemNow: (email: string) => `${email} n'est pas membre des espaces de travail ci-dessous. Voulez-vous l'inviter maintenant ?`, notAMemberMixed: (email: string) => - `${email} n'est pas membre de ces espaces de travail. Les administrateurs des espaces de travail que vous ne gérez pas seront invités à l'ajouter. Voulez-vous l'inviter dès maintenant à ceux dont vous êtes administrateur ?`, + `${email} n'est pas membre des espaces de travail ci-dessous. Les administrateurs des espaces de travail que vous ne gérez pas seront invités à l'ajouter. Voulez-vous l'inviter dès maintenant à ceux dont vous êtes administrateur ?`, youAreAMemberOf: 'Vous êtes membre de ces espaces de travail :', youAreAnAdminOf: 'Vous êtes administrateur de ces espaces de travail :', }, diff --git a/src/languages/it.ts b/src/languages/it.ts index df885b221369..698dcd4b9f07 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -3741,10 +3741,10 @@ ${amount} per ${merchant} - ${date}`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `a ${submittedToName} come delegato ferie per ${vacationDelegateName}`, vacationDelegate: { notAMemberAdminsWillBeAsked: (email: string) => - `${email} non è membro di questi spazi di lavoro. Agli amministratori degli spazi di lavoro che non gestisci verrà chiesto di aggiungerlo.`, - notAMemberInviteThemNow: (email: string) => `${email} non è membro di questi spazi di lavoro. Vuoi invitarlo ora?`, + `${email} non è membro degli spazi di lavoro elencati di seguito. Agli amministratori degli spazi di lavoro che non gestisci verrà chiesto di aggiungerlo.`, + notAMemberInviteThemNow: (email: string) => `${email} non è membro degli spazi di lavoro elencati di seguito. Vuoi invitarlo ora?`, notAMemberMixed: (email: string) => - `${email} non è membro di questi spazi di lavoro. Agli amministratori degli spazi di lavoro che non gestisci verrà chiesto di aggiungerlo. Vuoi invitarlo ora in quelli di cui sei amministratore?`, + `${email} non è membro degli spazi di lavoro elencati di seguito. Agli amministratori degli spazi di lavoro che non gestisci verrà chiesto di aggiungerlo. Vuoi invitarlo ora in quelli di cui sei amministratore?`, youAreAMemberOf: 'Sei membro di questi spazi di lavoro:', youAreAnAdminOf: 'Sei amministratore di questi spazi di lavoro:', }, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 34ecc7d455de..03284f0c5e46 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -3703,10 +3703,10 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `${vacationDelegateName} の休暇代理人として ${submittedToName} に`, vacationDelegate: { notAMemberAdminsWillBeAsked: (email: string) => - `${email}はこれらのワークスペースのメンバーではありません。あなたが管理していないワークスペースの管理者に追加を依頼します。`, - notAMemberInviteThemNow: (email: string) => `${email}はこれらのワークスペースのメンバーではありません。今すぐ招待しますか?`, + `${email}は以下のワークスペースのメンバーではありません。あなたが管理していないワークスペースの管理者に追加を依頼します。`, + notAMemberInviteThemNow: (email: string) => `${email}は以下のワークスペースのメンバーではありません。今すぐ招待しますか?`, notAMemberMixed: (email: string) => - `${email}はこれらのワークスペースのメンバーではありません。あなたが管理していないワークスペースの管理者に追加を依頼します。あなたが管理者になっているワークスペースには今すぐ招待しますか?`, + `${email}は以下のワークスペースのメンバーではありません。あなたが管理していないワークスペースの管理者に追加を依頼します。あなたが管理者になっているワークスペースには今すぐ招待しますか?`, youAreAMemberOf: 'あなたはこれらのワークスペースのメンバーです:', youAreAnAdminOf: 'あなたはこれらのワークスペースの管理者です:', }, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index b1a61968640a..955e832d2ea0 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -3740,10 +3740,10 @@ ${amount} voor ${merchant} - ${date}`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `aan ${submittedToName} als vakantieplaatsvervanger voor ${vacationDelegateName}`, vacationDelegate: { notAMemberAdminsWillBeAsked: (email: string) => - `${email} is geen lid van deze werkruimtes. Beheerders van de werkruimtes die je niet beheert, wordt gevraagd diegene toe te voegen.`, - notAMemberInviteThemNow: (email: string) => `${email} is geen lid van deze werkruimtes. Wil je diegene nu uitnodigen?`, + `${email} is geen lid van de onderstaande werkruimtes. Beheerders van de werkruimtes die je niet beheert, wordt gevraagd diegene toe te voegen.`, + notAMemberInviteThemNow: (email: string) => `${email} is geen lid van de onderstaande werkruimtes. Wil je diegene nu uitnodigen?`, notAMemberMixed: (email: string) => - `${email} is geen lid van deze werkruimtes. Beheerders van de werkruimtes die je niet beheert, wordt gevraagd diegene toe te voegen. Wil je diegene nu uitnodigen voor de werkruimtes waar je beheerder van bent?`, + `${email} is geen lid van de onderstaande werkruimtes. Beheerders van de werkruimtes die je niet beheert, wordt gevraagd diegene toe te voegen. Wil je diegene nu uitnodigen voor de werkruimtes waar je beheerder van bent?`, youAreAMemberOf: 'Je bent lid van deze werkruimtes:', youAreAnAdminOf: 'Je bent beheerder van deze werkruimtes:', }, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 2f5ae919e724..f9d5c1f52381 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -3767,10 +3767,10 @@ ${amount} dla ${merchant} - ${date}`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `do ${submittedToName} jako zastępca urlopowy dla ${vacationDelegateName}`, vacationDelegate: { notAMemberAdminsWillBeAsked: (email: string) => - `${email} nie jest członkiem tych przestrzeni roboczych. Administratorzy przestrzeni roboczych, którymi nie zarządzasz, zostaną poproszeni o dodanie tej osoby.`, - notAMemberInviteThemNow: (email: string) => `${email} nie jest członkiem tych przestrzeni roboczych. Czy chcesz zaprosić tę osobę teraz?`, + `${email} nie jest członkiem poniższych przestrzeni roboczych. Administratorzy przestrzeni roboczych, którymi nie zarządzasz, zostaną poproszeni o dodanie tej osoby.`, + notAMemberInviteThemNow: (email: string) => `${email} nie jest członkiem poniższych przestrzeni roboczych. Czy chcesz zaprosić tę osobę teraz?`, notAMemberMixed: (email: string) => - `${email} nie jest członkiem tych przestrzeni roboczych. Administratorzy przestrzeni roboczych, którymi nie zarządzasz, zostaną poproszeni o dodanie tej osoby. Czy chcesz zaprosić tę osobę do przestrzeni, w których jesteś administratorem?`, + `${email} nie jest członkiem poniższych przestrzeni roboczych. Administratorzy przestrzeni roboczych, którymi nie zarządzasz, zostaną poproszeni o dodanie tej osoby. Czy chcesz zaprosić tę osobę do przestrzeni, w których jesteś administratorem?`, youAreAMemberOf: 'Jesteś członkiem tych przestrzeni roboczych:', youAreAnAdminOf: 'Jesteś administratorem tych przestrzeni roboczych:', }, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 489fa6472651..14f0a4854b99 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -3729,10 +3729,10 @@ ${amount} para ${merchant} - ${date}`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `para ${submittedToName} como delegado(a) de férias de ${vacationDelegateName}`, vacationDelegate: { notAMemberAdminsWillBeAsked: (email: string) => - `${email} não é membro desses workspaces. Os admins dos workspaces que você não gerencia serão solicitados a adicioná-lo(a).`, - notAMemberInviteThemNow: (email: string) => `${email} não é membro desses workspaces. Deseja convidá-lo(a) agora?`, + `${email} não é membro dos workspaces abaixo. Os admins dos workspaces que você não gerencia serão solicitados a adicioná-lo(a).`, + notAMemberInviteThemNow: (email: string) => `${email} não é membro dos workspaces abaixo. Deseja convidá-lo(a) agora?`, notAMemberMixed: (email: string) => - `${email} não é membro desses workspaces. Os admins dos workspaces que você não gerencia serão solicitados a adicioná-lo(a). Deseja convidá-lo(a) agora para os workspaces em que você é admin?`, + `${email} não é membro dos workspaces abaixo. Os admins dos workspaces que você não gerencia serão solicitados a adicioná-lo(a). Deseja convidá-lo(a) agora para os workspaces em que você é admin?`, youAreAMemberOf: 'Você é membro destes workspaces:', youAreAnAdminOf: 'Você é admin destes workspaces:', }, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 24b251220523..8fff99ad19cc 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -3611,9 +3611,9 @@ ${amount},商户:${merchant} - 日期:${date}`, asVacationDelegate: (nameOrEmail: string) => `作为 ${nameOrEmail} 的休假代理`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `作为 ${vacationDelegateName} 的休假代理人提交给 ${submittedToName}`, vacationDelegate: { - notAMemberAdminsWillBeAsked: (email: string) => `${email} 不是这些工作区的成员。系统会请求你未管理的工作区的管理员将其添加进来。`, - notAMemberInviteThemNow: (email: string) => `${email} 不是这些工作区的成员。你要现在邀请他们吗?`, - notAMemberMixed: (email: string) => `${email} 不是这些工作区的成员。系统会请求你未管理的工作区的管理员将其添加进来。你要现在邀请他们加入你管理的工作区吗?`, + notAMemberAdminsWillBeAsked: (email: string) => `${email} 不是以下工作区的成员。系统会请求你未管理的工作区的管理员将其添加进来。`, + notAMemberInviteThemNow: (email: string) => `${email} 不是以下工作区的成员。你要现在邀请他们吗?`, + notAMemberMixed: (email: string) => `${email} 不是以下工作区的成员。系统会请求你未管理的工作区的管理员将其添加进来。你要现在邀请他们加入你管理的工作区吗?`, youAreAMemberOf: '你是这些工作区的成员:', youAreAnAdminOf: '你是这些工作区的管理员:', }, diff --git a/src/libs/actions/VacationDelegate.ts b/src/libs/actions/VacationDelegate.ts index 6d4d3eab51b9..c3599ec80a65 100644 --- a/src/libs/actions/VacationDelegate.ts +++ b/src/libs/actions/VacationDelegate.ts @@ -1,18 +1,29 @@ +import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; + import * as API from '@libs/API'; -import {waitForWrites} from '@libs/API'; import type {SetVacationDelegateParams} from '@libs/API/parameters'; import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; +import {getKnownAccountIDByLogin, getPersonalDetailsOnyxDataForOptimisticUsers} from '@libs/PersonalDetailsUtils'; +import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; +import {getMemberAccountIDsForWorkspace} from '@libs/PolicyUtils'; +import {getAllReportActions} from '@libs/ReportActionsUtils'; +import {getPolicyExpenseChat} from '@libs/ReportUtils'; +import {generateAccountID} from '@libs/UserUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {VacationDelegate} from '@src/types/onyx'; +import type {Policy, ReportActions, VacationDelegate} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import type {CurrentUser} from './Policy/Policy'; + +import {addMembersToWorkspace} from './Policy/Member'; + type SetVacationDelegateOptions = { creator: string; delegate: string; @@ -77,7 +88,8 @@ async function setVacationDelegate({creator, delegate, currentDelegate, shouldOv // A SetVacationDelegate write from the invite step, and the workspace invitations queued with it, can still be in flight. // Its success data clears policyDiff and previousDelegate on this same NVP, so letting it settle first keeps it from // overwriting the optimistic delegate and the policy diff this request is about to capture. - await waitForWrites(SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE); + // eslint-disable-next-line rulesdir/no-multiple-api-calls + await API.waitForWrites(SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE); // We need to read the API response for capturing a policy diff warning. This is the other half of the branch above, not a chained call. // No failureData: the API layer treats the 305 policy diff warning as a failure, so attaching it would light up a red brick road on the @@ -87,12 +99,12 @@ async function setVacationDelegate({creator, delegate, currentDelegate, shouldOv if (response?.jsonCode === CONST.JSON_CODE.POLICY_DIFF_WARNING && response.data?.policyDiff) { // Keep the optimistic delegate so the flow can continue into the missing workspaces step. - await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { + Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { policyDiff: response.data.policyDiff, pendingAction: null, }); } else if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { - await Onyx.update(failureData); + Onyx.update(failureData); } return response; @@ -154,4 +166,59 @@ function clearVacationDelegateError(previousDelegate?: string) { }); } -export {setVacationDelegate, deleteVacationDelegate, clearVacationDelegateError}; +type InviteVacationDelegateToWorkspacesOptions = { + /** Login of the delegate to invite */ + delegate: string; + + /** Workspaces to invite the delegate into. These have to be loaded, since an unavailable workspace cannot be invited into. */ + policies: Policy[]; + + /** The current user, on whose behalf the invitations are sent */ + inviter: CurrentUser; + + translate: LocalizedTranslate; + formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; +}; + +/** + * Adds a vacation delegate as a member of every given workspace, one invitation per workspace. Workspaces the + * current user does not administer are untouched here and are left for the backend to email their admins about. + */ +function inviteVacationDelegateToWorkspaces({delegate, policies, inviter, translate, formatPhoneNumber}: InviteVacationDelegateToWorkspacesOptions) { + // The delegate may have been picked from the selector without existing in personal details yet, so fall back to an optimistic accountID. + const knownDelegateAccountID = getKnownAccountIDByLogin(delegate); + const delegateAccountID = knownDelegateAccountID ?? generateAccountID(delegate); + const invitedEmailsToAccountIDs = {[delegate]: delegateAccountID}; + const isNewDelegate = knownDelegateAccountID === undefined; + const personalDetailsOnyxData = getPersonalDetailsOnyxDataForOptimisticUsers( + isNewDelegate ? [addSMSDomainIfPhoneNumber(delegate)] : [], + isNewDelegate ? [delegateAccountID] : [], + formatPhoneNumber, + ); + + const policyExpenseChatReportActions: Record = {}; + for (const policy of policies) { + const existingChatReportID = getPolicyExpenseChat(delegateAccountID, policy.id)?.reportID; + if (!existingChatReportID) { + continue; + } + policyExpenseChatReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${existingChatReportID}`] = getAllReportActions(existingChatReportID); + } + + for (const [index, policy] of policies.entries()) { + const isLastInvite = index === policies.length - 1; + addMembersToWorkspace( + invitedEmailsToAccountIDs, + // Writes resolve in queue order, so only the last invitation may clean the optimistic delegate up from under the ones still in flight. + isLastInvite ? personalDetailsOnyxData : {optimisticData: personalDetailsOnyxData.optimisticData}, + `# ${inviter.displayName ?? ''} invited you to ${policy.name}\n\n${translate('workspace.common.welcomeNote')}`, + policy, + Object.values(getMemberAccountIDsForWorkspace(policy.employeeList, false, false)), + CONST.POLICY.ROLE.USER, + inviter, + policyExpenseChatReportActions, + ); + } +} + +export {setVacationDelegate, deleteVacationDelegate, clearVacationDelegateError, inviteVacationDelegateToWorkspaces}; diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx deleted file mode 100644 index 7f3e39229bc6..000000000000 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage.tsx +++ /dev/null @@ -1,267 +0,0 @@ -/** - * RHP step shown when a vacation delegate is missing from workspaces: lets the user invite them into the - * workspaces they admin, skip, or just confirm the delegate. - */ -import WorkspaceAvatar from '@components/Avatar/WorkspaceAvatar'; -import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; -import Button from '@components/ButtonComposed'; -import FixedFooter from '@components/FixedFooter'; -import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItem from '@components/MenuItem'; -import RenderHTML from '@components/RenderHTML'; -import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; -import Section from '@components/Section'; - -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import {addMembersToWorkspace} from '@libs/actions/Policy/Member'; -import {clearVacationDelegateError, setVacationDelegate} from '@libs/actions/VacationDelegate'; -import Navigation from '@libs/Navigation/Navigation'; -import {getAccountIDsByLogins, getNewAccountIDsAndLogins, getPersonalDetailsOnyxDataForOptimisticUsers} from '@libs/PersonalDetailsUtils'; -import {getMemberAccountIDsForWorkspace} from '@libs/PolicyUtils'; -import {getAllPolicyExpenseChatReportActions} from '@libs/ReportUtils'; - -import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import {createPoliciesByIDsSelector} from '@src/selectors/Policy'; -import type {VacationDelegatePolicyDiff} from '@src/types/onyx'; -import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; - -import type {NavigationAction} from '@react-navigation/native'; - -import {useNavigation, usePreventRemove} from '@react-navigation/native'; -import {Str} from 'expensify-common'; -import React, {useRef, useState} from 'react'; -import {View} from 'react-native'; - -type ScreenInput = { - delegate: string; - policyDiff: VacationDelegatePolicyDiff; -}; - -function VacationDelegateMissingWorkspacesPage() { - const styles = useThemeStyles(); - const {translate, formatPhoneNumber} = useLocalize(); - const navigation = useNavigation(); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const currentUser = { - accountID: currentUserPersonalDetails.accountID, - displayName: currentUserPersonalDetails.displayName, - email: currentUserPersonalDetails.email, - avatar: currentUserPersonalDetails.avatar, - }; - - const [vacationDelegate, vacationDelegateMetadata] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); - - const [submittedInput, setSubmittedInput] = useState(); - - const creator = currentUserPersonalDetails.login ?? ''; - const delegate = submittedInput?.delegate ?? vacationDelegate?.delegate ?? ''; - const previousDelegate = vacationDelegate?.previousDelegate; - const policyDiff = submittedInput?.policyDiff ?? vacationDelegate?.policyDiff; - const adminPolicies = policyDiff?.adminPolicies ?? []; - const nonAdminPolicies = policyDiff?.nonAdminPolicies ?? []; - - const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createPoliciesByIDsSelector([...adminPolicies, ...nonAdminPolicies])}); - const [allPersonalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); - const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); - const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); - - const isSubmittingRef = useRef(false); - - usePreventRemove(!!policyDiff, ({data}: {data: {action: NavigationAction}}) => { - if (!isSubmittingRef.current && policyDiff) { - setSubmittedInput({delegate, policyDiff}); - clearVacationDelegateError(previousDelegate); - } - - navigation.dispatch(data.action); - }); - const canInvite = adminPolicies.length > 0; - const hasUnresolvedAdminPolicy = adminPolicies.some((policyID) => !policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.id); - - if (!submittedInput && isLoadingOnyxValue(vacationDelegateMetadata)) { - return ; - } - - if (!policyDiff) { - return ; - } - - const renderPolicyMenuItems = (policyIDs: string[]) => - policyIDs.map((policyID, index) => { - const title = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.name ?? translate('workspace.common.unavailable'); - return ( - - {/* MenuItem.Root's built-in padding is ph5 (20px). mhn1 pulls it back to ph4 (16px) to align with the section title */} - - - - - - - - {title} - {translate('workspace.common.workspace')} - - - - - - ); - }); - - const submit = () => { - setSubmittedInput({delegate, policyDiff}); - setVacationDelegate({creator, delegate, currentDelegate: previousDelegate, shouldOverridePolicyDiffWarning: true}); - Navigation.goBack(ROUTES.SETTINGS_STATUS); - }; - - const submitOnce = () => { - if (isSubmittingRef.current) { - return; - } - - isSubmittingRef.current = true; - submit(); - }; - - const invite = () => { - if (isSubmittingRef.current || hasUnresolvedAdminPolicy) { - return; - } - - isSubmittingRef.current = true; - - // The delegate may have been picked from the selector without existing in personal details yet, so fall back to an optimistic accountID. - const [delegateAccountID] = getAccountIDsByLogins([delegate]); - const invitedEmailsToAccountIDs = {[delegate]: delegateAccountID}; - const {newAccountIDs, newLogins} = getNewAccountIDsAndLogins(invitedEmailsToAccountIDs, allPersonalDetails); - const personalDetailsOnyxData = getPersonalDetailsOnyxDataForOptimisticUsers(newLogins, newAccountIDs, formatPhoneNumber); - const policyExpenseChatReportActions = getAllPolicyExpenseChatReportActions(allReports, allReportActions); - - for (const [index, policyID] of adminPolicies.entries()) { - const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; - const isLastInvite = index === adminPolicies.length - 1; - addMembersToWorkspace( - invitedEmailsToAccountIDs, - isLastInvite ? personalDetailsOnyxData : {optimisticData: personalDetailsOnyxData.optimisticData}, - `# ${currentUserPersonalDetails.displayName ?? ''} invited you to ${policy?.name ?? ''}\n\n${translate('workspace.common.welcomeNote')}`, - policy, - Object.values(getMemberAccountIDsForWorkspace(policy?.employeeList, false, false)), - CONST.POLICY.ROLE.USER, - currentUser, - policyExpenseChatReportActions, - ); - } - - // Workspaces the current user does not administer are untouched here and still need their own admins emailed. - submit(); - }; - - // Format SMS delegates as phone numbers rather than raw @expensify.sms logins, and escape the result since this copy is rendered as HTML. - const escapedDelegate = Str.htmlEncode(formatPhoneNumber(delegate)); - let introCopy: string; - if (!canInvite) { - introCopy = translate('statusPage.vacationDelegate.notAMemberAdminsWillBeAsked', escapedDelegate); - } else if (nonAdminPolicies.length > 0) { - introCopy = translate('statusPage.vacationDelegate.notAMemberMixed', escapedDelegate); - } else { - introCopy = translate('statusPage.vacationDelegate.notAMemberInviteThemNow', escapedDelegate); - } - - const policySections = ( - - {nonAdminPolicies.length > 0 && ( -
- {renderPolicyMenuItems(nonAdminPolicies)} -
- )} - {adminPolicies.length > 0 && ( -
0 ? styles.mt5 : undefined]} - > - {renderPolicyMenuItems(adminPolicies)} -
- )} -
- ); - - return ( - - Navigation.goBack(ROUTES.SETTINGS_VACATION_DELEGATE)} - /> - - - - - - {policySections} - - - {canInvite ? ( - <> - - - - ) : ( - - )} - - - - ); -} - -VacationDelegateMissingWorkspacesPage.displayName = 'VacationDelegateMissingWorkspacesPage'; - -export default VacationDelegateMissingWorkspacesPage; diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesFooter.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesFooter.tsx new file mode 100644 index 000000000000..673809bf7cf8 --- /dev/null +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesFooter.tsx @@ -0,0 +1,65 @@ +import Button from '@components/ButtonComposed'; +import FixedFooter from '@components/FixedFooter'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +type MissingWorkspacesFooterProps = { + /** The current user administers some of the missing workspaces, so they are offered the invite */ + canInvite: boolean; + + /** One of the workspaces to invite into has not loaded, so the invite cannot be built yet */ + isInviteDisabled: boolean; + + onInvite: () => void; + + /** Sets the delegate without sending any invite, backing both Skip and Confirm */ + onSkip: () => void; +}; + +function MissingWorkspacesFooter({canInvite, isInviteDisabled, onInvite, onSkip}: MissingWorkspacesFooterProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + if (!canInvite) { + return ( + + + + ); + } + + return ( + + + + + ); +} + +MissingWorkspacesFooter.displayName = 'MissingWorkspacesFooter'; + +export default MissingWorkspacesFooter; diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesIntro.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesIntro.tsx new file mode 100644 index 000000000000..c9e0f724f9e2 --- /dev/null +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesIntro.tsx @@ -0,0 +1,45 @@ +import RenderHTML from '@components/RenderHTML'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {Str} from 'expensify-common'; +import React from 'react'; +import {View} from 'react-native'; + +type MissingWorkspacesIntroProps = { + /** Login of the delegate the copy is about */ + delegate: string; + + /** The current user administers some of the missing workspaces, so they can invite the delegate themselves */ + hasAdminWorkspaces: boolean; + + /** Some of the missing workspaces are administered by somebody else, whose admins the backend emails instead */ + hasNonAdminWorkspaces: boolean; +}; + +function MissingWorkspacesIntro({delegate, hasAdminWorkspaces, hasNonAdminWorkspaces}: MissingWorkspacesIntroProps) { + const styles = useThemeStyles(); + const {translate, formatPhoneNumber} = useLocalize(); + + // Format SMS delegates as phone numbers rather than raw @expensify.sms logins, and escape the result since this copy is rendered as HTML. + const escapedDelegate = Str.htmlEncode(formatPhoneNumber(delegate)); + let copy: string; + if (!hasAdminWorkspaces) { + copy = translate('statusPage.vacationDelegate.notAMemberAdminsWillBeAsked', escapedDelegate); + } else if (hasNonAdminWorkspaces) { + copy = translate('statusPage.vacationDelegate.notAMemberMixed', escapedDelegate); + } else { + copy = translate('statusPage.vacationDelegate.notAMemberInviteThemNow', escapedDelegate); + } + + return ( + + + + ); +} + +MissingWorkspacesIntro.displayName = 'MissingWorkspacesIntro'; + +export default MissingWorkspacesIntro; diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/WorkspaceRow.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/WorkspaceRow.tsx new file mode 100644 index 000000000000..6a799edd9b4b --- /dev/null +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/WorkspaceRow.tsx @@ -0,0 +1,52 @@ +import WorkspaceAvatar from '@components/Avatar/WorkspaceAvatar'; +import MenuItem from '@components/MenuItem'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import React from 'react'; +import {View} from 'react-native'; + +type WorkspaceRowProps = { + policyID: string; + + /** Undefined when the workspace is not available to the current user */ + policy: OnyxEntry; + + /** The last row of a section skips the separator */ + shouldShowSeparator: boolean; +}; + +function WorkspaceRow({policyID, policy, shouldShowSeparator}: WorkspaceRowProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const title = policy?.name ?? translate('workspace.common.unavailable'); + + return ( + + + + + + + + {title} + {translate('workspace.common.workspace')} + + + + + ); +} + +WorkspaceRow.displayName = 'WorkspaceRow'; + +export default WorkspaceRow; diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/WorkspaceSection.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/WorkspaceSection.tsx new file mode 100644 index 000000000000..ed827ad5e427 --- /dev/null +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/WorkspaceSection.tsx @@ -0,0 +1,50 @@ +import Section from '@components/Section'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxCollection} from 'react-native-onyx'; + +import React from 'react'; + +import WorkspaceRow from './WorkspaceRow'; + +type WorkspaceSectionProps = { + title: string; + + /** Workspaces to list, in the order the policy diff returned them. Nothing is rendered when empty. */ + policyIDs: string[]; + + policies: OnyxCollection; +}; + +function WorkspaceSection({title, policyIDs, policies}: WorkspaceSectionProps) { + const styles = useThemeStyles(); + + if (policyIDs.length === 0) { + return null; + } + + return ( +
+ {policyIDs.map((policyID, index) => ( + + ))} +
+ ); +} + +WorkspaceSection.displayName = 'WorkspaceSection'; + +export default WorkspaceSection; diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx new file mode 100644 index 000000000000..d15f8e7ffb1c --- /dev/null +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx @@ -0,0 +1,166 @@ +/** + * RHP step shown when a vacation delegate is missing from workspaces: lets the user invite them into the + * workspaces they admin, skip, or just confirm the delegate. + */ +import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; + +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {clearVacationDelegateError, inviteVacationDelegateToWorkspaces, setVacationDelegate} from '@libs/actions/VacationDelegate'; +import Navigation from '@libs/Navigation/Navigation'; + +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import {createPoliciesByIDsSelector} from '@src/selectors/Policy'; +import type {Policy, VacationDelegatePolicyDiff} from '@src/types/onyx'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +import type {NavigationAction} from '@react-navigation/native'; + +import {useNavigation, usePreventRemove} from '@react-navigation/native'; +import React, {useRef, useState} from 'react'; + +import MissingWorkspacesFooter from './MissingWorkspacesFooter'; +import MissingWorkspacesIntro from './MissingWorkspacesIntro'; +import WorkspaceSection from './WorkspaceSection'; + +type ScreenInput = { + delegate: string; + policyDiff: VacationDelegatePolicyDiff; +}; + +function VacationDelegateMissingWorkspacesPage() { + const styles = useThemeStyles(); + const {translate, formatPhoneNumber} = useLocalize(); + const navigation = useNavigation(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + + const [vacationDelegate, vacationDelegateMetadata] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + + const [submittedInput, setSubmittedInput] = useState(); + + const creator = currentUserPersonalDetails.login ?? ''; + const delegate = submittedInput?.delegate ?? vacationDelegate?.delegate ?? ''; + const previousDelegate = vacationDelegate?.previousDelegate; + const policyDiff = submittedInput?.policyDiff ?? vacationDelegate?.policyDiff; + const adminPolicies = policyDiff?.adminPolicies ?? []; + const nonAdminPolicies = policyDiff?.nonAdminPolicies ?? []; + + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createPoliciesByIDsSelector([...adminPolicies, ...nonAdminPolicies])}); + + // Only loaded workspaces can be invited into, so a missing one has to block the invite rather than be dropped from it. + const adminWorkspaces = adminPolicies.map((policyID) => policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]).filter((policy): policy is Policy => !!policy?.id); + const canInvite = adminPolicies.length > 0; + const hasUnresolvedAdminPolicy = adminWorkspaces.length !== adminPolicies.length; + + const isSubmittingRef = useRef(false); + + usePreventRemove(!!policyDiff, ({data}: {data: {action: NavigationAction}}) => { + if (!isSubmittingRef.current && policyDiff) { + setSubmittedInput({delegate, policyDiff}); + clearVacationDelegateError(previousDelegate); + } + + navigation.dispatch(data.action); + }); + + if (!submittedInput && isLoadingOnyxValue(vacationDelegateMetadata)) { + return ; + } + + if (!policyDiff) { + return ; + } + + const submit = () => { + setSubmittedInput({delegate, policyDiff}); + setVacationDelegate({creator, delegate, currentDelegate: previousDelegate, shouldOverridePolicyDiffWarning: true}); + Navigation.goBack(ROUTES.SETTINGS_STATUS); + }; + + const submitOnce = () => { + if (isSubmittingRef.current) { + return; + } + + isSubmittingRef.current = true; + submit(); + }; + + const invite = () => { + if (isSubmittingRef.current || hasUnresolvedAdminPolicy) { + return; + } + + isSubmittingRef.current = true; + + inviteVacationDelegateToWorkspaces({ + delegate, + policies: adminWorkspaces, + inviter: { + accountID: currentUserPersonalDetails.accountID, + displayName: currentUserPersonalDetails.displayName, + email: currentUserPersonalDetails.email, + avatar: currentUserPersonalDetails.avatar, + }, + translate, + formatPhoneNumber, + }); + + submit(); + }; + + return ( + + Navigation.goBack(ROUTES.SETTINGS_VACATION_DELEGATE)} + /> + + + 0} + /> + + + + + + + ); +} + +VacationDelegateMissingWorkspacesPage.displayName = 'VacationDelegateMissingWorkspacesPage'; + +export default VacationDelegateMissingWorkspacesPage; diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx index 4f1698958689..d87256cec670 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx @@ -76,7 +76,14 @@ function VacationDelegatePage() { Navigation.goBack(ROUTES.SETTINGS_STATUS); }) - .catch(() => showErrorModal(currentDelegate)) + .catch(() => { + if (!navigation.isFocused()) { + clearVacationDelegateError(currentDelegate); + return; + } + + showErrorModal(currentDelegate); + }) .finally(() => { isSelectingRef.current = false; }); diff --git a/src/styles/utils/spacing.ts b/src/styles/utils/spacing.ts index 941a26738619..7437eee3ca23 100644 --- a/src/styles/utils/spacing.ts +++ b/src/styles/utils/spacing.ts @@ -75,10 +75,6 @@ export default { marginHorizontal: 32, }, - mhn1: { - marginHorizontal: -4, - }, - mhn2: { marginHorizontal: -8, }, diff --git a/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx index 6f0500d13332..b37f9b867d82 100644 --- a/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx +++ b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx @@ -42,6 +42,10 @@ const MEMBER_POLICY_ID = 'memberPolicy'; const ADMIN_POLICY_ID = 'adminPolicy'; const SECOND_ADMIN_POLICY_ID = 'secondAdminPolicy'; +const DELEGATE_ACCOUNT_ID = 2; +const EXISTING_CHAT_REPORT_ID = 'existingExpenseChat'; +const ARCHIVED_EXPENSE_REPORT_ID = 'archivedExpenseReport'; + const Stack = createPlatformStackNavigator(); let mockPreventRemoveCallback: Parameters[1] | undefined; @@ -97,6 +101,32 @@ function hasPersonalDetailsUpdate(updates: Array<{key: string}> | undefined) { return !!updates?.some((update) => update.key === ONYXKEYS.PERSONAL_DETAILS_LIST); } +/** + * Seeds a policy expense chat the delegate already owns in ADMIN_POLICY_ID, holding a report preview of an + * archived expense report. Re-inviting them has to un-archive that preview's child report, which is only + * reachable through the report actions the page hands to addMembersToWorkspace. + */ +async function seedExistingDelegateExpenseChat() { + await act(async () => { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[DELEGATE_ACCOUNT_ID]: {accountID: DELEGATE_ACCOUNT_ID, login: DELEGATE_EMAIL}}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${EXISTING_CHAT_REPORT_ID}`, { + reportID: EXISTING_CHAT_REPORT_ID, + policyID: ADMIN_POLICY_ID, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + ownerAccountID: DELEGATE_ACCOUNT_ID, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${EXISTING_CHAT_REPORT_ID}`, { + previewAction: { + reportActionID: 'previewAction', + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + childReportID: ARCHIVED_EXPENSE_REPORT_ID, + created: '2024-01-01 00:00:00.000', + }, + }); + }); + await waitForBatchedUpdatesWithAct(); +} + function VacationDelegateSelectionPage() { return Delegate selection; } @@ -280,6 +310,29 @@ describe('VacationDelegateMissingWorkspacesPage', () => { ); }); + it('reuses the expense chat the delegate already owns and un-archives its report previews', async () => { + await seedExistingDelegateExpenseChat(); + await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID], nonAdminPolicies: []}); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.invite')})); + await waitForBatchedUpdatesWithAct(); + + // The existing chat is reused rather than recreated... + expect(apiWriteSpy).toHaveBeenCalledWith( + WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, + // require('@libs/API') is untyped (any), which taints the inferred type of these matchers; the assertion itself is fine. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.objectContaining({reportCreationData: expect.stringContaining(EXISTING_CHAT_REPORT_ID)}), + expect.anything(), + ); + + // ...and its preview's expense report is un-archived, which only happens when that chat's report actions reach the invite. + const [inviteOnyxData] = getInviteOnyxData(apiWriteSpy.mock.calls); + expect(inviteOnyxData.optimisticData?.some((update) => update.key === `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${ARCHIVED_EXPENSE_REPORT_ID}`)).toBe(true); + }); + it('lets only the last queued invite clean up the delegate optimistic personal details', async () => { await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID, SECOND_ADMIN_POLICY_ID], nonAdminPolicies: []}); renderPage(); From 671cd1c9bc18a4ea9e98552c69156415256c1918 Mon Sep 17 00:00:00 2001 From: jakubstec Date: Wed, 9 Sep 2026 16:40:14 +0200 Subject: [PATCH 14/23] fix: don't disable policy diff rhp when offline, allow to optimistically set a vacation delegate --- .../index.tsx | 53 +++++++++---------- ...ationDelegateMissingWorkspacesPageTest.tsx | 20 +++++++ 2 files changed, 45 insertions(+), 28 deletions(-) diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx index d15f8e7ffb1c..0bbe83e3f7e1 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx @@ -2,7 +2,6 @@ * RHP step shown when a vacation delegate is missing from workspaces: lets the user invite them into the * workspaces they admin, skip, or just confirm the delegate. */ -import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -129,34 +128,32 @@ function VacationDelegateMissingWorkspacesPage() { title={translate('common.vacationDelegate')} onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_VACATION_DELEGATE)} /> - - - 0} - /> - - - - + 0} /> - + + + + ); } diff --git a/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx index b37f9b867d82..835955d90fe4 100644 --- a/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx +++ b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx @@ -292,6 +292,26 @@ describe('VacationDelegateMissingWorkspacesPage', () => { expect(apiSideEffectSpy).not.toHaveBeenCalledWith(SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, expect.anything(), expect.anything()); }); + it('stays usable offline instead of blocking the step, since every button it offers is a persisted write', async () => { + await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID], nonAdminPolicies: []}); + await act(async () => { + await Onyx.merge(ONYXKEYS.NETWORK, {shouldForceOffline: true}); + }); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // The delegate is already optimistically set at this point, so blocking the page would strand the user + // with an unconfirmed change until they reconnect. The offline indicator still shows, only the blocking view must not. + expect(screen.queryByText(TestHelper.translateLocal('common.thisFeatureRequiresInternet'))).not.toBeOnTheScreen(); + expect(screen.getByText('Admin Workspace')).toBeOnTheScreen(); + + fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.invite')})); + await waitForBatchedUpdatesWithAct(); + + expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, expect.objectContaining({policyID: ADMIN_POLICY_ID}), expect.anything()); + expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.SET_VACATION_DELEGATE, expect.objectContaining({overridePolicyDiffWarning: true}), expect.anything()); + }); + it('invites a delegate that has no personal details entry yet using an optimistic accountID', async () => { await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID], nonAdminPolicies: []}); renderPage(); From 8b54c271ff27e21f1cd97dd113dee7ec665897bb Mon Sep 17 00:00:00 2001 From: jakubstec Date: Wed, 9 Sep 2026 18:33:13 +0200 Subject: [PATCH 15/23] fix: hide RBR after first setVacationDelegate() request --- src/libs/UserUtils.ts | 3 +- src/libs/actions/VacationDelegate.ts | 2 ++ src/libs/getVacationDelegateErrors.ts | 23 ++++++++++++ .../Profile/CustomStatus/StatusPage.tsx | 3 +- src/pages/settings/Profile/ProfilePage.tsx | 3 +- tests/actions/VacationDelegateTest.ts | 35 +++++++++++++++++++ .../libs/getVacationDelegateErrors.test.ts | 33 +++++++++++++++++ 7 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 src/libs/getVacationDelegateErrors.ts create mode 100644 tests/unit/libs/getVacationDelegateErrors.test.ts diff --git a/src/libs/UserUtils.ts b/src/libs/UserUtils.ts index 5ac3972de83f..4a02ee5c6728 100644 --- a/src/libs/UserUtils.ts +++ b/src/libs/UserUtils.ts @@ -11,6 +11,7 @@ import {Str} from 'expensify-common'; import type {AvatarSource} from './UserAvatarUtils'; +import getVacationDelegateErrors from './getVacationDelegateErrors'; import hashCode from './hashCode'; type LoginListIndicator = ValueOf | undefined; @@ -193,7 +194,7 @@ function getProfilePageBrickRoadIndicator( shouldShowAddHomeAddress = false, ): LoginListIndicator { const hasPhoneNumberError = !!privatePersonalDetails?.errorFields?.phoneNumber; - if (hasLoginListError(loginList) || hasPhoneNumberError || !isEmptyObject(vacationDelegate?.errors)) { + if (hasLoginListError(loginList) || hasPhoneNumberError || !isEmptyObject(getVacationDelegateErrors(vacationDelegate))) { return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; } if (hasLoginListInfo(loginList, email) || shouldShowAddHomeAddress) { diff --git a/src/libs/actions/VacationDelegate.ts b/src/libs/actions/VacationDelegate.ts index c3599ec80a65..ab4b92f7d5dd 100644 --- a/src/libs/actions/VacationDelegate.ts +++ b/src/libs/actions/VacationDelegate.ts @@ -100,6 +100,7 @@ async function setVacationDelegate({creator, delegate, currentDelegate, shouldOv if (response?.jsonCode === CONST.JSON_CODE.POLICY_DIFF_WARNING && response.data?.policyDiff) { // Keep the optimistic delegate so the flow can continue into the missing workspaces step. Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { + errors: null, policyDiff: response.data.policyDiff, pendingAction: null, }); @@ -149,6 +150,7 @@ function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { creator, delegate, errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('statusPage.vacationDelegateError'), + pendingAction: null, }, }, ]; diff --git a/src/libs/getVacationDelegateErrors.ts b/src/libs/getVacationDelegateErrors.ts new file mode 100644 index 000000000000..cd899c8e2044 --- /dev/null +++ b/src/libs/getVacationDelegateErrors.ts @@ -0,0 +1,23 @@ +import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; +import type VacationDelegate from '@src/types/onyx/VacationDelegate'; + +import type {OnyxEntry} from 'react-native-onyx'; + +/** + * Returns the vacation delegate errors worth showing, which means only those left behind once the change has settled. + * + * Auth answers a delegate who is missing from some of the vacationer's workspaces with a 305 policy diff warning, and + * ships an `errors` payload in its onyxData even though the warning is a confirmation prompt rather than a failure. + * That payload lands while the optimistic `pendingAction` is still set, so gating on it keeps the prompt from + * flashing a red brick road. Every write that reports a real failure clears `pendingAction` alongside the errors, + * which is what keeps genuine failures visible — a new one has to keep doing that. + */ +function getVacationDelegateErrors(vacationDelegate: OnyxEntry): OnyxCommon.Errors | undefined { + if (vacationDelegate?.pendingAction) { + return undefined; + } + + return vacationDelegate?.errors; +} + +export default getVacationDelegateErrors; diff --git a/src/pages/settings/Profile/CustomStatus/StatusPage.tsx b/src/pages/settings/Profile/CustomStatus/StatusPage.tsx index 812c1860616e..df4714826bc9 100644 --- a/src/pages/settings/Profile/CustomStatus/StatusPage.tsx +++ b/src/pages/settings/Profile/CustomStatus/StatusPage.tsx @@ -27,6 +27,7 @@ import {isMobileChrome} from '@libs/Browser'; import DateUtils from '@libs/DateUtils'; import focusAfterModalClose from '@libs/focusAfterModalClose'; import focusComposerWithDelay from '@libs/focusComposerWithDelay'; +import getVacationDelegateErrors from '@libs/getVacationDelegateErrors'; import Navigation from '@libs/Navigation/Navigation'; import {clearCustomStatus, clearDraftCustomStatus, updateCustomStatus, updateDraftCustomStatus} from '@userActions/User'; @@ -255,7 +256,7 @@ function StatusPage() { {translate('statusPage.setVacationDelegate')} clearVacationDelegateError(vacationDelegate?.previousDelegate)} onPress={() => Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE)} diff --git a/src/pages/settings/Profile/ProfilePage.tsx b/src/pages/settings/Profile/ProfilePage.tsx index 46f5a38280e2..5b1eaffb827b 100755 --- a/src/pages/settings/Profile/ProfilePage.tsx +++ b/src/pages/settings/Profile/ProfilePage.tsx @@ -26,6 +26,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import getVacationDelegateErrors from '@libs/getVacationDelegateErrors'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -125,7 +126,7 @@ function ProfilePage() { description: translate('statusPage.status'), title: emojiCode ? `${emojiCode} ${currentUserPersonalDetails?.status?.text ?? ''}` : '', pageRoute: ROUTES.SETTINGS_STATUS, - brickRoadIndicator: isEmptyObject(vacationDelegate?.errors) ? undefined : CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, + brickRoadIndicator: isEmptyObject(getVacationDelegateErrors(vacationDelegate)) ? undefined : CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, testID: 'status-menu-item', sentryLabel: CONST.SENTRY_LABEL.SETTINGS_PROFILE.STATUS, }, diff --git a/tests/actions/VacationDelegateTest.ts b/tests/actions/VacationDelegateTest.ts index 629f8180558c..65024f3e6566 100644 --- a/tests/actions/VacationDelegateTest.ts +++ b/tests/actions/VacationDelegateTest.ts @@ -1,5 +1,6 @@ import {clearVacationDelegateError, setVacationDelegate} from '@libs/actions/VacationDelegate'; import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import getVacationDelegateErrors from '@libs/getVacationDelegateErrors'; import CONST from '@src/CONST'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; @@ -143,6 +144,40 @@ describe('actions/VacationDelegate', () => { jest.restoreAllMocks(); }); + it('clears the errors payload the backend ships in onyxData alongside a 305', async () => { + const policyDiff = {adminPolicies: [], nonAdminPolicies: ['79705898949FB240']}; + + jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(async () => { + await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { + creator: 'admin@test.com', + delegate: 'delegate@test.com', + previousDelegate: 'old@test.com', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + errors: null, + }); + // Auth returns 305 so the client can prompt, but the same response carries an errors payload in + // onyxData, which the API layer applies before the caller ever sees the response. + await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { + // an ID map key is not a name! + // eslint-disable-next-line @typescript-eslint/naming-convention + errors: {1788970253939928: "Vacation delegate is not part of all of vacationer's policies."}, + }); + return {jsonCode: CONST.JSON_CODE.POLICY_DIFF_WARNING, data: {policyDiff}}; + }); + + await setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', currentDelegate: 'old@test.com'}); + await waitForBatchedUpdates(); + + // The confirmation step must not leave a red brick road behind on the profile page. While the payload is + // in flight getVacationDelegateErrors hides it, which getVacationDelegateErrors.test.ts covers. + const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + expect(getVacationDelegateErrors(vacationDelegate)).toBeUndefined(); + expect(vacationDelegate?.policyDiff).toEqual(policyDiff); + expect(vacationDelegate?.delegate).toBe('delegate@test.com'); + + jest.restoreAllMocks(); + }); + it('applies the failureData it could not attach when the response fails, since the caller may have already navigated away', async () => { const response = {jsonCode: CONST.JSON_CODE.EXP_ERROR, message: 'Nope'}; jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(async () => { diff --git a/tests/unit/libs/getVacationDelegateErrors.test.ts b/tests/unit/libs/getVacationDelegateErrors.test.ts new file mode 100644 index 000000000000..77c76de9bfd0 --- /dev/null +++ b/tests/unit/libs/getVacationDelegateErrors.test.ts @@ -0,0 +1,33 @@ +/** + * These tests verify that a vacation delegate error only counts once the change it belongs to has settled, so the + * errors payload Auth attaches to a 305 policy diff warning never reaches the profile page's red brick road. + */ +import getVacationDelegateErrors from '@libs/getVacationDelegateErrors'; + +import CONST from '@src/CONST'; + +// an ID map key is not a name! +// eslint-disable-next-line @typescript-eslint/naming-convention +const ERRORS = {1788970253939928: "Vacation delegate is not part of all of vacationer's policies."}; + +describe('getVacationDelegateErrors', () => { + it('hides errors that arrive while the change is still in flight', () => { + // This is the 305 policy diff warning: Auth ships an errors payload with a prompt that is not a failure, + // and it lands while the optimistic pendingAction is still set. + expect(getVacationDelegateErrors({errors: ERRORS, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE})).toBeUndefined(); + }); + + it('hides errors while a delete is still in flight', () => { + expect(getVacationDelegateErrors({errors: ERRORS, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE})).toBeUndefined(); + }); + + it('surfaces errors once the change has settled, which is how every real failure is written', () => { + expect(getVacationDelegateErrors({errors: ERRORS, pendingAction: null})).toEqual(ERRORS); + expect(getVacationDelegateErrors({errors: ERRORS})).toEqual(ERRORS); + }); + + it('returns nothing when there is no error to show', () => { + expect(getVacationDelegateErrors({delegate: 'delegate@test.com'})).toBeUndefined(); + expect(getVacationDelegateErrors(undefined)).toBeUndefined(); + }); +}); From 35cac1ff226777171bcab9e363f5b9b4ca218b01 Mon Sep 17 00:00:00 2001 From: jakubstec Date: Mon, 14 Sep 2026 11:03:08 +0200 Subject: [PATCH 16/23] fix: don't show unsaved delegate as saved after abandoning missing workspaces step --- src/libs/actions/VacationDelegate.ts | 16 ++++- .../MissingWorkspacesFooter.tsx | 4 +- .../index.tsx | 51 ++++++--------- src/types/onyx/VacationDelegate.ts | 6 ++ tests/actions/VacationDelegateTest.ts | 65 +++++++++++++++++-- ...ationDelegateMissingWorkspacesPageTest.tsx | 10 +-- 6 files changed, 108 insertions(+), 44 deletions(-) diff --git a/src/libs/actions/VacationDelegate.ts b/src/libs/actions/VacationDelegate.ts index ab4b92f7d5dd..326b21648d1d 100644 --- a/src/libs/actions/VacationDelegate.ts +++ b/src/libs/actions/VacationDelegate.ts @@ -41,8 +41,9 @@ async function setVacationDelegate({creator, delegate, currentDelegate, shouldOv delegate, errors: null, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - previousDelegate: currentDelegate, + previousDelegate: currentDelegate ?? null, policyDiff: null, + pendingDelegate: null, }, }, ]; @@ -56,6 +57,7 @@ async function setVacationDelegate({creator, delegate, currentDelegate, shouldOv pendingAction: null, previousDelegate: null, policyDiff: null, + pendingDelegate: null, }, }, ]; @@ -98,9 +100,13 @@ async function setVacationDelegate({creator, delegate, currentDelegate, shouldOv const response = await API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, parameters, {optimisticData, successData}); if (response?.jsonCode === CONST.JSON_CODE.POLICY_DIFF_WARNING && response.data?.policyDiff) { - // Keep the optimistic delegate so the flow can continue into the missing workspaces step. + // The backend has not saved the pick, so put the saved delegate back and park the pick next to the policy diff for the + // missing workspaces step. This NVP is persisted, so a pick left in `delegate` would read as saved if that step were + // abandoned by killing the app, and the next attempt to remove it would fail because the backend never stored it. Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { errors: null, + delegate: currentDelegate ?? null, + pendingDelegate: delegate, policyDiff: response.data.policyDiff, pendingAction: null, }); @@ -127,6 +133,8 @@ function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { errors: null, previousDelegate: vacationDelegate?.delegate, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + policyDiff: null, + pendingDelegate: null, }, }, ]; @@ -138,6 +146,7 @@ function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { value: { errors: null, pendingAction: null, + previousDelegate: null, }, }, ]; @@ -159,12 +168,13 @@ function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { } function clearVacationDelegateError(previousDelegate?: string) { - return Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { + Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { errors: null, pendingAction: null, delegate: previousDelegate ?? null, previousDelegate: null, policyDiff: null, + pendingDelegate: null, }); } diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesFooter.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesFooter.tsx index 673809bf7cf8..aad369056aa4 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesFooter.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesFooter.tsx @@ -1,4 +1,4 @@ -import Button from '@components/ButtonComposed'; +import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import useLocalize from '@hooks/useLocalize'; @@ -17,7 +17,7 @@ type MissingWorkspacesFooterProps = { onInvite: () => void; - /** Sets the delegate without sending any invite, backing both Skip and Confirm */ + /** Sets the delegate without inviting them into the workspaces the user administers, backing both Skip and Confirm */ onSkip: () => void; }; diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx index 0bbe83e3f7e1..1bbfdf4c222d 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx @@ -48,7 +48,7 @@ function VacationDelegateMissingWorkspacesPage() { const [submittedInput, setSubmittedInput] = useState(); const creator = currentUserPersonalDetails.login ?? ''; - const delegate = submittedInput?.delegate ?? vacationDelegate?.delegate ?? ''; + const delegate = submittedInput?.delegate ?? vacationDelegate?.pendingDelegate ?? ''; const previousDelegate = vacationDelegate?.previousDelegate; const policyDiff = submittedInput?.policyDiff ?? vacationDelegate?.policyDiff; const adminPolicies = policyDiff?.adminPolicies ?? []; @@ -80,42 +80,31 @@ function VacationDelegateMissingWorkspacesPage() { return ; } - const submit = () => { - setSubmittedInput({delegate, policyDiff}); - setVacationDelegate({creator, delegate, currentDelegate: previousDelegate, shouldOverridePolicyDiffWarning: true}); - Navigation.goBack(ROUTES.SETTINGS_STATUS); - }; - - const submitOnce = () => { + const submit = (policiesToInvite?: Policy[]) => { if (isSubmittingRef.current) { return; } isSubmittingRef.current = true; - submit(); - }; - const invite = () => { - if (isSubmittingRef.current || hasUnresolvedAdminPolicy) { - return; + if (policiesToInvite?.length) { + inviteVacationDelegateToWorkspaces({ + delegate, + policies: policiesToInvite, + inviter: { + accountID: currentUserPersonalDetails.accountID, + displayName: currentUserPersonalDetails.displayName, + email: currentUserPersonalDetails.email, + avatar: currentUserPersonalDetails.avatar, + }, + translate, + formatPhoneNumber, + }); } - isSubmittingRef.current = true; - - inviteVacationDelegateToWorkspaces({ - delegate, - policies: adminWorkspaces, - inviter: { - accountID: currentUserPersonalDetails.accountID, - displayName: currentUserPersonalDetails.displayName, - email: currentUserPersonalDetails.email, - avatar: currentUserPersonalDetails.avatar, - }, - translate, - formatPhoneNumber, - }); - - submit(); + setSubmittedInput({delegate, policyDiff}); + setVacationDelegate({creator, delegate, currentDelegate: previousDelegate, shouldOverridePolicyDiffWarning: true}); + Navigation.goBack(ROUTES.SETTINGS_STATUS); }; return ( @@ -151,8 +140,8 @@ function VacationDelegateMissingWorkspacesPage() { submit(adminWorkspaces)} + onSkip={() => submit()} /> ); diff --git a/src/types/onyx/VacationDelegate.ts b/src/types/onyx/VacationDelegate.ts index de4e9fd1116d..565181bce169 100644 --- a/src/types/onyx/VacationDelegate.ts +++ b/src/types/onyx/VacationDelegate.ts @@ -25,6 +25,12 @@ type BaseVacationDelegate = { /** Workspaces the delegate is missing from, captured from the 305 response. Client-only. */ policyDiff?: VacationDelegatePolicyDiff; + + /** + * Delegate picked but not yet saved, because the 305 response left them waiting on the missing workspaces step. + * Kept apart from `delegate` so an abandoned step never makes an unsaved pick read as saved. Client-only. + */ + pendingDelegate?: string; }; /** Information about vacation delegate with offline feedback */ diff --git a/tests/actions/VacationDelegateTest.ts b/tests/actions/VacationDelegateTest.ts index 65024f3e6566..22458c59830e 100644 --- a/tests/actions/VacationDelegateTest.ts +++ b/tests/actions/VacationDelegateTest.ts @@ -1,4 +1,4 @@ -import {clearVacationDelegateError, setVacationDelegate} from '@libs/actions/VacationDelegate'; +import {clearVacationDelegateError, deleteVacationDelegate, setVacationDelegate} from '@libs/actions/VacationDelegate'; import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import getVacationDelegateErrors from '@libs/getVacationDelegateErrors'; @@ -104,7 +104,8 @@ describe('actions/VacationDelegate', () => { await waitForBatchedUpdates(); const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); - expect(vacationDelegate?.delegate).toBe('second@test.com'); + expect(vacationDelegate?.delegate).toBe('first@test.com'); + expect(vacationDelegate?.pendingDelegate).toBe('second@test.com'); expect(vacationDelegate?.policyDiff).toEqual(policyDiff); }); @@ -135,7 +136,8 @@ describe('actions/VacationDelegate', () => { const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); expect(vacationDelegate?.policyDiff).toEqual(policyDiff); expect(vacationDelegate?.pendingAction).toBeFalsy(); - expect(vacationDelegate?.delegate).toBe('delegate@test.com'); + expect(vacationDelegate?.pendingDelegate).toBe('delegate@test.com'); + expect(vacationDelegate?.delegate).toBe('old@test.com'); expect(vacationDelegate?.previousDelegate).toBe('old@test.com'); // An error at any point would flash a red brick road on the profile page, so it must never be written at all. @@ -173,7 +175,28 @@ describe('actions/VacationDelegate', () => { const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); expect(getVacationDelegateErrors(vacationDelegate)).toBeUndefined(); expect(vacationDelegate?.policyDiff).toEqual(policyDiff); - expect(vacationDelegate?.delegate).toBe('delegate@test.com'); + expect(vacationDelegate?.pendingDelegate).toBe('delegate@test.com'); + + jest.restoreAllMocks(); + }); + + it('clears the delegate on a 305 when there was no delegate saved before the pick', async () => { + jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(async () => { + await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { + creator: 'admin@test.com', + delegate: 'delegate@test.com', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + errors: null, + }); + return {jsonCode: CONST.JSON_CODE.POLICY_DIFF_WARNING, data: {policyDiff: {adminPolicies: ['1'], nonAdminPolicies: []}}}; + }); + + await setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com'}); + await waitForBatchedUpdates(); + + const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + expect(vacationDelegate?.delegate).toBeFalsy(); + expect(vacationDelegate?.pendingDelegate).toBe('delegate@test.com'); jest.restoreAllMocks(); }); @@ -249,6 +272,38 @@ describe('actions/VacationDelegate', () => { }); }); + describe('deleteVacationDelegate', () => { + it('does not leave the deleted delegate as the rollback target of the next pick', async () => { + const mockFetch = createGlobalFetchMock(); + global.fetch = mockFetch; + await Onyx.set(ONYXKEYS.SESSION, {email: 'admin@test.com', accountID: 1, authToken: 'testAuthToken'}); + await Onyx.set(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, {creator: 'admin@test.com', delegate: 'a@test.com'}); + await waitForBatchedUpdates(); + + deleteVacationDelegate({creator: 'admin@test.com', delegate: 'a@test.com'}); + await waitForBatchedUpdates(); + + const afterDelete = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + expect(afterDelete?.delegate).toBeFalsy(); + expect(afterDelete?.previousDelegate).toBeFalsy(); + + // Picking B now has nothing to roll back to, so a 305 followed by dismissing the step must not bring A back. + jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(async () => { + await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, {delegate: 'b@test.com', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}); + return {jsonCode: CONST.JSON_CODE.POLICY_DIFF_WARNING, data: {policyDiff: {adminPolicies: ['1'], nonAdminPolicies: []}}}; + }); + await setVacationDelegate({creator: 'admin@test.com', delegate: 'b@test.com', currentDelegate: afterDelete?.delegate}); + await waitForBatchedUpdates(); + + const afterPick = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + clearVacationDelegateError(afterPick?.previousDelegate); + await waitForBatchedUpdates(); + + expect((await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE))?.delegate).toBeFalsy(); + jest.restoreAllMocks(); + }); + }); + describe('clearVacationDelegateError', () => { it('clears errors, pendingAction, and policyDiff, and restores the previous delegate', async () => { const timestamp = 123; @@ -259,6 +314,7 @@ describe('actions/VacationDelegate', () => { pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, errors: {[timestamp]: 'Some error'}, policyDiff: {adminPolicies: ['1'], nonAdminPolicies: []}, + pendingDelegate: 'delegate@test.com', }); clearVacationDelegateError('old@test.com'); @@ -270,6 +326,7 @@ describe('actions/VacationDelegate', () => { expect(vacationDelegate?.errors).toBeFalsy(); expect(vacationDelegate?.pendingAction).toBeFalsy(); expect(vacationDelegate?.policyDiff).toBeFalsy(); + expect(vacationDelegate?.pendingDelegate).toBeFalsy(); }); }); }); diff --git a/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx index 835955d90fe4..72ff0bda5301 100644 --- a/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx +++ b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx @@ -164,11 +164,13 @@ function renderPage(shouldIncludeBackRoute = false) { async function seedVacationDelegate(policyDiff?: VacationDelegatePolicyDiff, delegate: string = DELEGATE_EMAIL) { // The real 305 policy-diff-warning response never writes NVP errors (see VacationDelegate.ts), so this - // page is only ever reached with errors already null. + // page is only ever reached with errors already null. It also puts the saved delegate back and parks the pick + // in pendingDelegate, so seed that shape. await act(async () => { await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { creator: CREATOR_EMAIL, - delegate, + delegate: PREVIOUS_DELEGATE_EMAIL, + pendingDelegate: delegate, previousDelegate: PREVIOUS_DELEGATE_EMAIL, policyDiff, }); @@ -435,7 +437,7 @@ describe('VacationDelegateMissingWorkspacesPage', () => { await waitForBatchedUpdatesWithAct(); const nextVacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); - expect(nextVacationDelegate?.delegate).toBe('next@example.com'); + expect(nextVacationDelegate?.pendingDelegate).toBe('next@example.com'); expect(nextVacationDelegate?.policyDiff).toEqual(nextPolicyDiff); }); @@ -474,7 +476,7 @@ describe('VacationDelegateMissingWorkspacesPage', () => { // The rollback runs from the route-removal handler, not from the back press, so nothing can flash while Navigation is still waiting on the transition. const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); - expect(vacationDelegate?.delegate).toBe(DELEGATE_EMAIL); + expect(vacationDelegate?.pendingDelegate).toBe(DELEGATE_EMAIL); expect(vacationDelegate?.policyDiff).not.toBeFalsy(); expect(screen.queryByText(TestHelper.translateLocal('notFound.notHere'))).not.toBeOnTheScreen(); expect(screen.getByText(DELEGATE_EMAIL)).toBeOnTheScreen(); From 1d8101889dcd73cecdaa2de17398a75248a29e7d Mon Sep 17 00:00:00 2001 From: jakubstec Date: Wed, 16 Sep 2026 10:40:18 +0200 Subject: [PATCH 17/23] fix: remove hardcoded welcome message --- src/languages/de.ts | 1 + src/languages/el.ts | 1 + src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + src/libs/actions/VacationDelegate.ts | 2 +- 12 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 387d146c1d99..ce588646dd24 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -4699,6 +4699,7 @@ ${amount} für ${merchant} – ${date}`, defaultDescription: 'Ein Ort für all Ihre Belege und Ausgaben.', descriptionHint: 'Teile Informationen über diesen Arbeitsbereich mit allen Mitgliedern.', welcomeNote: 'Bitte nutze Expensify, um deine Belege zur Erstattung einzureichen, danke!', + invitedYouToWorkspace: (inviterName: string, workspaceName: string) => `# ${inviterName} hat dich zu ${workspaceName} eingeladen`, subscription: 'Abonnement', markAsEntered: 'Als manuell erfasst markieren', markAsExported: 'Als exportiert markieren', diff --git a/src/languages/el.ts b/src/languages/el.ts index 0ed3ca89763c..ef9dca91300d 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -4774,6 +4774,7 @@ ${amount} για ${merchant} - ${date}`, defaultDescription: 'Ένα μέρος για όλες τις αποδείξεις και τα έξοδά σας.', descriptionHint: 'Κοινοποιήστε πληροφορίες σχετικά με αυτόν τον χώρο εργασίας σε όλα τα μέλη.', welcomeNote: 'Παρακαλούμε χρησιμοποιήστε το Expensify για να υποβάλετε τις αποδείξεις σας για αποζημίωση, ευχαριστούμε!', + invitedYouToWorkspace: (inviterName: string, workspaceName: string) => `# Ο/Η ${inviterName} σας προσκάλεσε στο ${workspaceName}`, subscription: 'Συνδρομή', markAsEntered: 'Σήμανση ως καταχωρισμένο χειροκίνητα', markAsExported: 'Επισήμανση ως εξαχθέν', diff --git a/src/languages/en.ts b/src/languages/en.ts index 6f863f9cea7b..7e1d38186a8e 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4822,6 +4822,7 @@ const translations = { defaultDescription: 'One place for all your receipts and expenses.', descriptionHint: 'Share information about this workspace with all members.', welcomeNote: 'Please use Expensify to submit your receipts for reimbursement, thanks!', + invitedYouToWorkspace: (inviterName: string, workspaceName: string) => `# ${inviterName} invited you to ${workspaceName}`, subscription: 'Subscription', markAsEntered: 'Mark as manually entered', markAsExported: 'Mark as exported', diff --git a/src/languages/es.ts b/src/languages/es.ts index 68738b131e08..2d41149b09d5 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4632,6 +4632,7 @@ ${amount} para ${merchant} - ${date}`, defaultDescription: 'Un solo lugar para todos tus recibos y gastos.', descriptionHint: 'Comparte información sobre este espacio de trabajo con todos los miembros.', welcomeNote: `Por favor, utiliza Expensify para enviar tus recibos para reembolso, ¡gracias!`, + invitedYouToWorkspace: (inviterName, workspaceName) => `# ${inviterName} te invitó a ${workspaceName}`, subscription: 'Suscripción', markAsEntered: 'Marcar como introducido manualmente', markAsExported: 'Marcar como exportado', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 6949f88bb2f4..fc45df952c52 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -4707,6 +4707,7 @@ ${amount} pour ${merchant} - ${date}`, defaultDescription: 'Un seul endroit pour tous vos reçus et dépenses.', descriptionHint: 'Partager des informations sur cet espace de travail avec tous les membres.', welcomeNote: 'Veuillez utiliser Expensify pour soumettre vos reçus en vue de leur remboursement, merci !', + invitedYouToWorkspace: (inviterName: string, workspaceName: string) => `# ${inviterName} vous a invité à ${workspaceName}`, subscription: 'Abonnement', markAsEntered: 'Marquer comme saisi manuellement', markAsExported: 'Marquer comme exporté', diff --git a/src/languages/it.ts b/src/languages/it.ts index b04dded40918..7cd43dd81de5 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -4672,6 +4672,7 @@ ${amount} per ${merchant} - ${date}`, defaultDescription: 'Un unico posto per tutte le tue ricevute e spese.', descriptionHint: 'Condividi informazioni su questo workspace con tutti i membri.', welcomeNote: 'Usa Expensify per inviare le tue ricevute per il rimborso, grazie!', + invitedYouToWorkspace: (inviterName: string, workspaceName: string) => `# ${inviterName} ti ha invitato a ${workspaceName}`, subscription: 'Abbonamento', markAsEntered: 'Segna come inserito manualmente', markAsExported: 'Segna come esportato', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 0c3069f3c35a..3cf36ee43b48 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -4637,6 +4637,7 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' defaultDescription: 'すべての領収書と経費を一か所で管理。', descriptionHint: 'このワークスペースに関する情報をすべてのメンバーと共有します。', welcomeNote: '精算のための領収書提出には Expensify をご利用ください。ありがとうございます!', + invitedYouToWorkspace: (inviterName: string, workspaceName: string) => `# ${inviterName}さんがあなたを${workspaceName}に招待しました`, subscription: 'サブスクリプション', markAsEntered: '手入力としてマーク', markAsExported: 'エクスポート済みにする', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 81ca25d4551c..b9b3fbb51129 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -4671,6 +4671,7 @@ ${amount} voor ${merchant} - ${date}`, defaultDescription: 'Eén plek voor al je bonnetjes en uitgaven.', descriptionHint: 'Deel informatie over deze werkruimte met alle leden.', welcomeNote: 'Gebruik Expensify om je kwitanties in te dienen voor vergoeding, bedankt!', + invitedYouToWorkspace: (inviterName: string, workspaceName: string) => `# ${inviterName} heeft je uitgenodigd voor ${workspaceName}`, subscription: 'Abonnement', markAsEntered: 'Markeren als handmatig ingevoerd', markAsExported: 'Markeren als geëxporteerd', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index a48308d2aefc..dee004e35328 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -4701,6 +4701,7 @@ ${amount} dla ${merchant} - ${date}`, defaultDescription: 'Jedno miejsce na wszystkie Twoje paragony i wydatki.', descriptionHint: 'Udostępnij informacje o tym obszarze roboczym wszystkim członkom.', welcomeNote: 'Prosimy o przesyłanie paragonów do zwrotu kosztów za pomocą Expensify, dziękujemy!', + invitedYouToWorkspace: (inviterName: string, workspaceName: string) => `# ${inviterName} zaprosił(a) Cię do ${workspaceName}`, subscription: 'Subskrypcja', markAsEntered: 'Oznacz jako wprowadzone ręcznie', markAsExported: 'Oznacz jako wyeksportowane', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 160a1c1bc6bc..a862df12d119 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -4665,6 +4665,7 @@ ${amount} para ${merchant} - ${date}`, defaultDescription: 'Um só lugar para todos os seus recibos e despesas.', descriptionHint: 'Compartilhe informações sobre este espaço de trabalho com todos os membros.', welcomeNote: 'Use o Expensify para enviar seus recibos para reembolso, obrigado!', + invitedYouToWorkspace: (inviterName: string, workspaceName: string) => `# ${inviterName} convidou você para ${workspaceName}`, subscription: 'Assinatura', markAsEntered: 'Marcar como inserido manualmente', markAsExported: 'Marcar como exportado', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 88c25ffb68f1..01fdfc547a61 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -4523,6 +4523,7 @@ ${amount},商户:${merchant} - 日期:${date}`, defaultDescription: '一个集中管理所有收据和报销费用的地方。', descriptionHint: '与所有成员共享此工作区的信息。', welcomeNote: '请使用 Expensify 提交报销收据,谢谢!', + invitedYouToWorkspace: (inviterName: string, workspaceName: string) => `# ${inviterName} 邀请你加入 ${workspaceName}`, subscription: '订阅', markAsEntered: '标记为手动输入', markAsExported: '标记为已导出', diff --git a/src/libs/actions/VacationDelegate.ts b/src/libs/actions/VacationDelegate.ts index 326b21648d1d..9497a1c67975 100644 --- a/src/libs/actions/VacationDelegate.ts +++ b/src/libs/actions/VacationDelegate.ts @@ -223,7 +223,7 @@ function inviteVacationDelegateToWorkspaces({delegate, policies, inviter, transl invitedEmailsToAccountIDs, // Writes resolve in queue order, so only the last invitation may clean the optimistic delegate up from under the ones still in flight. isLastInvite ? personalDetailsOnyxData : {optimisticData: personalDetailsOnyxData.optimisticData}, - `# ${inviter.displayName ?? ''} invited you to ${policy.name}\n\n${translate('workspace.common.welcomeNote')}`, + `${translate('workspace.common.invitedYouToWorkspace', inviter.displayName ?? '', policy.name)}\n\n${translate('workspace.common.welcomeNote')}`, policy, Object.values(getMemberAccountIDsForWorkspace(policy.employeeList, false, false)), CONST.POLICY.ROLE.USER, From 0dcdb412a718599ef2b884e96ec15859090c7622 Mon Sep 17 00:00:00 2001 From: jakubstec Date: Wed, 16 Sep 2026 11:03:45 +0200 Subject: [PATCH 18/23] fix: regression causing showing email names instead of full names --- src/languages/de.ts | 10 ++-- src/languages/el.ts | 10 ++-- src/languages/en.ts | 10 ++-- src/languages/es.ts | 10 ++-- src/languages/fr.ts | 10 ++-- src/languages/it.ts | 10 ++-- src/languages/ja.ts | 10 ++-- src/languages/nl.ts | 10 ++-- src/languages/pl.ts | 10 ++-- src/languages/pt-BR.ts | 10 ++-- src/languages/zh-hans.ts | 7 +-- .../MissingWorkspacesIntro.tsx | 9 +++- ...ationDelegateMissingWorkspacesPageTest.tsx | 16 ++++++- tests/ui/VacationDelegatePageTest.tsx | 47 +++++++++++++++++++ 14 files changed, 123 insertions(+), 56 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index ce588646dd24..5abc6edc70dc 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -3762,11 +3762,11 @@ ${amount} für ${merchant} – ${date}`, asVacationDelegate: (nameOrEmail: string) => `als Urlaubsvertretung von ${nameOrEmail}`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `an ${submittedToName} als Urlaubsvertretung für ${vacationDelegateName}`, vacationDelegate: { - notAMemberAdminsWillBeAsked: (email: string) => - `${email} ist kein Mitglied der unten aufgeführten Arbeitsbereiche. Admins der Arbeitsbereiche, die Sie nicht verwalten, werden gebeten, die Person hinzuzufügen.`, - notAMemberInviteThemNow: (email: string) => `${email} ist kein Mitglied der unten aufgeführten Arbeitsbereiche. Möchten Sie die Person jetzt einladen?`, - notAMemberMixed: (email: string) => - `${email} ist kein Mitglied der unten aufgeführten Arbeitsbereiche. Admins der Arbeitsbereiche, die Sie nicht verwalten, werden gebeten, die Person hinzuzufügen. Möchten Sie die Person jetzt zu den Arbeitsbereichen einladen, in denen Sie Admin sind?`, + notAMemberAdminsWillBeAsked: (delegate: string) => + `${delegate} ist kein Mitglied der unten aufgeführten Arbeitsbereiche. Admins der Arbeitsbereiche, die Sie nicht verwalten, werden gebeten, die Person hinzuzufügen.`, + notAMemberInviteThemNow: (delegate: string) => `${delegate} ist kein Mitglied der unten aufgeführten Arbeitsbereiche. Möchten Sie die Person jetzt einladen?`, + notAMemberMixed: (delegate: string) => + `${delegate} ist kein Mitglied der unten aufgeführten Arbeitsbereiche. Admins der Arbeitsbereiche, die Sie nicht verwalten, werden gebeten, die Person hinzuzufügen. Möchten Sie die Person jetzt zu den Arbeitsbereichen einladen, in denen Sie Admin sind?`, youAreAMemberOf: 'Sie sind Mitglied dieser Arbeitsbereiche:', youAreAnAdminOf: 'Sie sind Admin dieser Arbeitsbereiche:', }, diff --git a/src/languages/el.ts b/src/languages/el.ts index ef9dca91300d..ab1c6f8e0708 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -3817,11 +3817,11 @@ ${amount} για ${merchant} - ${date}`, asVacationDelegate: (nameOrEmail: string) => `ως εκπρόσωπος αδειών του/της ${nameOrEmail}`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `στον/στη ${submittedToName} ως αναπληρωτής/τρια αδειούχου για τον/τη ${vacationDelegateName}`, vacationDelegate: { - notAMemberAdminsWillBeAsked: (email: string) => - `Ο/Η ${email} δεν είναι μέλος των παρακάτω χώρων εργασίας. Θα ζητηθεί από τους διαχειριστές των χώρων εργασίας που δεν διαχειρίζεστε να τον/την προσθέσουν.`, - notAMemberInviteThemNow: (email: string) => `Ο/Η ${email} δεν είναι μέλος των παρακάτω χώρων εργασίας. Θέλετε να τον/την προσκαλέσετε τώρα;`, - notAMemberMixed: (email: string) => - `Ο/Η ${email} δεν είναι μέλος των παρακάτω χώρων εργασίας. Θα ζητηθεί από τους διαχειριστές των χώρων εργασίας που δεν διαχειρίζεστε να τον/την προσθέσουν. Θέλετε να τον/την προσκαλέσετε τώρα σε αυτούς που διαχειρίζεστε ως διαχειριστής;`, + notAMemberAdminsWillBeAsked: (delegate: string) => + `Ο/Η ${delegate} δεν είναι μέλος των παρακάτω χώρων εργασίας. Θα ζητηθεί από τους διαχειριστές των χώρων εργασίας που δεν διαχειρίζεστε να τον/την προσθέσουν.`, + notAMemberInviteThemNow: (delegate: string) => `Ο/Η ${delegate} δεν είναι μέλος των παρακάτω χώρων εργασίας. Θέλετε να τον/την προσκαλέσετε τώρα;`, + notAMemberMixed: (delegate: string) => + `Ο/Η ${delegate} δεν είναι μέλος των παρακάτω χώρων εργασίας. Θα ζητηθεί από τους διαχειριστές των χώρων εργασίας που δεν διαχειρίζεστε να τον/την προσθέσουν. Θέλετε να τον/την προσκαλέσετε τώρα σε αυτούς που διαχειρίζεστε ως διαχειριστής;`, youAreAMemberOf: 'Είστε μέλος αυτών των χώρων εργασίας:', youAreAnAdminOf: 'Είστε διαχειριστής αυτών των χώρων εργασίας:', }, diff --git a/src/languages/en.ts b/src/languages/en.ts index 7e1d38186a8e..bf008db4c97c 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3870,11 +3870,11 @@ const translations = { asVacationDelegate: (nameOrEmail: string) => `as ${nameOrEmail}'s vacation delegate`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `to ${submittedToName} as vacation delegate for ${vacationDelegateName}`, vacationDelegate: { - notAMemberAdminsWillBeAsked: (email: string) => - `${email} is not a member of the workspaces below. Admins of the workspaces you don't manage will be asked to add them.`, - notAMemberInviteThemNow: (email: string) => `${email} is not a member of the workspaces below. Do you want to invite them now?`, - notAMemberMixed: (email: string) => - `${email} isn't a member of the workspaces below. Admins of the workspaces you don't manage will be asked to add them. Do you want to invite them to the ones you are an admin of now?`, + notAMemberAdminsWillBeAsked: (delegate: string) => + `${delegate} is not a member of the workspaces below. Admins of the workspaces you don't manage will be asked to add them.`, + notAMemberInviteThemNow: (delegate: string) => `${delegate} is not a member of the workspaces below. Do you want to invite them now?`, + notAMemberMixed: (delegate: string) => + `${delegate} isn't a member of the workspaces below. Admins of the workspaces you don't manage will be asked to add them. Do you want to invite them to the ones you are an admin of now?`, youAreAMemberOf: 'You are a member of these workspaces:', youAreAnAdminOf: 'You are an admin of these workspaces:', }, diff --git a/src/languages/es.ts b/src/languages/es.ts index 2d41149b09d5..69a493ed9b57 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3687,11 +3687,11 @@ ${amount} para ${merchant} - ${date}`, asVacationDelegate: (managerName) => `como delegado de vacaciones de ${managerName}`, toAsVacationDelegate: (submittedToName, vacationDelegateName) => `a ${submittedToName} como delegado de vacaciones de ${vacationDelegateName}`, vacationDelegate: { - notAMemberAdminsWillBeAsked: (email: string) => - `${email} no es miembro de los siguientes espacios de trabajo. Se pedirá a los administradores de los espacios de trabajo que no gestiona que lo agreguen.`, - notAMemberInviteThemNow: (email: string) => `${email} no es miembro de los siguientes espacios de trabajo. ¿Desea invitarlo ahora?`, - notAMemberMixed: (email: string) => - `${email} no es miembro de los siguientes espacios de trabajo. Se pedirá a los administradores de los espacios de trabajo que no gestiona que lo agreguen. ¿Desea invitarlo ahora a los espacios de trabajo en los que es administrador?`, + notAMemberAdminsWillBeAsked: (delegate) => + `${delegate} no es miembro de los siguientes espacios de trabajo. Se pedirá a los administradores de los espacios de trabajo que no gestiona que lo agreguen.`, + notAMemberInviteThemNow: (delegate) => `${delegate} no es miembro de los siguientes espacios de trabajo. ¿Desea invitarlo ahora?`, + notAMemberMixed: (delegate) => + `${delegate} no es miembro de los siguientes espacios de trabajo. Se pedirá a los administradores de los espacios de trabajo que no gestiona que lo agreguen. ¿Desea invitarlo ahora a los espacios de trabajo en los que es administrador?`, youAreAMemberOf: 'Usted es miembro de estos espacios de trabajo:', youAreAnAdminOf: 'Usted es administrador de estos espacios de trabajo:', }, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index fc45df952c52..94a0df388f05 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -3770,11 +3770,11 @@ ${amount} pour ${merchant} - ${date}`, asVacationDelegate: (nameOrEmail: string) => `en tant que délégué de vacances de ${nameOrEmail}`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `à ${submittedToName} en tant que délégué de congés pour ${vacationDelegateName}`, vacationDelegate: { - notAMemberAdminsWillBeAsked: (email: string) => - `${email} n'est pas membre des espaces de travail ci-dessous. Les administrateurs des espaces de travail que vous ne gérez pas seront invités à l'ajouter.`, - notAMemberInviteThemNow: (email: string) => `${email} n'est pas membre des espaces de travail ci-dessous. Voulez-vous l'inviter maintenant ?`, - notAMemberMixed: (email: string) => - `${email} n'est pas membre des espaces de travail ci-dessous. Les administrateurs des espaces de travail que vous ne gérez pas seront invités à l'ajouter. Voulez-vous l'inviter dès maintenant à ceux dont vous êtes administrateur ?`, + notAMemberAdminsWillBeAsked: (delegate: string) => + `${delegate} n'est pas membre des espaces de travail ci-dessous. Les administrateurs des espaces de travail que vous ne gérez pas seront invités à l'ajouter.`, + notAMemberInviteThemNow: (delegate: string) => `${delegate} n'est pas membre des espaces de travail ci-dessous. Voulez-vous l'inviter maintenant ?`, + notAMemberMixed: (delegate: string) => + `${delegate} n'est pas membre des espaces de travail ci-dessous. Les administrateurs des espaces de travail que vous ne gérez pas seront invités à l'ajouter. Voulez-vous l'inviter dès maintenant à ceux dont vous êtes administrateur ?`, youAreAMemberOf: 'Vous êtes membre de ces espaces de travail :', youAreAnAdminOf: 'Vous êtes administrateur de ces espaces de travail :', }, diff --git a/src/languages/it.ts b/src/languages/it.ts index 7cd43dd81de5..54bbafb04f10 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -3746,11 +3746,11 @@ ${amount} per ${merchant} - ${date}`, asVacationDelegate: (nameOrEmail: string) => `come delegato per le ferie di ${nameOrEmail}`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `a ${submittedToName} come delegato ferie per ${vacationDelegateName}`, vacationDelegate: { - notAMemberAdminsWillBeAsked: (email: string) => - `${email} non è membro degli spazi di lavoro elencati di seguito. Agli amministratori degli spazi di lavoro che non gestisci verrà chiesto di aggiungerlo.`, - notAMemberInviteThemNow: (email: string) => `${email} non è membro degli spazi di lavoro elencati di seguito. Vuoi invitarlo ora?`, - notAMemberMixed: (email: string) => - `${email} non è membro degli spazi di lavoro elencati di seguito. Agli amministratori degli spazi di lavoro che non gestisci verrà chiesto di aggiungerlo. Vuoi invitarlo ora in quelli di cui sei amministratore?`, + notAMemberAdminsWillBeAsked: (delegate: string) => + `${delegate} non è membro degli spazi di lavoro elencati di seguito. Agli amministratori degli spazi di lavoro che non gestisci verrà chiesto di aggiungerlo.`, + notAMemberInviteThemNow: (delegate: string) => `${delegate} non è membro degli spazi di lavoro elencati di seguito. Vuoi invitarlo ora?`, + notAMemberMixed: (delegate: string) => + `${delegate} non è membro degli spazi di lavoro elencati di seguito. Agli amministratori degli spazi di lavoro che non gestisci verrà chiesto di aggiungerlo. Vuoi invitarlo ora in quelli di cui sei amministratore?`, youAreAMemberOf: 'Sei membro di questi spazi di lavoro:', youAreAnAdminOf: 'Sei amministratore di questi spazi di lavoro:', }, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 3cf36ee43b48..9da42567d9fe 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -3718,11 +3718,11 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' asVacationDelegate: (nameOrEmail: string) => `${nameOrEmail} さんの休暇代理として`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `${vacationDelegateName} の休暇代理人として ${submittedToName} に`, vacationDelegate: { - notAMemberAdminsWillBeAsked: (email: string) => - `${email}は以下のワークスペースのメンバーではありません。あなたが管理していないワークスペースの管理者に追加を依頼します。`, - notAMemberInviteThemNow: (email: string) => `${email}は以下のワークスペースのメンバーではありません。今すぐ招待しますか?`, - notAMemberMixed: (email: string) => - `${email}は以下のワークスペースのメンバーではありません。あなたが管理していないワークスペースの管理者に追加を依頼します。あなたが管理者になっているワークスペースには今すぐ招待しますか?`, + notAMemberAdminsWillBeAsked: (delegate: string) => + `${delegate}は以下のワークスペースのメンバーではありません。あなたが管理していないワークスペースの管理者に追加を依頼します。`, + notAMemberInviteThemNow: (delegate: string) => `${delegate}は以下のワークスペースのメンバーではありません。今すぐ招待しますか?`, + notAMemberMixed: (delegate: string) => + `${delegate}は以下のワークスペースのメンバーではありません。あなたが管理していないワークスペースの管理者に追加を依頼します。あなたが管理者になっているワークスペースには今すぐ招待しますか?`, youAreAMemberOf: 'あなたはこれらのワークスペースのメンバーです:', youAreAnAdminOf: 'あなたはこれらのワークスペースの管理者です:', }, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index b9b3fbb51129..30c6d9ef8e17 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -3746,11 +3746,11 @@ ${amount} voor ${merchant} - ${date}`, asVacationDelegate: (nameOrEmail: string) => `als vakantiewaarnemer van ${nameOrEmail}`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `aan ${submittedToName} als vakantieplaatsvervanger voor ${vacationDelegateName}`, vacationDelegate: { - notAMemberAdminsWillBeAsked: (email: string) => - `${email} is geen lid van de onderstaande werkruimtes. Beheerders van de werkruimtes die je niet beheert, wordt gevraagd diegene toe te voegen.`, - notAMemberInviteThemNow: (email: string) => `${email} is geen lid van de onderstaande werkruimtes. Wil je diegene nu uitnodigen?`, - notAMemberMixed: (email: string) => - `${email} is geen lid van de onderstaande werkruimtes. Beheerders van de werkruimtes die je niet beheert, wordt gevraagd diegene toe te voegen. Wil je diegene nu uitnodigen voor de werkruimtes waar je beheerder van bent?`, + notAMemberAdminsWillBeAsked: (delegate: string) => + `${delegate} is geen lid van de onderstaande werkruimtes. Beheerders van de werkruimtes die je niet beheert, wordt gevraagd diegene toe te voegen.`, + notAMemberInviteThemNow: (delegate: string) => `${delegate} is geen lid van de onderstaande werkruimtes. Wil je diegene nu uitnodigen?`, + notAMemberMixed: (delegate: string) => + `${delegate} is geen lid van de onderstaande werkruimtes. Beheerders van de werkruimtes die je niet beheert, wordt gevraagd diegene toe te voegen. Wil je diegene nu uitnodigen voor de werkruimtes waar je beheerder van bent?`, youAreAMemberOf: 'Je bent lid van deze werkruimtes:', youAreAnAdminOf: 'Je bent beheerder van deze werkruimtes:', }, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index dee004e35328..59fed0074ac0 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -3776,11 +3776,11 @@ ${amount} dla ${merchant} - ${date}`, asVacationDelegate: (nameOrEmail: string) => `jako osoba zastępująca ${nameOrEmail} podczas urlopu`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `do ${submittedToName} jako zastępca urlopowy dla ${vacationDelegateName}`, vacationDelegate: { - notAMemberAdminsWillBeAsked: (email: string) => - `${email} nie jest członkiem poniższych przestrzeni roboczych. Administratorzy przestrzeni roboczych, którymi nie zarządzasz, zostaną poproszeni o dodanie tej osoby.`, - notAMemberInviteThemNow: (email: string) => `${email} nie jest członkiem poniższych przestrzeni roboczych. Czy chcesz zaprosić tę osobę teraz?`, - notAMemberMixed: (email: string) => - `${email} nie jest członkiem poniższych przestrzeni roboczych. Administratorzy przestrzeni roboczych, którymi nie zarządzasz, zostaną poproszeni o dodanie tej osoby. Czy chcesz zaprosić tę osobę do przestrzeni, w których jesteś administratorem?`, + notAMemberAdminsWillBeAsked: (delegate: string) => + `${delegate} nie jest członkiem poniższych przestrzeni roboczych. Administratorzy przestrzeni roboczych, którymi nie zarządzasz, zostaną poproszeni o dodanie tej osoby.`, + notAMemberInviteThemNow: (delegate: string) => `${delegate} nie jest członkiem poniższych przestrzeni roboczych. Czy chcesz zaprosić tę osobę teraz?`, + notAMemberMixed: (delegate: string) => + `${delegate} nie jest członkiem poniższych przestrzeni roboczych. Administratorzy przestrzeni roboczych, którymi nie zarządzasz, zostaną poproszeni o dodanie tej osoby. Czy chcesz zaprosić tę osobę do przestrzeni, w których jesteś administratorem?`, youAreAMemberOf: 'Jesteś członkiem tych przestrzeni roboczych:', youAreAnAdminOf: 'Jesteś administratorem tych przestrzeni roboczych:', }, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index a862df12d119..11f6f62b2cb4 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -3735,11 +3735,11 @@ ${amount} para ${merchant} - ${date}`, asVacationDelegate: (nameOrEmail: string) => `como delegado de férias de ${nameOrEmail}`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `para ${submittedToName} como delegado(a) de férias de ${vacationDelegateName}`, vacationDelegate: { - notAMemberAdminsWillBeAsked: (email: string) => - `${email} não é membro dos workspaces abaixo. Os admins dos workspaces que você não gerencia serão solicitados a adicioná-lo(a).`, - notAMemberInviteThemNow: (email: string) => `${email} não é membro dos workspaces abaixo. Deseja convidá-lo(a) agora?`, - notAMemberMixed: (email: string) => - `${email} não é membro dos workspaces abaixo. Os admins dos workspaces que você não gerencia serão solicitados a adicioná-lo(a). Deseja convidá-lo(a) agora para os workspaces em que você é admin?`, + notAMemberAdminsWillBeAsked: (delegate: string) => + `${delegate} não é membro dos workspaces abaixo. Os admins dos workspaces que você não gerencia serão solicitados a adicioná-lo(a).`, + notAMemberInviteThemNow: (delegate: string) => `${delegate} não é membro dos workspaces abaixo. Deseja convidá-lo(a) agora?`, + notAMemberMixed: (delegate: string) => + `${delegate} não é membro dos workspaces abaixo. Os admins dos workspaces que você não gerencia serão solicitados a adicioná-lo(a). Deseja convidá-lo(a) agora para os workspaces em que você é admin?`, youAreAMemberOf: 'Você é membro destes workspaces:', youAreAnAdminOf: 'Você é admin destes workspaces:', }, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 01fdfc547a61..07f81ad16d73 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -3628,9 +3628,10 @@ ${amount},商户:${merchant} - 日期:${date}`, asVacationDelegate: (nameOrEmail: string) => `作为 ${nameOrEmail} 的休假代理`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `作为 ${vacationDelegateName} 的休假代理人提交给 ${submittedToName}`, vacationDelegate: { - notAMemberAdminsWillBeAsked: (email: string) => `${email} 不是以下工作区的成员。系统会请求你未管理的工作区的管理员将其添加进来。`, - notAMemberInviteThemNow: (email: string) => `${email} 不是以下工作区的成员。你要现在邀请他们吗?`, - notAMemberMixed: (email: string) => `${email} 不是以下工作区的成员。系统会请求你未管理的工作区的管理员将其添加进来。你要现在邀请他们加入你管理的工作区吗?`, + notAMemberAdminsWillBeAsked: (delegate: string) => `${delegate} 不是以下工作区的成员。系统会请求你未管理的工作区的管理员将其添加进来。`, + notAMemberInviteThemNow: (delegate: string) => `${delegate} 不是以下工作区的成员。你要现在邀请他们吗?`, + notAMemberMixed: (delegate: string) => + `${delegate} 不是以下工作区的成员。系统会请求你未管理的工作区的管理员将其添加进来。你要现在邀请他们加入你管理的工作区吗?`, youAreAMemberOf: '你是这些工作区的成员:', youAreAnAdminOf: '你是这些工作区的管理员:', }, diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesIntro.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesIntro.tsx index c9e0f724f9e2..8d64b2405980 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesIntro.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesIntro.tsx @@ -2,6 +2,9 @@ import RenderHTML from '@components/RenderHTML'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import useVacationDelegatePersonalDetails from '@hooks/useVacationDelegatePersonalDetails'; + +import getVacationDelegateDisplayName from '@libs/getVacationDelegateDisplayName'; import {Str} from 'expensify-common'; import React from 'react'; @@ -21,9 +24,11 @@ type MissingWorkspacesIntroProps = { function MissingWorkspacesIntro({delegate, hasAdminWorkspaces, hasNonAdminWorkspaces}: MissingWorkspacesIntroProps) { const styles = useThemeStyles(); const {translate, formatPhoneNumber} = useLocalize(); + const delegatePersonalDetails = useVacationDelegatePersonalDetails(delegate); - // Format SMS delegates as phone numbers rather than raw @expensify.sms logins, and escape the result since this copy is rendered as HTML. - const escapedDelegate = Str.htmlEncode(formatPhoneNumber(delegate)); + // Prefer the delegate's display name over their raw login, formatting an SMS delegate as a phone number when it falls back to + // one, and escape the result since this copy is rendered as HTML. + const escapedDelegate = Str.htmlEncode(getVacationDelegateDisplayName(delegate, delegatePersonalDetails?.displayName, formatPhoneNumber)); let copy: string; if (!hasAdminWorkspaces) { copy = translate('statusPage.vacationDelegate.notAMemberAdminsWillBeAsked', escapedDelegate); diff --git a/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx index 72ff0bda5301..9328ab5058ba 100644 --- a/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx +++ b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx @@ -5,6 +5,7 @@ import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersona import HTMLEngineProvider from '@components/HTMLEngineProvider'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import PersonalDetailsByLoginProvider from '@components/PersonalDetailsByLoginProvider'; import Text from '@components/Text'; import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; @@ -140,7 +141,7 @@ function renderPage(shouldIncludeBackRoute = false) { : undefined; return render( - + { expect(screen.getByText(formatPhoneNumber(smsDelegate))).toBeOnTheScreen(); }); + it('uses the display name for the intro copy instead of the raw login when the delegate has one', async () => { + const DELEGATE_DISPLAY_NAME = 'Jane Doe'; + await act(async () => { + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[DELEGATE_ACCOUNT_ID]: {accountID: DELEGATE_ACCOUNT_ID, login: DELEGATE_EMAIL, displayName: DELEGATE_DISPLAY_NAME}}); + }); + await seedVacationDelegate({adminPolicies: [], nonAdminPolicies: [MEMBER_POLICY_ID]}); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText(DELEGATE_DISPLAY_NAME)).toBeOnTheScreen(); + expect(screen.queryByText(DELEGATE_EMAIL)).not.toBeOnTheScreen(); + }); + it('shows only the admin-of section and Invite/Skip buttons when the delegate is admin of all, and Skip sends no invites', async () => { await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID], nonAdminPolicies: []}); renderPage(); diff --git a/tests/ui/VacationDelegatePageTest.tsx b/tests/ui/VacationDelegatePageTest.tsx index c591f9e688b4..82df887b8d6b 100644 --- a/tests/ui/VacationDelegatePageTest.tsx +++ b/tests/ui/VacationDelegatePageTest.tsx @@ -52,6 +52,16 @@ jest.mock('@react-navigation/native', () => { }; }); +// The real confirm modal is bridged through the global modal system, which needs Navigation methods this file's +// lightweight Navigation mock doesn't provide. Stub the hook instead so tests can assert on what VacationDelegatePage +// asks it to show without pulling in that machinery. +const mockShowConfirmModal = jest.fn(); +jest.mock('@hooks/useConfirmModal', () => + jest.fn(() => ({ + showConfirmModal: mockShowConfirmModal, + })), +); + // Replaces the real, personal-details-backed selection list with two plain pressable rows so tests can // simulate rapid row taps on VacationDelegatePage's onSelectRow without driving the full list UI. jest.mock('@components/BaseVacationDelegateSelectionComponent', () => { @@ -113,6 +123,7 @@ describe('VacationDelegatePage', () => { }); beforeEach(async () => { + mockShowConfirmModal.mockReset().mockResolvedValue({action: 'CLOSE'}); await act(async () => { await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); }); @@ -146,6 +157,42 @@ describe('VacationDelegatePage', () => { expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.SETTINGS_STATUS); }); + // Regression: an EXP_ERROR response's server-provided message must reach the error modal instead of being dropped for the generic copy. + it('surfaces the EXP_ERROR response message in the error modal and restores the previous delegate on dismissal', async () => { + const EXP_ERROR_MESSAGE = 'This delegate has already been assigned as your submitsTo approver.'; + apiSideEffectSpy = jest + .spyOn(require('@libs/API'), 'makeRequestWithSideEffects') + .mockImplementation(() => Promise.resolve({jsonCode: CONST.JSON_CODE.EXP_ERROR, message: EXP_ERROR_MESSAGE})); + // jest.mock's factory functions (unlike jest.spyOn) are not reset by jest.restoreAllMocks() in afterEach, so call counts otherwise leak across tests in this file. + jest.mocked(Navigation.goBack).mockClear(); + + renderPage(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByTestId('select-delegate-a')); + await waitForBatchedUpdatesWithAct(); + + expect(mockShowConfirmModal).toHaveBeenCalledWith(expect.objectContaining({prompt: EXP_ERROR_MESSAGE})); + expect(Navigation.goBack).not.toHaveBeenCalled(); + + const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + expect(vacationDelegate?.delegate).toBeFalsy(); + expect(vacationDelegate?.errors).toBeFalsy(); + }); + + // Without a server message (e.g. a non-EXP_ERROR failure, or a transport rejection), the generic translation is used instead. + it('falls back to the generic error copy when the response carries no EXP_ERROR message', async () => { + apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve({jsonCode: CONST.JSON_CODE.BAD_REQUEST})); + + renderPage(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByTestId('select-delegate-a')); + await waitForBatchedUpdatesWithAct(); + + expect(mockShowConfirmModal).toHaveBeenCalledWith(expect.objectContaining({prompt: TestHelper.translateLocal('statusPage.vacationDelegateError')})); + }); + it('ignores a second row selection while the first request is still pending', async () => { // Never resolves, so the request stays "in flight" for the duration of the test. apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => new Promise(() => {})); From 4f2ef8c1459adae91c2b4a7617b084eaf579b0b3 Mon Sep 17 00:00:00 2001 From: jakubstec Date: Wed, 16 Sep 2026 12:37:34 +0200 Subject: [PATCH 19/23] fix: address codex comment --- .../index.tsx | 32 ++++++++-- ...ationDelegateMissingWorkspacesPageTest.tsx | 58 ++++++++++++++++++- 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx index 1bbfdf4c222d..397012af6943 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx @@ -9,9 +9,11 @@ import ScrollView from '@components/ScrollView'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import {openWorkspaceMembersPage} from '@libs/actions/Policy/Member'; import {clearVacationDelegateError, inviteVacationDelegateToWorkspaces, setVacationDelegate} from '@libs/actions/VacationDelegate'; import Navigation from '@libs/Navigation/Navigation'; @@ -21,12 +23,13 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {createPoliciesByIDsSelector} from '@src/selectors/Policy'; import type {Policy, VacationDelegatePolicyDiff} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type {NavigationAction} from '@react-navigation/native'; import {useNavigation, usePreventRemove} from '@react-navigation/native'; -import React, {useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import MissingWorkspacesFooter from './MissingWorkspacesFooter'; import MissingWorkspacesIntro from './MissingWorkspacesIntro'; @@ -56,10 +59,31 @@ function VacationDelegateMissingWorkspacesPage() { const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createPoliciesByIDsSelector([...adminPolicies, ...nonAdminPolicies])}); - // Only loaded workspaces can be invited into, so a missing one has to block the invite rather than be dropped from it. - const adminWorkspaces = adminPolicies.map((policyID) => policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]).filter((policy): policy is Policy => !!policy?.id); + // The invite builds the optimistic #announce room from employeeList, so refresh every admin workspace on entry. + const fetchAdminWorkspaceMembers = () => { + for (const policyID of adminPolicies) { + const knownMemberEmails = Object.keys(policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.employeeList ?? {}); + openWorkspaceMembersPage(policyID, knownMemberEmails); + } + }; + + useNetwork({onReconnect: fetchAdminWorkspaceMembers}); + + // Depends only on the admin workspaces: the response updates `policies`, which would refetch in a loop. + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(fetchAdminWorkspaceMembers, [policyDiff?.adminPolicies]); + + const adminWorkspaces: Policy[] = []; + let hasUnresolvedAdminPolicy = false; + for (const policyID of adminPolicies) { + const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + if (policy?.id && !isEmptyObject(policy.employeeList)) { + adminWorkspaces.push(policy); + } else { + hasUnresolvedAdminPolicy = true; + } + } const canInvite = adminPolicies.length > 0; - const hasUnresolvedAdminPolicy = adminWorkspaces.length !== adminPolicies.length; const isSubmittingRef = useRef(false); diff --git a/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx index 9328ab5058ba..14bac422181b 100644 --- a/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx +++ b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx @@ -8,7 +8,7 @@ import OnyxListItemProvider from '@components/OnyxListItemProvider'; import PersonalDetailsByLoginProvider from '@components/PersonalDetailsByLoginProvider'; import Text from '@components/Text'; -import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; @@ -180,6 +180,7 @@ async function seedVacationDelegate(policyDiff?: VacationDelegatePolicyDiff, del describe('VacationDelegateMissingWorkspacesPage', () => { let apiSideEffectSpy: jest.SpyInstance; + let apiReadSpy: jest.SpyInstance; let apiWriteSpy: jest.SpyInstance, ApiWriteCall>; beforeAll(() => { @@ -201,6 +202,7 @@ describe('VacationDelegateMissingWorkspacesPage', () => { // require('@libs/API') is untyped (any), so the spy has to be re-typed here for mock.calls to be readable. // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion apiWriteSpy = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()) as jest.SpyInstance, ApiWriteCall>; + apiReadSpy = jest.spyOn(require('@libs/API'), 'read').mockImplementation(() => {}); await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${MEMBER_POLICY_ID}`, { @@ -404,6 +406,60 @@ describe('VacationDelegateMissingWorkspacesPage', () => { expect(apiWriteSpy).not.toHaveBeenCalledWith(WRITE_COMMANDS.SET_VACATION_DELEGATE, expect.anything(), expect.anything()); }); + // A workspace with no employee list at all cannot be invited into. A partial list is accepted, the same as in the + // regular invite flow, since it only affects the optimistic #announce room; every admin workspace is refreshed on + // entry so the list is as complete as possible by the time Invite is pressed. + it('disables Invite when an admin policy has no employee list, and refreshes every admin policy on entry', async () => { + const SUMMARY_POLICY_ID = 'summaryPolicy'; + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${SUMMARY_POLICY_ID}`, getFakePolicy(SUMMARY_POLICY_ID, 'Summary Workspace')); + }); + await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID, SUMMARY_POLICY_ID], nonAdminPolicies: []}); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // The workspace is named here, unlike one missing from Onyx entirely, so the blocked Invite is the only signal it is not ready. + expect(screen.getByText('Summary Workspace')).toBeOnTheScreen(); + // The members the client already knows are sent so the server can drop the ones that no longer exist. + expect(apiReadSpy).toHaveBeenCalledWith( + READ_COMMANDS.OPEN_WORKSPACE_MEMBERS_PAGE, + expect.objectContaining({policyID: ADMIN_POLICY_ID, clientMemberEmails: JSON.stringify([CREATOR_EMAIL])}), + ); + expect(apiReadSpy).toHaveBeenCalledWith(READ_COMMANDS.OPEN_WORKSPACE_MEMBERS_PAGE, expect.objectContaining({policyID: SUMMARY_POLICY_ID, clientMemberEmails: '[]'})); + + fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.invite')})); + await waitForBatchedUpdatesWithAct(); + + expect(apiWriteSpy).not.toHaveBeenCalledWith(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, expect.anything(), expect.anything()); + expect(apiWriteSpy).not.toHaveBeenCalledWith(WRITE_COMMANDS.SET_VACATION_DELEGATE, expect.anything(), expect.anything()); + }); + + it('enables Invite once the fetched employee list arrives', async () => { + const SUMMARY_POLICY_ID = 'summaryPolicy'; + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${SUMMARY_POLICY_ID}`, getFakePolicy(SUMMARY_POLICY_ID, 'Summary Workspace')); + }); + await seedVacationDelegate({adminPolicies: [SUMMARY_POLICY_ID], nonAdminPolicies: []}); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.invite')})); + await waitForBatchedUpdatesWithAct(); + expect(apiWriteSpy).not.toHaveBeenCalledWith(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, expect.anything(), expect.anything()); + + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${SUMMARY_POLICY_ID}`, { + employeeList: {[CREATOR_EMAIL]: {email: CREATOR_EMAIL, role: CONST.POLICY.ROLE.ADMIN}}, + }); + }); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.invite')})); + await waitForBatchedUpdatesWithAct(); + + expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, expect.objectContaining({policyID: SUMMARY_POLICY_ID}), expect.anything()); + }); + it('shows both sections, member-of before admin-of, for a mixed diff', async () => { await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID], nonAdminPolicies: [MEMBER_POLICY_ID]}); renderPage(); From dc4256a9c5cb679f5c4e42233cea4b10f0bee330 Mon Sep 17 00:00:00 2001 From: jakubstec Date: Thu, 17 Sep 2026 11:07:43 +0200 Subject: [PATCH 20/23] fix: remove unneccesary displayName props naming --- .../MissingWorkspacesFooter.tsx | 2 -- .../MissingWorkspacesIntro.tsx | 2 -- .../VacationDelegateMissingWorkspacesPage/WorkspaceRow.tsx | 2 -- .../VacationDelegateMissingWorkspacesPage/WorkspaceSection.tsx | 2 -- .../VacationDelegateMissingWorkspacesPage/index.tsx | 2 -- 5 files changed, 10 deletions(-) diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesFooter.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesFooter.tsx index aad369056aa4..94ae45eecedc 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesFooter.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesFooter.tsx @@ -60,6 +60,4 @@ function MissingWorkspacesFooter({canInvite, isInviteDisabled, onInvite, onSkip} ); } -MissingWorkspacesFooter.displayName = 'MissingWorkspacesFooter'; - export default MissingWorkspacesFooter; diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesIntro.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesIntro.tsx index 8d64b2405980..9cb072a704c1 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesIntro.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesIntro.tsx @@ -45,6 +45,4 @@ function MissingWorkspacesIntro({delegate, hasAdminWorkspaces, hasNonAdminWorksp ); } -MissingWorkspacesIntro.displayName = 'MissingWorkspacesIntro'; - export default MissingWorkspacesIntro; diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/WorkspaceRow.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/WorkspaceRow.tsx index 6a799edd9b4b..3231f3fa6ff8 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/WorkspaceRow.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/WorkspaceRow.tsx @@ -47,6 +47,4 @@ function WorkspaceRow({policyID, policy, shouldShowSeparator}: WorkspaceRowProps ); } -WorkspaceRow.displayName = 'WorkspaceRow'; - export default WorkspaceRow; diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/WorkspaceSection.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/WorkspaceSection.tsx index ed827ad5e427..f83c95400f22 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/WorkspaceSection.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/WorkspaceSection.tsx @@ -45,6 +45,4 @@ function WorkspaceSection({title, policyIDs, policies}: WorkspaceSectionProps) { ); } -WorkspaceSection.displayName = 'WorkspaceSection'; - export default WorkspaceSection; diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx index 397012af6943..9668e61dab40 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx @@ -171,6 +171,4 @@ function VacationDelegateMissingWorkspacesPage() { ); } -VacationDelegateMissingWorkspacesPage.displayName = 'VacationDelegateMissingWorkspacesPage'; - export default VacationDelegateMissingWorkspacesPage; From 42016570d4228006323fd06ec29654f711405724 Mon Sep 17 00:00:00 2001 From: jakubstec Date: Thu, 17 Sep 2026 11:08:27 +0200 Subject: [PATCH 21/23] fix: update test comments, ensure proper format of test: given, when, then --- tests/actions/VacationDelegateTest.ts | 81 +++++++++++++++++---------- 1 file changed, 52 insertions(+), 29 deletions(-) diff --git a/tests/actions/VacationDelegateTest.ts b/tests/actions/VacationDelegateTest.ts index 22458c59830e..9fb4c261e0c7 100644 --- a/tests/actions/VacationDelegateTest.ts +++ b/tests/actions/VacationDelegateTest.ts @@ -26,12 +26,17 @@ describe('actions/VacationDelegate', () => { return Onyx.clear().then(waitForBatchedUpdates); }); + afterEach(() => jest.restoreAllMocks()); + describe('setVacationDelegate', () => { it('sends SetVacationDelegate with the mapped params and clears policyDiff optimistically', async () => { + // Given the API side-effect call is mocked, since only the shape of the outgoing request matters here const apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve()); + // When a delegate is picked while another delegate is already saved await setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', currentDelegate: 'old@test.com'}); + // Then the request carries the mapped params and clears policyDiff optimistically expect(apiSideEffectSpy).toHaveBeenCalledWith( SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, { @@ -55,26 +60,28 @@ describe('actions/VacationDelegate', () => { }, ); - // The 305 policy diff warning is a non-200 response, so passing failureData would make the request pipeline - // write an error we immediately have to clear, flashing a red brick road. The action applies it from the response instead. + // Then no failureData is attached, because the 305 policy diff warning is a non-200 response and passing + // failureData would make the request pipeline write an error we immediately have to clear, flashing a red + // brick road. The action applies it from the response instead. expect(apiSideEffectSpy.mock.calls.at(0)?.at(2)).not.toHaveProperty('failureData'); - - apiSideEffectSpy.mockRestore(); }); it('sends a persisted write instead of a side effect request once the policy diff warning is overridden', async () => { + // Given both API paths are spied on, so we can prove which one is actually used const apiWriteSpy = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => undefined); const apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve()); + // When the user confirms the missing-workspaces step, so the override flag is set await setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', shouldOverridePolicyDiffWarning: true}); + // Then only the persisted write fires, because the 305 can no longer come back and the request needs to survive offline expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.SET_VACATION_DELEGATE, expect.objectContaining({overridePolicyDiffWarning: true}), expect.anything()); expect(apiSideEffectSpy).not.toHaveBeenCalled(); - - jest.restoreAllMocks(); }); it('waits for queued writes to settle before starting, so their responses cannot clear the policy diff it captures', async () => { + // Given a real fetch mock with the queue paused, so the invite step's write is still in flight, plus a + // session so the sequential queue is able to run at all const mockFetch = createGlobalFetchMock(); global.fetch = mockFetch; await Onyx.set(ONYXKEYS.SESSION, {email: 'admin@test.com', accountID: 1, authToken: 'testAuthToken'}); @@ -92,10 +99,11 @@ describe('actions/VacationDelegate', () => { setVacationDelegate({creator: 'admin@test.com', delegate: 'first@test.com', shouldOverridePolicyDiffWarning: true}); await waitForBatchedUpdates(); + // When the next delegate is picked before the queued write has drained const request = setVacationDelegate({creator: 'admin@test.com', delegate: 'second@test.com', currentDelegate: 'first@test.com'}); await waitForBatchedUpdates(); - // Nothing of the second selection may reach the network or the NVP while the queued write can still respond over it. + // Then nothing from the second pick may reach the network or the NVP while the queued write can still respond over it expect(getFetchMockCalls(WRITE_COMMANDS.SET_VACATION_DELEGATE).length).toBe(1); expect((await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE))?.delegate).toBe('first@test.com'); @@ -103,6 +111,7 @@ describe('actions/VacationDelegate', () => { await request; await waitForBatchedUpdates(); + // Then, once the queue resumes, the 305 policyDiff is captured against the correct (second) delegate const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); expect(vacationDelegate?.delegate).toBe('first@test.com'); expect(vacationDelegate?.pendingDelegate).toBe('second@test.com'); @@ -110,6 +119,8 @@ describe('actions/VacationDelegate', () => { }); it('merges the policyDiff from a 305 response into the NVP without ever surfacing an error', async () => { + // Given an Onyx subscriber recording every errors value the NVP ever takes on, and a mocked 305 that + // applies its optimistic data first, the same way the real request pipeline does const policyDiff = {adminPolicies: ['1'], nonAdminPolicies: ['2']}; const errorStates: Array = []; const connection = Onyx.connectWithoutView({ @@ -129,10 +140,12 @@ describe('actions/VacationDelegate', () => { return {jsonCode: CONST.JSON_CODE.POLICY_DIFF_WARNING, data: {policyDiff}}; }); + // When a delegate is picked await setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', currentDelegate: 'old@test.com'}); await waitForBatchedUpdates(); Onyx.disconnect(connection); + // Then the policyDiff is stored, pendingDelegate points at the pick, and the saved delegate is left unchanged const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); expect(vacationDelegate?.policyDiff).toEqual(policyDiff); expect(vacationDelegate?.pendingAction).toBeFalsy(); @@ -140,13 +153,12 @@ describe('actions/VacationDelegate', () => { expect(vacationDelegate?.delegate).toBe('old@test.com'); expect(vacationDelegate?.previousDelegate).toBe('old@test.com'); - // An error at any point would flash a red brick road on the profile page, so it must never be written at all. + // Then no error is ever written, because an error at any point would flash a red brick road on the profile page expect(errorStates.every(isEmptyObject)).toBe(true); - - jest.restoreAllMocks(); }); it('clears the errors payload the backend ships in onyxData alongside a 305', async () => { + // Given a 305 whose onyxData carries an errors payload const policyDiff = {adminPolicies: [], nonAdminPolicies: ['79705898949FB240']}; jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(async () => { @@ -167,20 +179,21 @@ describe('actions/VacationDelegate', () => { return {jsonCode: CONST.JSON_CODE.POLICY_DIFF_WARNING, data: {policyDiff}}; }); + // When a delegate is picked await setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', currentDelegate: 'old@test.com'}); await waitForBatchedUpdates(); - // The confirmation step must not leave a red brick road behind on the profile page. While the payload is - // in flight getVacationDelegateErrors hides it, which getVacationDelegateErrors.test.ts covers. + // Then no error is visible via getVacationDelegateErrors, so the confirmation step doesn't leave a red brick + // road behind on the profile page (while the payload is in flight getVacationDelegateErrors hides it, which + // getVacationDelegateErrors.test.ts covers), and the policyDiff/pendingDelegate are still set const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); expect(getVacationDelegateErrors(vacationDelegate)).toBeUndefined(); expect(vacationDelegate?.policyDiff).toEqual(policyDiff); expect(vacationDelegate?.pendingDelegate).toBe('delegate@test.com'); - - jest.restoreAllMocks(); }); it('clears the delegate on a 305 when there was no delegate saved before the pick', async () => { + // Given no saved delegate and a mocked 305 jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(async () => { await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { creator: 'admin@test.com', @@ -191,17 +204,20 @@ describe('actions/VacationDelegate', () => { return {jsonCode: CONST.JSON_CODE.POLICY_DIFF_WARNING, data: {policyDiff: {adminPolicies: ['1'], nonAdminPolicies: []}}}; }); + // When the first delegate is picked await setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com'}); await waitForBatchedUpdates(); + // Then the delegate is cleared and only pendingDelegate is set, so the profile page doesn't show an + // unsaved delegate as though it were already saved const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); expect(vacationDelegate?.delegate).toBeFalsy(); expect(vacationDelegate?.pendingDelegate).toBe('delegate@test.com'); - - jest.restoreAllMocks(); }); it('applies the failureData it could not attach when the response fails, since the caller may have already navigated away', async () => { + // Given a mocked non-305 error response, with no failureData attached to the request (see the first test + // above), so the action has to apply it itself instead of relying on a caller that may no longer be mounted const response = {jsonCode: CONST.JSON_CODE.EXP_ERROR, message: 'Nope'}; jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(async () => { // The optimistic data is applied before the response resolves. @@ -215,23 +231,22 @@ describe('actions/VacationDelegate', () => { return response; }); - // There is no failureData attached to the request (see the first test above), so the action has to apply it itself - // instead of relying on a caller that may no longer be mounted to report the failure. + // When the pick fails await expect(setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', currentDelegate: 'old@test.com'})).resolves.toEqual(response); await waitForBatchedUpdates(); + // Then the action writes the error itself const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); expect(vacationDelegate?.pendingAction).toBeFalsy(); expect(vacationDelegate?.errors).toBeTruthy(); - // Rolling back belongs to whoever dismisses the error, so both delegate fields have to survive for it to roll back to. + // Then both delegate fields survive, because rolling back belongs to whoever dismisses the error and needs something to roll back to expect(vacationDelegate?.delegate).toBe('delegate@test.com'); expect(vacationDelegate?.previousDelegate).toBe('old@test.com'); - - jest.restoreAllMocks(); }); it('leaves a failed response in a state where dismissing the error restores the last confirmed delegate', async () => { + // Given a pick that fails over a previously saved delegate jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(async () => { await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { creator: 'admin@test.com', @@ -246,52 +261,57 @@ describe('actions/VacationDelegate', () => { await setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', currentDelegate: 'old@test.com'}); await waitForBatchedUpdates(); - // What the profile page does when the red brick road is dismissed. It reads previousDelegate back out of Onyx, so a failure - // that cleared it would delete the delegate the user actually has instead of restoring it. + // When the error is dismissed the way the profile page does it, via clearVacationDelegateError with the stored previousDelegate const failed = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); clearVacationDelegateError(failed?.previousDelegate); await waitForBatchedUpdates(); + // Then the last confirmed delegate is restored, because a failure that cleared previousDelegate would + // delete the delegate the user actually has instead of restoring it, and errors are cleared const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); expect(vacationDelegate?.delegate).toBe('old@test.com'); expect(vacationDelegate?.errors).toBeFalsy(); - - jest.restoreAllMocks(); }); it('does not merge a policyDiff on a successful (200) response', async () => { + // Given a mocked 200 jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve({jsonCode: 200})); + // When a delegate is picked await setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com'}); await waitForBatchedUpdates(); + // Then no policyDiff is stored, so no missing-workspaces step opens const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); expect(vacationDelegate?.policyDiff).toBeFalsy(); - - jest.restoreAllMocks(); }); }); describe('deleteVacationDelegate', () => { it('does not leave the deleted delegate as the rollback target of the next pick', async () => { + // Given a saved delegate and a session, so the sequential queue is able to run const mockFetch = createGlobalFetchMock(); global.fetch = mockFetch; await Onyx.set(ONYXKEYS.SESSION, {email: 'admin@test.com', accountID: 1, authToken: 'testAuthToken'}); await Onyx.set(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, {creator: 'admin@test.com', delegate: 'a@test.com'}); await waitForBatchedUpdates(); + // When the delegate is deleted deleteVacationDelegate({creator: 'admin@test.com', delegate: 'a@test.com'}); await waitForBatchedUpdates(); + // Then the delegate is gone and there is no previousDelegate left to roll back to const afterDelete = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); expect(afterDelete?.delegate).toBeFalsy(); expect(afterDelete?.previousDelegate).toBeFalsy(); - // Picking B now has nothing to roll back to, so a 305 followed by dismissing the step must not bring A back. + // Given a mocked 305 for picking a new delegate (B), now that there is nothing to roll back to jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(async () => { await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, {delegate: 'b@test.com', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}); return {jsonCode: CONST.JSON_CODE.POLICY_DIFF_WARNING, data: {policyDiff: {adminPolicies: ['1'], nonAdminPolicies: []}}}; }); + + // When B is picked and the resulting missing-workspaces step is dismissed await setVacationDelegate({creator: 'admin@test.com', delegate: 'b@test.com', currentDelegate: afterDelete?.delegate}); await waitForBatchedUpdates(); @@ -299,13 +319,14 @@ describe('actions/VacationDelegate', () => { clearVacationDelegateError(afterPick?.previousDelegate); await waitForBatchedUpdates(); + // Then the deleted delegate (A) does not come back expect((await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE))?.delegate).toBeFalsy(); - jest.restoreAllMocks(); }); }); describe('clearVacationDelegateError', () => { it('clears errors, pendingAction, and policyDiff, and restores the previous delegate', async () => { + // Given an NVP populated with every field the error path can leave behind const timestamp = 123; await Onyx.set(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { creator: 'admin@test.com', @@ -317,9 +338,11 @@ describe('actions/VacationDelegate', () => { pendingDelegate: 'delegate@test.com', }); + // When the error is dismissed clearVacationDelegateError('old@test.com'); await waitForBatchedUpdates(); + // Then everything the error path could have left behind is cleared and the previous delegate is restored const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); expect(vacationDelegate?.delegate).toBe('old@test.com'); expect(vacationDelegate?.previousDelegate).toBeFalsy(); From a12c1bc28085367337dab6970bdefa8f54b9c94a Mon Sep 17 00:00:00 2001 From: jakubstec Date: Thu, 17 Sep 2026 11:51:15 +0200 Subject: [PATCH 22/23] fix: ensure all test files related to vacation delegate have given when then structure --- ...ationDelegateMissingWorkspacesPageTest.tsx | 102 ++++++++++++++---- tests/ui/VacationDelegatePageTest.tsx | 42 ++++++-- tests/unit/PolicySelectorTest.ts | 16 +++ .../VacationDelegateMenuItemTest.tsx | 48 +++++++-- .../libs/getVacationDelegateErrors.test.ts | 16 ++- 5 files changed, 180 insertions(+), 44 deletions(-) diff --git a/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx index 14bac422181b..91959b13435b 100644 --- a/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx +++ b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx @@ -230,42 +230,51 @@ describe('VacationDelegateMissingWorkspacesPage', () => { }); it('renders the not found page when there is no policy diff', async () => { + // Given no policy diff is seeded, which is the shape the NVP has when the page is reached without a pending 305 await seedVacationDelegate(undefined); renderPage(); await waitForBatchedUpdatesWithAct(); + // Then the not found page is shown instead of any workspace content expect(screen.getByText(TestHelper.translateLocal('notFound.notHere'))).toBeOnTheScreen(); }); it('shows only the member-of section and a Confirm button when the delegate is admin of none', async () => { + // Given a policy diff where the delegate is a member of one workspace and admin of none await seedVacationDelegate({adminPolicies: [], nonAdminPolicies: [MEMBER_POLICY_ID]}); renderPage(); await waitForBatchedUpdatesWithAct(); + // Then only the member-of section and a Confirm button are shown, since there is nothing to invite into expect(screen.getByText('Member Workspace')).toBeOnTheScreen(); expect(screen.queryByText('Admin Workspace')).not.toBeOnTheScreen(); expect(screen.getByRole('button', {name: TestHelper.translateLocal('common.confirm')})).toBeOnTheScreen(); expect(screen.queryByRole('button', {name: TestHelper.translateLocal('common.invite')})).not.toBeOnTheScreen(); + // When Confirm is pressed fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.confirm')})); await waitForBatchedUpdatesWithAct(); + // Then the pick is finalized with the override flag and no invite is sent, since admin-of is empty expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.SET_VACATION_DELEGATE, expect.objectContaining({creator: CREATOR_EMAIL, overridePolicyDiffWarning: true}), expect.anything()); expect(apiWriteSpy).not.toHaveBeenCalledWith(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, expect.anything(), expect.anything()); }); - // Bug #89578 - this page replaced the warning modal that first carried the fix, so it owns the guarantee now. it('formats an SMS delegate login as a phone number in the intro copy instead of the raw @expensify.sms address', async () => { + // Given a delegate whose login is an SMS address, since bug #89578 once showed the raw @expensify.sms address + // and this page replaced the warning modal that first carried the fix, so it now owns the guarantee const smsDelegate = `+15005550006${CONST.SMS.DOMAIN}`; await seedVacationDelegate({adminPolicies: [], nonAdminPolicies: [MEMBER_POLICY_ID]}, smsDelegate); renderPage(); await waitForBatchedUpdatesWithAct(); + // Then the intro copy shows the formatted phone number, not the raw SMS login expect(screen.queryByText(smsDelegate)).not.toBeOnTheScreen(); expect(screen.getByText(formatPhoneNumber(smsDelegate))).toBeOnTheScreen(); }); it('uses the display name for the intro copy instead of the raw login when the delegate has one', async () => { + // Given a delegate whose personal details already carry a display name const DELEGATE_DISPLAY_NAME = 'Jane Doe'; await act(async () => { await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[DELEGATE_ACCOUNT_ID]: {accountID: DELEGATE_ACCOUNT_ID, login: DELEGATE_EMAIL, displayName: DELEGATE_DISPLAY_NAME}}); @@ -274,43 +283,52 @@ describe('VacationDelegateMissingWorkspacesPage', () => { renderPage(); await waitForBatchedUpdatesWithAct(); + // Then the intro copy shows the display name, not the raw login expect(screen.getByText(DELEGATE_DISPLAY_NAME)).toBeOnTheScreen(); expect(screen.queryByText(DELEGATE_EMAIL)).not.toBeOnTheScreen(); }); it('shows only the admin-of section and Invite/Skip buttons when the delegate is admin of all, and Skip sends no invites', async () => { + // Given a policy diff where the delegate is admin of every workspace and a member of none await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID], nonAdminPolicies: []}); renderPage(); await waitForBatchedUpdatesWithAct(); + // Then only the admin-of section and Invite/Skip buttons are shown, since there is nothing to acknowledge in member-of expect(screen.getByText('Admin Workspace')).toBeOnTheScreen(); expect(screen.queryByText('Member Workspace')).not.toBeOnTheScreen(); expect(screen.getByRole('button', {name: TestHelper.translateLocal('common.invite')})).toBeOnTheScreen(); expect(screen.getByRole('button', {name: TestHelper.translateLocal('common.skip')})).toBeOnTheScreen(); + // When Skip is pressed fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.skip')})); await waitForBatchedUpdatesWithAct(); + // Then the pick is finalized with the override flag and no invite is sent, since Skip declines every invite expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.SET_VACATION_DELEGATE, expect.objectContaining({overridePolicyDiffWarning: true}), expect.anything()); expect(apiWriteSpy).not.toHaveBeenCalledWith(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, expect.anything(), expect.anything()); }); it('sends one AddMembersToWorkspace call per admin policy and then SetVacationDelegate when Invite is pressed', async () => { + // Given a policy diff where the delegate is admin of one workspace await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID], nonAdminPolicies: []}); renderPage(); await waitForBatchedUpdatesWithAct(); + // When Invite is pressed fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.invite')})); await waitForBatchedUpdatesWithAct(); + // Then an AddMembersToWorkspace write is sent for that workspace, followed by SetVacationDelegate with the + // override flag, and both go through the persisted write queue rather than a side-effect request, so going + // offline mid-flow cannot invite the delegate without also setting them expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, expect.objectContaining({policyID: ADMIN_POLICY_ID}), expect.anything()); - - // Both requests go through the persisted queue, so going offline mid-flow cannot invite the delegate without setting them. expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.SET_VACATION_DELEGATE, expect.objectContaining({overridePolicyDiffWarning: true}), expect.anything()); expect(apiSideEffectSpy).not.toHaveBeenCalledWith(SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, expect.anything(), expect.anything()); }); it('stays usable offline instead of blocking the step, since every button it offers is a persisted write', async () => { + // Given the network forced offline and a policy diff where the delegate is admin of one workspace await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID], nonAdminPolicies: []}); await act(async () => { await Onyx.merge(ONYXKEYS.NETWORK, {shouldForceOffline: true}); @@ -318,27 +336,32 @@ describe('VacationDelegateMissingWorkspacesPage', () => { renderPage(); await waitForBatchedUpdatesWithAct(); - // The delegate is already optimistically set at this point, so blocking the page would strand the user - // with an unconfirmed change until they reconnect. The offline indicator still shows, only the blocking view must not. + // Then the offline-blocking view is not shown, only the offline indicator, since the delegate is already + // optimistically set at this point and blocking the page would strand the user with an unconfirmed change until they reconnect expect(screen.queryByText(TestHelper.translateLocal('common.thisFeatureRequiresInternet'))).not.toBeOnTheScreen(); expect(screen.getByText('Admin Workspace')).toBeOnTheScreen(); + // When Invite is pressed while offline fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.invite')})); await waitForBatchedUpdatesWithAct(); + // Then both writes are still sent, since persisted writes are queued and survive being offline expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, expect.objectContaining({policyID: ADMIN_POLICY_ID}), expect.anything()); expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.SET_VACATION_DELEGATE, expect.objectContaining({overridePolicyDiffWarning: true}), expect.anything()); }); it('invites a delegate that has no personal details entry yet using an optimistic accountID', async () => { + // Given a delegate with no personal details entry yet, so no real accountID exists for them await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID], nonAdminPolicies: []}); renderPage(); await waitForBatchedUpdatesWithAct(); + // When Invite is pressed fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.invite')})); await waitForBatchedUpdatesWithAct(); - // Both params are keyed off the invited accountID, so they stay empty when the delegate cannot be resolved to one. + // Then the invite still carries the delegate's login in both employees and reportCreationData, keyed off an + // optimistic accountID, rather than staying empty because the delegate could not be resolved to one expect(apiWriteSpy).toHaveBeenCalledWith( WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, // require('@libs/API') is untyped (any), which taints the inferred type of these matchers; the assertion itself is fine. @@ -349,15 +372,18 @@ describe('VacationDelegateMissingWorkspacesPage', () => { }); it('reuses the expense chat the delegate already owns and un-archives its report previews', async () => { + // Given a delegate who already owns a policy expense chat in the admin workspace, holding a report preview of + // an archived expense report (see seedExistingDelegateExpenseChat) await seedExistingDelegateExpenseChat(); await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID], nonAdminPolicies: []}); renderPage(); await waitForBatchedUpdatesWithAct(); + // When Invite is pressed fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.invite')})); await waitForBatchedUpdatesWithAct(); - // The existing chat is reused rather than recreated... + // Then the existing chat is reused rather than recreated expect(apiWriteSpy).toHaveBeenCalledWith( WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, // require('@libs/API') is untyped (any), which taints the inferred type of these matchers; the assertion itself is fine. @@ -366,50 +392,57 @@ describe('VacationDelegateMissingWorkspacesPage', () => { expect.anything(), ); - // ...and its preview's expense report is un-archived, which only happens when that chat's report actions reach the invite. + // Then its preview's expense report is un-archived, which only happens when that chat's report actions reach + // the invite, since re-inviting the delegate must not leave old previews stuck archived const [inviteOnyxData] = getInviteOnyxData(apiWriteSpy.mock.calls); expect(inviteOnyxData.optimisticData?.some((update) => update.key === `${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${ARCHIVED_EXPENSE_REPORT_ID}`)).toBe(true); }); it('lets only the last queued invite clean up the delegate optimistic personal details', async () => { + // Given a policy diff where the delegate is admin of two workspaces, so two invites get queued await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID, SECOND_ADMIN_POLICY_ID], nonAdminPolicies: []}); renderPage(); await waitForBatchedUpdatesWithAct(); + // When Invite is pressed fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.invite')})); await waitForBatchedUpdatesWithAct(); const inviteCalls = getInviteOnyxData(apiWriteSpy.mock.calls); expect(inviteCalls).toHaveLength(2); - // Every invite still seeds the optimistic delegate, so each policy expense chat renders them... + // Then every invite still seeds the optimistic delegate, so each policy expense chat renders them expect(inviteCalls.every((onyxData) => hasPersonalDetailsUpdate(onyxData.optimisticData))).toBe(true); - // ...but only the last one tears them down. Writes resolve in queue order, so a cleanup on the first invite - // would wipe the delegate out from under the invites still in flight. + // Then only the last invite tears the optimistic personal details down, because writes resolve in queue order + // and a cleanup on the first invite would wipe the delegate out from under the invites still in flight expect(hasPersonalDetailsUpdate(inviteCalls.at(0)?.successData)).toBe(false); expect(hasPersonalDetailsUpdate(inviteCalls.at(-1)?.successData)).toBe(true); }); it('disables Invite and never sends AddMembersToWorkspace when an admin policy has not loaded', async () => { + // Given a policy diff that references an admin workspace not present in Onyx at all const UNAVAILABLE_POLICY_ID = 'unavailablePolicy'; await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID, UNAVAILABLE_POLICY_ID], nonAdminPolicies: []}); renderPage(); await waitForBatchedUpdatesWithAct(); + // Then that workspace is shown as unavailable expect(screen.getByText(TestHelper.translateLocal('workspace.common.unavailable'))).toBeOnTheScreen(); + // When Invite is pressed anyway fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.invite')})); await waitForBatchedUpdatesWithAct(); + // Then nothing is sent, since inviting into a workspace the client knows nothing about could not be done safely expect(apiWriteSpy).not.toHaveBeenCalledWith(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, expect.anything(), expect.anything()); expect(apiWriteSpy).not.toHaveBeenCalledWith(WRITE_COMMANDS.SET_VACATION_DELEGATE, expect.anything(), expect.anything()); }); - // A workspace with no employee list at all cannot be invited into. A partial list is accepted, the same as in the - // regular invite flow, since it only affects the optimistic #announce room; every admin workspace is refreshed on - // entry so the list is as complete as possible by the time Invite is pressed. it('disables Invite when an admin policy has no employee list, and refreshes every admin policy on entry', async () => { + // Given an admin workspace present in Onyx but with no employee list loaded yet (unlike one missing from + // Onyx entirely, this one is named, so the blocked Invite button is the only signal it isn't ready), alongside + // another admin workspace whose employee list already loaded const SUMMARY_POLICY_ID = 'summaryPolicy'; await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${SUMMARY_POLICY_ID}`, getFakePolicy(SUMMARY_POLICY_ID, 'Summary Workspace')); @@ -418,23 +451,27 @@ describe('VacationDelegateMissingWorkspacesPage', () => { renderPage(); await waitForBatchedUpdatesWithAct(); - // The workspace is named here, unlike one missing from Onyx entirely, so the blocked Invite is the only signal it is not ready. + // Then the workspace is still named on screen, and every admin workspace is refreshed on entry, sending the + // members the client already knows so the server can drop the ones that no longer exist expect(screen.getByText('Summary Workspace')).toBeOnTheScreen(); - // The members the client already knows are sent so the server can drop the ones that no longer exist. expect(apiReadSpy).toHaveBeenCalledWith( READ_COMMANDS.OPEN_WORKSPACE_MEMBERS_PAGE, expect.objectContaining({policyID: ADMIN_POLICY_ID, clientMemberEmails: JSON.stringify([CREATOR_EMAIL])}), ); expect(apiReadSpy).toHaveBeenCalledWith(READ_COMMANDS.OPEN_WORKSPACE_MEMBERS_PAGE, expect.objectContaining({policyID: SUMMARY_POLICY_ID, clientMemberEmails: '[]'})); + // When Invite is pressed fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.invite')})); await waitForBatchedUpdatesWithAct(); + // Then nothing is sent, since a workspace with no employee list at all cannot be invited into (a partial list + // is accepted, the same as in the regular invite flow, since it only affects the optimistic #announce room) expect(apiWriteSpy).not.toHaveBeenCalledWith(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, expect.anything(), expect.anything()); expect(apiWriteSpy).not.toHaveBeenCalledWith(WRITE_COMMANDS.SET_VACATION_DELEGATE, expect.anything(), expect.anything()); }); it('enables Invite once the fetched employee list arrives', async () => { + // Given an admin workspace whose employee list has not loaded yet const SUMMARY_POLICY_ID = 'summaryPolicy'; await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${SUMMARY_POLICY_ID}`, getFakePolicy(SUMMARY_POLICY_ID, 'Summary Workspace')); @@ -443,10 +480,14 @@ describe('VacationDelegateMissingWorkspacesPage', () => { renderPage(); await waitForBatchedUpdatesWithAct(); + // When Invite is pressed before the list arrives fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.invite')})); await waitForBatchedUpdatesWithAct(); + + // Then nothing is sent, since the workspace isn't ready yet expect(apiWriteSpy).not.toHaveBeenCalledWith(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, expect.anything(), expect.anything()); + // When the employee list then arrives and Invite is pressed again await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${SUMMARY_POLICY_ID}`, { employeeList: {[CREATOR_EMAIL]: {email: CREATOR_EMAIL, role: CONST.POLICY.ROLE.ADMIN}}, @@ -457,76 +498,91 @@ describe('VacationDelegateMissingWorkspacesPage', () => { fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.invite')})); await waitForBatchedUpdatesWithAct(); + // Then the invite is sent for that workspace, since it's now ready expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, expect.objectContaining({policyID: SUMMARY_POLICY_ID}), expect.anything()); }); it('shows both sections, member-of before admin-of, for a mixed diff', async () => { + // Given a mixed policy diff where the delegate is both a member of one workspace and admin of another await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID], nonAdminPolicies: [MEMBER_POLICY_ID]}); renderPage(); await waitForBatchedUpdatesWithAct(); + // Then both sections are shown, member-of before admin-of, along with Invite and Skip buttons for the admin-of section expect(screen.getByText('Member Workspace')).toBeOnTheScreen(); expect(screen.getByText('Admin Workspace')).toBeOnTheScreen(); expect(screen.getByRole('button', {name: TestHelper.translateLocal('common.invite')})).toBeOnTheScreen(); expect(screen.getByRole('button', {name: TestHelper.translateLocal('common.skip')})).toBeOnTheScreen(); }); - // Skip only skips the invites the user controls: the owners of the workspaces they don't administer are still emailed, matching Classic. it('still asks the backend to email the non-admin workspaces when Skip is pressed on a mixed diff', async () => { + // Given a mixed policy diff, since Skip only skips the invites the user controls: the owners of the + // workspaces they don't administer are still emailed, matching Classic await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID], nonAdminPolicies: [MEMBER_POLICY_ID]}); renderPage(); await waitForBatchedUpdatesWithAct(); + // When Skip is pressed fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.skip')})); await waitForBatchedUpdatesWithAct(); + // Then the pick is finalized with the override flag so the backend can still email the non-admin workspaces, + // and no AddMembersToWorkspace write is sent, since Skip only declines the invites the user controls expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.SET_VACATION_DELEGATE, expect.objectContaining({overridePolicyDiffWarning: true}), expect.anything()); expect(apiWriteSpy).not.toHaveBeenCalledWith(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, expect.anything(), expect.anything()); }); it('finishes rolling back an abandoned flow before exposing the delegate selection page', async () => { + // Given a policy diff seeded with a back route to the delegate selection page await seedVacationDelegate({adminPolicies: [], nonAdminPolicies: [MEMBER_POLICY_ID]}); renderPage(true); await waitForBatchedUpdatesWithAct(); expect(mockPreventRemoveCallback).toBeDefined(); + + // When the route is popped, abandoning the flow await act(async () => { mockPreventRemoveCallback?.({data: {action: StackActions.pop()}}); await waitForBatchedUpdatesWithAct(); }); + // Then the rollback finishes before the selection page underneath is shown, restoring the previous delegate and leaving no policy diff behind expect(screen.getByText('Delegate selection')).toBeOnTheScreen(); expect(screen.queryByText('Member Workspace')).not.toBeOnTheScreen(); const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); expect(vacationDelegate?.delegate).toBe(PREVIOUS_DELEGATE_EMAIL); expect(vacationDelegate?.policyDiff).toBeFalsy(); - // Starting a new selection after the picker is visible must not be overwritten by cleanup from the route that just closed. + // When a new selection is seeded right after the picker becomes visible const nextPolicyDiff = {adminPolicies: [ADMIN_POLICY_ID], nonAdminPolicies: []}; await seedVacationDelegate(nextPolicyDiff, 'next@example.com'); await waitForBatchedUpdatesWithAct(); + // Then it is not overwritten by cleanup from the route that just closed, since that cleanup must only ever affect the flow it belonged to const nextVacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); expect(nextVacationDelegate?.pendingDelegate).toBe('next@example.com'); expect(nextVacationDelegate?.policyDiff).toEqual(nextPolicyDiff); }); it('keeps rendering what was submitted when the flow state is cleared underneath it', async () => { + // Given Navigation.goBack stubbed out, so the screen stays mounted past submission the same way it does in + // the real app, where navigation only pops this screen once the transition finishes jest.spyOn(Navigation, 'goBack').mockImplementation(() => {}); await seedVacationDelegate({adminPolicies: [], nonAdminPolicies: [MEMBER_POLICY_ID]}); renderPage(); await waitForBatchedUpdatesWithAct(); + // When Confirm is pressed fireEvent.press(screen.getByRole('button', {name: TestHelper.translateLocal('common.confirm')})); await waitForBatchedUpdatesWithAct(); - // Navigation only pops this screen once the transition finishes, so the submitted request nulls the flow state while it is - // still mounted. That must not turn it into the not found page or flip the copy to the previous delegate. + // When the submitted request then nulls the flow state (delegate/policyDiff) while the screen is still mounted await act(async () => { await Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, {delegate: PREVIOUS_DELEGATE_EMAIL, policyDiff: null}); }); await waitForBatchedUpdatesWithAct(); + // Then the page keeps rendering what was submitted rather than turning into the not found page or flipping the copy to the previous delegate expect(screen.queryByText(TestHelper.translateLocal('notFound.notHere'))).not.toBeOnTheScreen(); expect(screen.getByText('Member Workspace')).toBeOnTheScreen(); expect(screen.getByText(DELEGATE_EMAIL)).toBeOnTheScreen(); @@ -534,17 +590,19 @@ describe('VacationDelegateMissingWorkspacesPage', () => { }); it('keeps rendering the current flow while a back navigation action has not been handled', async () => { + // Given Navigation.goBack stubbed out, so pressing back does not itself complete the navigation const goBackSpy = jest.spyOn(Navigation, 'goBack').mockImplementation(() => {}); await seedVacationDelegate({adminPolicies: [], nonAdminPolicies: [MEMBER_POLICY_ID]}); renderPage(); await waitForBatchedUpdatesWithAct(); + // When the back button is pressed fireEvent.press(screen.getByLabelText(TestHelper.translateLocal('common.back'))); await waitForBatchedUpdatesWithAct(); + // Then Navigation.goBack is asked to go to the selection page, but the rollback itself runs from the + // route-removal handler, not from the back press, so nothing can flash while Navigation is still waiting on the transition expect(goBackSpy).toHaveBeenCalledWith(ROUTES.SETTINGS_VACATION_DELEGATE); - - // The rollback runs from the route-removal handler, not from the back press, so nothing can flash while Navigation is still waiting on the transition. const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); expect(vacationDelegate?.pendingDelegate).toBe(DELEGATE_EMAIL); expect(vacationDelegate?.policyDiff).not.toBeFalsy(); diff --git a/tests/ui/VacationDelegatePageTest.tsx b/tests/ui/VacationDelegatePageTest.tsx index 82df887b8d6b..7faeaf2f7cfd 100644 --- a/tests/ui/VacationDelegatePageTest.tsx +++ b/tests/ui/VacationDelegatePageTest.tsx @@ -140,14 +140,18 @@ describe('VacationDelegatePage', () => { }); it('calls setVacationDelegate once for a single selection', async () => { + // Given a signed-in creator (see beforeEach) and a side-effect request mocked to succeed, so only what the + // page sends and does next is under test apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve({jsonCode: CONST.JSON_CODE.SUCCESS})); renderPage(); await waitForBatchedUpdatesWithAct(); + // When a delegate row is tapped fireEvent.press(screen.getByTestId('select-delegate-a')); await waitForBatchedUpdatesWithAct(); + // Then setVacationDelegate is sent exactly once with the mapped params, and the page navigates back to the status page expect(apiSideEffectSpy).toHaveBeenCalledTimes(1); expect(apiSideEffectSpy).toHaveBeenCalledWith( SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, @@ -157,8 +161,9 @@ describe('VacationDelegatePage', () => { expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.SETTINGS_STATUS); }); - // Regression: an EXP_ERROR response's server-provided message must reach the error modal instead of being dropped for the generic copy. it('surfaces the EXP_ERROR response message in the error modal and restores the previous delegate on dismissal', async () => { + // Given a response carrying a server-provided EXP_ERROR message, since a regression once dropped this message + // for the generic copy and this page owns the guarantee that it reaches the user const EXP_ERROR_MESSAGE = 'This delegate has already been assigned as your submitsTo approver.'; apiSideEffectSpy = jest .spyOn(require('@libs/API'), 'makeRequestWithSideEffects') @@ -169,9 +174,11 @@ describe('VacationDelegatePage', () => { renderPage(); await waitForBatchedUpdatesWithAct(); + // When a delegate row is tapped and the request fails fireEvent.press(screen.getByTestId('select-delegate-a')); await waitForBatchedUpdatesWithAct(); + // Then the server's own message reaches the error modal, the page does not navigate away, and no delegate or error is left behind expect(mockShowConfirmModal).toHaveBeenCalledWith(expect.objectContaining({prompt: EXP_ERROR_MESSAGE})); expect(Navigation.goBack).not.toHaveBeenCalled(); @@ -180,30 +187,34 @@ describe('VacationDelegatePage', () => { expect(vacationDelegate?.errors).toBeFalsy(); }); - // Without a server message (e.g. a non-EXP_ERROR failure, or a transport rejection), the generic translation is used instead. it('falls back to the generic error copy when the response carries no EXP_ERROR message', async () => { + // Given a failure response with no server-provided message, e.g. a non-EXP_ERROR failure or a transport rejection apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.resolve({jsonCode: CONST.JSON_CODE.BAD_REQUEST})); renderPage(); await waitForBatchedUpdatesWithAct(); + // When a delegate row is tapped and the request fails fireEvent.press(screen.getByTestId('select-delegate-a')); await waitForBatchedUpdatesWithAct(); + // Then the generic translation is used instead of a blank or missing prompt expect(mockShowConfirmModal).toHaveBeenCalledWith(expect.objectContaining({prompt: TestHelper.translateLocal('statusPage.vacationDelegateError')})); }); it('ignores a second row selection while the first request is still pending', async () => { - // Never resolves, so the request stays "in flight" for the duration of the test. + // Given a request that never resolves, so the first pick stays "in flight" for the duration of the test apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => new Promise(() => {})); renderPage(); await waitForBatchedUpdatesWithAct(); + // When a second row is tapped before the first request settles fireEvent.press(screen.getByTestId('select-delegate-a')); fireEvent.press(screen.getByTestId('select-delegate-b')); await waitForBatchedUpdatesWithAct(); + // Then only the first pick is sent, so a fast double tap cannot fire two overlapping requests expect(apiSideEffectSpy).toHaveBeenCalledTimes(1); expect(apiSideEffectSpy).toHaveBeenCalledWith( SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, @@ -213,7 +224,8 @@ describe('VacationDelegatePage', () => { }); it('rolls back the optimistic delegate instead of leaving a stuck pending row when the request rejects', async () => { - // Simulates a transport failure (e.g. connection dropped after the tap), which rejects rather than resolving with a jsonCode. + // Given a request that rejects outright, simulating a transport failure (e.g. connection dropped after the tap) + // rather than a resolved response with a jsonCode apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => Promise.reject(new Error('Failed to fetch'))); // jest.mock's factory functions (unlike jest.spyOn) are not reset by jest.restoreAllMocks() in afterEach, so call counts otherwise leak across tests in this file. jest.mocked(Navigation.goBack).mockClear(); @@ -221,26 +233,29 @@ describe('VacationDelegatePage', () => { renderPage(); await waitForBatchedUpdatesWithAct(); + // When a delegate row is tapped and the request rejects fireEvent.press(screen.getByTestId('select-delegate-a')); await waitForBatchedUpdatesWithAct(); + // Then the optimistic delegate and any error are rolled back instead of left stuck, and the page does not navigate away expect(Navigation.goBack).not.toHaveBeenCalled(); const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); expect(vacationDelegate?.pendingAction).toBeFalsy(); expect(vacationDelegate?.delegate).toBeFalsy(); expect(vacationDelegate?.errors).toBeFalsy(); - // A second tap must not be ignored as "still pending" once the failed request has settled. + // When the same row is tapped again after the failed request has settled fireEvent.press(screen.getByTestId('select-delegate-a')); await waitForBatchedUpdatesWithAct(); + + // Then the new tap is not ignored as "still pending", since the earlier request already settled expect(apiSideEffectSpy).toHaveBeenCalledTimes(2); }); it('rolls back to the last confirmed delegate, not to an unconfirmed one, when a previous change is still unresolved', async () => { - // Never resolves, so the optimistic write for the second selection stays in place for the assertion. + // Given a request that never resolves, so the optimistic write for the second selection stays in place for the + // assertion, and an NVP already left in the state a failed change the user has not dismissed leaves behind: delegateA shown but never saved apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation(() => new Promise(() => {})); - - // What a failed change the user has not dismissed yet leaves behind: delegateA is shown but was never saved. await act(async () => { await Onyx.set(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { creator: CREATOR_EMAIL, @@ -253,10 +268,12 @@ describe('VacationDelegatePage', () => { renderPage(); await waitForBatchedUpdatesWithAct(); + // When a different delegate is picked fireEvent.press(screen.getByTestId('select-delegate-b')); await waitForBatchedUpdatesWithAct(); - // The API call is mocked out, so the optimistic data it was handed is where the rollback target is visible. + // Then the rollback target is the original confirmed delegate, not the unconfirmed delegateA; the API call is + // mocked out, so the optimistic data it was handed is where that target is visible expect(apiSideEffectSpy).toHaveBeenLastCalledWith( SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, expect.objectContaining({vacationDelegateEmail: DELEGATE_B_EMAIL}), @@ -267,6 +284,7 @@ describe('VacationDelegatePage', () => { }); it('rolls back the optimistic delegate instead of leaving an unconfirmed policy diff behind when the screen loses focus before the response resolves', async () => { + // Given a request whose resolution is held open, so the screen can navigate away before it settles let resolveSideEffect: (response: {jsonCode: number; data?: {policyDiff: unknown}}) => void = () => {}; apiSideEffectSpy = jest.spyOn(require('@libs/API'), 'makeRequestWithSideEffects').mockImplementation( () => @@ -282,18 +300,20 @@ describe('VacationDelegatePage', () => { fireEvent.press(screen.getByTestId('select-delegate-a')); await waitForBatchedUpdatesWithAct(); - // Simulates the user swiping the RHP away (or otherwise navigating off this screen) before the request settles. + // When the screen loses focus before the request settles, simulating the user swiping the RHP away (or otherwise navigating off this screen) await act(async () => { testNavigationRef.current?.navigate('Other'); await waitForBatchedUpdatesWithAct(); }); + // When the request then resolves with a 305 policy diff warning await act(async () => { resolveSideEffect({jsonCode: CONST.JSON_CODE.POLICY_DIFF_WARNING, data: {policyDiff: {adminPolicies: [], nonAdminPolicies: []}}}); await waitForBatchedUpdatesWithAct(); }); - // There's no longer a screen to carry the user into the missing-workspaces step, so it must not be pushed onto whatever they navigated to instead. + // Then the missing-workspaces step is not pushed onto whatever the user navigated to instead, since there's no longer a + // screen to carry them into it, and the optimistic delegate/policy diff are rolled back rather than left unconfirmed expect(Navigation.navigate).not.toHaveBeenCalledWith(ROUTES.SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES); const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); diff --git a/tests/unit/PolicySelectorTest.ts b/tests/unit/PolicySelectorTest.ts index 8f2142d1804c..402918bd1d55 100644 --- a/tests/unit/PolicySelectorTest.ts +++ b/tests/unit/PolicySelectorTest.ts @@ -152,25 +152,41 @@ describe('createPoliciesByIDsSelector', () => { }; it('returns an empty object for an empty ID list without touching the collection', () => { + // Given a selector built with no policy IDs to select + // When it runs against a populated policy collection + // Then it short-circuits to an empty object instead of iterating a collection it has nothing to pick from expect(createPoliciesByIDsSelector([])(allPolicies)).toEqual({}); }); it('returns an empty object when the collection is undefined', () => { + // Given requested IDs but a policies collection that has not loaded into Onyx yet + // When the selector runs against that undefined collection + // Then it returns an empty object rather than throwing on the missing collection expect(createPoliciesByIDsSelector(['1', '2'])(undefined)).toEqual({}); }); it('returns only the requested keys and drops policies that were not requested', () => { + // Given a collection with policies beyond the ones being requested + // When the selector is built for a subset of the IDs in that collection + // Then only the requested keys come back, so callers get exactly the policies they asked for and nothing more const result = createPoliciesByIDsSelector(['1', '3'])(allPolicies); expect(Object.keys(result).sort()).toEqual([`${P}1`, `${P}3`]); }); it('omits a requested ID that has no policy in the collection', () => { + // Given a requested ID with no matching entry in the collection, alongside one that does exist + // When the selector runs + // Then the missing ID is left out entirely rather than appearing with an undefined/null value const result = createPoliciesByIDsSelector(['1', 'missing'])(allPolicies); expect(Object.keys(result)).toEqual([`${P}1`]); expect(`${P}missing` in result).toBe(false); }); it('returns the same object references as the input, without copying or narrowing', () => { + // Given policy objects already stored in the collection + // When the selector picks a subset of them + // Then it hands back the exact same references, since copying here would break callers relying on + // reference equality (e.g. memoized components) to skip unnecessary re-renders const result = createPoliciesByIDsSelector(['1', '2'])(allPolicies); expect(result[`${P}1`]).toBe(policy1); expect(result[`${P}2`]).toBe(policy2); diff --git a/tests/unit/components/VacationDelegateMenuItemTest.tsx b/tests/unit/components/VacationDelegateMenuItemTest.tsx index f9b4b0c0631f..76da533e9261 100644 --- a/tests/unit/components/VacationDelegateMenuItemTest.tsx +++ b/tests/unit/components/VacationDelegateMenuItemTest.tsx @@ -98,6 +98,7 @@ describe('VacationDelegateMenuItem', () => { describe('existing account (personal details available)', () => { it('renders the displayName for an email delegate without `@expensify.sms` artifacts', () => { + // Given an existing account with personal details already loaded: an email login and a real display name const personalDetails: PersonalDetails = { accountID: 42, login: EMAIL_DELEGATE, @@ -105,17 +106,19 @@ describe('VacationDelegateMenuItem', () => { }; mockUseVacationDelegatePersonalDetails.mockReturnValue(personalDetails); + // When the menu item renders that delegate renderMenuItem(EMAIL_DELEGATE); + // Then the name, email, and avatar all show as-is, since an email login has no SMS artifacts to strip expect(screen.getByText('Jane Doe')).toBeTruthy(); expect(screen.getByText(EMAIL_DELEGATE)).toBeTruthy(); expect(lastAvatarProps().accountID).toBe(42); expect(screen.queryByText(/@expensify\.sms/)).toBeNull(); }); - // The backend defaults `displayName` to the login, so the title has to be formatted rather than - // shown as the raw E.164 login it is. it('renders the localized phone number for a phone-number account that has no name of its own', () => { + // Given a phone-number account whose displayName the backend defaulted to the raw login (its SMS + // domain still attached), because it has no name of its own const personalDetails: PersonalDetails = { accountID: 43, login: PHONE_DELEGATE_WITH_SMS_DOMAIN, @@ -123,17 +126,19 @@ describe('VacationDelegateMenuItem', () => { }; mockUseVacationDelegatePersonalDetails.mockReturnValue(personalDetails); + // When the menu item renders that delegate renderMenuItem(PHONE_DELEGATE_WITH_SMS_DOMAIN); + // Then the title is formatted into the localized number rather than shown as the raw E.164 login it is expect(textOccurrences(PHONE_DELEGATE_LOCALIZED)).toBe(2); expect(screen.queryByText(PHONE_DELEGATE_RAW)).toBeNull(); expect(lastAvatarProps().accountID).toBe(43); expect(screen.queryByText(/@expensify\.sms/)).toBeNull(); }); - // Bug #89578 — the reported case: the backend hands back the login as the display name with the SMS domain - // already stripped, which used to reach the title as a raw E.164 number complete with its country code. it('localizes a display name that is the login without its SMS domain', () => { + // Given Bug #89578's reported case: Auth hands back the login as the display name but with the SMS + // domain already stripped off const personalDetails: PersonalDetails = { accountID: 43, login: PHONE_DELEGATE_WITH_SMS_DOMAIN, @@ -141,13 +146,17 @@ describe('VacationDelegateMenuItem', () => { }; mockUseVacationDelegatePersonalDetails.mockReturnValue(personalDetails); + // When the menu item renders that delegate renderMenuItem(PHONE_DELEGATE_WITH_SMS_DOMAIN); + // Then the title is still localized instead of reaching the screen as a raw E.164 number, which is + // what used to happen because stripping the domain alone isn't the same as detecting a phone number expect(textOccurrences(PHONE_DELEGATE_LOCALIZED)).toBe(2); expect(screen.queryByText(PHONE_DELEGATE_RAW)).toBeNull(); }); it('keeps the name a phone-number account did set, and localizes the number below it', () => { + // Given a phone-number account that did set a real display name of its own const personalDetails: PersonalDetails = { accountID: 44, login: PHONE_DELEGATE_WITH_SMS_DOMAIN, @@ -155,16 +164,19 @@ describe('VacationDelegateMenuItem', () => { }; mockUseVacationDelegatePersonalDetails.mockReturnValue(personalDetails); + // When the menu item renders that delegate renderMenuItem(PHONE_DELEGATE_WITH_SMS_DOMAIN); + // Then the real name is left untouched and only the number below it is localized, so formatting logic + // built for the no-name case doesn't clobber a name the account actually has expect(screen.getByText('Jane Doe')).toBeTruthy(); expect(textOccurrences(PHONE_DELEGATE_LOCALIZED)).toBe(1); expect(screen.queryByText(PHONE_DELEGATE_RAW)).toBeNull(); }); - // A local contact is stored without the country code. Personal details come back as E.164, which used - // to become the title so the status page showed `+91…` above a localized number. it('localizes an E.164 display name when the login is still the national form', () => { + // Given a local contact stored without its country code, whose personal details came back as E.164 — + // which used to become the title verbatim, showing `+91…` above a localized number underneath it const personalDetails: PersonalDetails = { accountID: 45, login: '9789942470@expensify.sms', @@ -172,8 +184,11 @@ describe('VacationDelegateMenuItem', () => { }; mockUseVacationDelegatePersonalDetails.mockReturnValue(personalDetails); + // When the menu item renders that delegate renderMenuItem('9789942470@expensify.sms'); + // Then the title is localized rather than left as the raw E.164 display name, and the login below it + // still shows in its stored national form expect(screen.getByText(PHONE_DELEGATE_LOCALIZED)).toBeTruthy(); expect(screen.getByText('9789942470')).toBeTruthy(); expect(screen.queryByText(PHONE_DELEGATE_RAW)).toBeNull(); @@ -182,20 +197,29 @@ describe('VacationDelegateMenuItem', () => { describe('new account (personal details missing, e.g. after cache clear)', () => { it('renders the raw email as title and description when no personal details exist', () => { + // Given a new account with no personal details loaded yet (e.g. right after a cache clear), for an + // email delegate mockUseVacationDelegatePersonalDetails.mockReturnValue(undefined); + // When the menu item renders that delegate renderMenuItem(EMAIL_DELEGATE); + // Then the raw email is used for both title and description, since there is no name to fall back on + // and an email login has nothing to format expect(textOccurrences(EMAIL_DELEGATE)).toBe(2); expect(screen.queryByText(/@expensify\.sms/)).toBeNull(); }); - // Bug #89578 — the exact scenario reported. it('renders the localized phone number when no personal details exist', () => { + // Given Bug #89578's exact reported scenario: no personal details loaded at all, for a phone-number + // delegate mockUseVacationDelegatePersonalDetails.mockReturnValue(undefined); + // When the menu item renders that delegate renderMenuItem(PHONE_DELEGATE_WITH_SMS_DOMAIN); + // Then the number is still localized from the login alone, rather than showing the raw E.164 login + // or leaking its SMS domain expect(textOccurrences(PHONE_DELEGATE_LOCALIZED)).toBe(2); expect(screen.queryByText(PHONE_DELEGATE_RAW)).toBeNull(); expect(screen.queryByText(/@expensify\.sms/)).toBeNull(); @@ -203,17 +227,23 @@ describe('VacationDelegateMenuItem', () => { }); it('renders the empty-state menu item when no vacation delegate is set', () => { + // Given no vacation delegate has ever been set + // When the menu item renders renderMenuItem(); + // Then the empty-state label shows and no avatar is rendered, since there is no delegate to show one for expect(screen.getByText('common.vacationDelegate')).toBeTruthy(); expect(capturedAvatarProps).toHaveLength(0); }); - // OfflineWithFeedback hides a row that is pending deletion, which blanked the whole field for the length of the - // request. Removing the delegate empties this field, it does not remove the row, so the empty state has to show at once. it('shows the empty state immediately while the delegate removal is in flight', () => { + // Given a delegate removal that is still in flight: OfflineWithFeedback hides a row pending deletion, which + // used to blank the whole field for the length of the request, and removing the delegate empties this + // field rather than removing the row + // When the menu item renders with no delegate and a DELETE pendingAction renderMenuItem(undefined, CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + // Then the empty state shows at once instead of a blank field for the duration of the request expect(screen.getByText('common.vacationDelegate')).toBeTruthy(); }); }); diff --git a/tests/unit/libs/getVacationDelegateErrors.test.ts b/tests/unit/libs/getVacationDelegateErrors.test.ts index 77c76de9bfd0..dd34320b7b6b 100644 --- a/tests/unit/libs/getVacationDelegateErrors.test.ts +++ b/tests/unit/libs/getVacationDelegateErrors.test.ts @@ -12,21 +12,33 @@ const ERRORS = {1788970253939928: "Vacation delegate is not part of all of vacat describe('getVacationDelegateErrors', () => { it('hides errors that arrive while the change is still in flight', () => { - // This is the 305 policy diff warning: Auth ships an errors payload with a prompt that is not a failure, - // and it lands while the optimistic pendingAction is still set. + // Given the 305 policy diff warning, which ships an errors payload as a confirmation prompt rather than a + // failure, landing while the optimistic pendingAction from an update is still set + // When getVacationDelegateErrors evaluates that vacation delegate + // Then no error is surfaced, since surfacing it here would flash a red brick road for a prompt, not a failure expect(getVacationDelegateErrors({errors: ERRORS, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE})).toBeUndefined(); }); it('hides errors while a delete is still in flight', () => { + // Given the same 305 payload landing during a delete's optimistic pendingAction instead of an update's + // When getVacationDelegateErrors evaluates that vacation delegate + // Then no error is surfaced, because the gate has to cover every pendingAction, not just update expect(getVacationDelegateErrors({errors: ERRORS, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE})).toBeUndefined(); }); it('surfaces errors once the change has settled, which is how every real failure is written', () => { + // Given the errors a real failure leaves behind, with pendingAction cleared (explicitly null, or simply + // absent) the same way every genuine failing write clears it alongside the errors it attaches + // When getVacationDelegateErrors evaluates that vacation delegate + // Then the errors are surfaced, since nothing is left in flight to gate them expect(getVacationDelegateErrors({errors: ERRORS, pendingAction: null})).toEqual(ERRORS); expect(getVacationDelegateErrors({errors: ERRORS})).toEqual(ERRORS); }); it('returns nothing when there is no error to show', () => { + // Given a vacation delegate with no errors field at all, or no vacation delegate NVP at all + // When getVacationDelegateErrors evaluates either one + // Then it returns undefined instead of throwing, since both are the normal, error-free state expect(getVacationDelegateErrors({delegate: 'delegate@test.com'})).toBeUndefined(); expect(getVacationDelegateErrors(undefined)).toBeUndefined(); }); From e75bec323cadbcc0cda519b93cd30b9fdefe0685 Mon Sep 17 00:00:00 2001 From: jakubstec Date: Thu, 17 Sep 2026 11:54:37 +0200 Subject: [PATCH 23/23] fix: add missing comments to DomainTest.ts --- tests/actions/DomainTest.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/actions/DomainTest.ts b/tests/actions/DomainTest.ts index 2a0df625e886..a016e40dfd4a 100644 --- a/tests/actions/DomainTest.ts +++ b/tests/actions/DomainTest.ts @@ -486,6 +486,7 @@ describe('actions/Domain', () => { describe('setDomainVacationDelegate', () => { it('sends SET_VACATION_DELEGATE request with ADD pending action when no existing delegate', () => { + // Given a domain member with no vacation delegate set yet const apiWriteSpy = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); const domainAccountID = 123; const domainMemberAccountID = 456; @@ -495,8 +496,12 @@ describe('actions/Domain', () => { const PRIVATE_VACATION_DELEGATE_KEY = `${CONST.DOMAIN.PRIVATE_VACATION_DELEGATE_PREFIX}${domainMemberAccountID}` as const satisfies `${typeof CONST.DOMAIN.PRIVATE_VACATION_DELEGATE_PREFIX}${string}`; + // When a domain admin sets a delegate for that member setDomainVacationDelegate(domainAccountID, domainMemberAccountID, creator, vacationer, delegate); + // Then the request is a persisted write with overridePolicyDiffWarning always true, since a domain admin + // acting on someone else's behalf skips the per-workspace confirmation step entirely, and the pending + // action is ADD because no delegate existed before this pick expect(apiWriteSpy).toHaveBeenCalledWith( WRITE_COMMANDS.SET_VACATION_DELEGATE, {creator, vacationerEmail: vacationer, vacationDelegateEmail: delegate, overridePolicyDiffWarning: true, domainAccountID}, @@ -547,6 +552,7 @@ describe('actions/Domain', () => { }); it('uses UPDATE pending action when existing delegate is present', () => { + // Given a domain member who already has a vacation delegate set const apiWriteSpy = jest.spyOn(require('@libs/API'), 'write').mockImplementation(() => Promise.resolve()); const domainAccountID = 123; const domainMemberAccountID = 456; @@ -555,8 +561,10 @@ describe('actions/Domain', () => { const delegate = 'newdelegate@test.com'; const existingVacationDelegate: BaseVacationDelegate = {delegate: 'olddelegate@test.com'}; + // When a domain admin changes that member's delegate to someone new setDomainVacationDelegate(domainAccountID, domainMemberAccountID, creator, vacationer, delegate, existingVacationDelegate); + // Then the pending action is UPDATE rather than ADD, since a delegate was already in place before this pick expect(apiWriteSpy).toHaveBeenCalledWith( WRITE_COMMANDS.SET_VACATION_DELEGATE, expect.any(Object),