diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 423dfdeecd48..edee8c5ad5b5 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2612,6 +2612,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_TROUBLESHOOT_BETA_OVERRIDES: 'settings/troubleshoot/beta-overrides', SETTINGS_HELP: 'settings/help', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 6aaf8e51fe24..a7a04749c245 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -111,6 +111,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/components/VacationDelegateMenuItem.tsx b/src/components/VacationDelegateMenuItem.tsx index c2b624c59dcd..ff3c58528440 100644 --- a/src/components/VacationDelegateMenuItem.tsx +++ b/src/components/VacationDelegateMenuItem.tsx @@ -57,6 +57,7 @@ function VacationDelegateMenuItem({vacationDelegate, errors, pendingAction, onCl errorRowStyles={styles.mh5} onClose={onCloseError} style={hasVacationDelegate && styles.mt4} + shouldHideOnDelete={false} > {hasVacationDelegate ? ( `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: (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:', + }, }, stepCounter: (step: number, total?: number, text?: string) => { let result = `Schritt ${step}`; @@ -4692,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 f364d9ced5ec..ab1c6f8e0708 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -3816,8 +3816,15 @@ ${amount} για ${merchant} - ${date}`, vacationDelegateError: 'Παρουσιάστηκε σφάλμα κατά την ενημέρωση του αναπληρωτή σας για την άδεια.', asVacationDelegate: (nameOrEmail: string) => `ως εκπρόσωπος αδειών του/της ${nameOrEmail}`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `στον/στη ${submittedToName} ως αναπληρωτής/τρια αδειούχου για τον/τη ${vacationDelegateName}`, - vacationDelegateWarning: (nameOrEmail: string) => - `Ορίζετε τον/την ${nameOrEmail} ως αναπληρωτή/τριά σας κατά τις διακοπές. Δεν συμμετέχει ακόμη σε όλους τους χώρους εργασίας σας. Αν επιλέξετε να συνεχίσετε, θα σταλεί email σε όλους τους διαχειριστές των χώρων εργασίας σας για να τον/την προσθέσουν.`, + vacationDelegate: { + notAMemberAdminsWillBeAsked: (delegate: string) => + `Ο/Η ${delegate} δεν είναι μέλος των παρακάτω χώρων εργασίας. Θα ζητηθεί από τους διαχειριστές των χώρων εργασίας που δεν διαχειρίζεστε να τον/την προσθέσουν.`, + notAMemberInviteThemNow: (delegate: string) => `Ο/Η ${delegate} δεν είναι μέλος των παρακάτω χώρων εργασίας. Θέλετε να τον/την προσκαλέσετε τώρα;`, + notAMemberMixed: (delegate: string) => + `Ο/Η ${delegate} δεν είναι μέλος των παρακάτω χώρων εργασίας. Θα ζητηθεί από τους διαχειριστές των χώρων εργασίας που δεν διαχειρίζεστε να τον/την προσθέσουν. Θέλετε να τον/την προσκαλέσετε τώρα σε αυτούς που διαχειρίζεστε ως διαχειριστής;`, + youAreAMemberOf: 'Είστε μέλος αυτών των χώρων εργασίας:', + youAreAnAdminOf: 'Είστε διαχειριστής αυτών των χώρων εργασίας:', + }, }, stepCounter: (step, total, text) => { let result = `Βήμα ${step}`; @@ -4767,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 6517d08c40f2..bf008db4c97c 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3869,8 +3869,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: (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:', + }, }, stepCounter: (step: number, total?: number, text?: string) => { let result = `Step ${step}`; @@ -4815,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 83170a770a9c..69a493ed9b57 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3686,8 +3686,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: (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:', + }, }, stepCounter: (step, total, text) => { let result = `Paso ${step}`; @@ -4625,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 24a200c3e4eb..94a0df388f05 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -3769,8 +3769,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: (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 :', + }, }, stepCounter: (step: number, total?: number, text?: string) => { let result = `Étape ${step}`; @@ -4700,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 9577bb5de973..54bbafb04f10 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -3745,8 +3745,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: (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:', + }, }, stepCounter: (step: number, total?: number, text?: string) => { let result = `Passaggio ${step}`; @@ -4665,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 9ab28f5a7bca..9da42567d9fe 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -3717,8 +3717,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: (delegate: string) => + `${delegate}は以下のワークスペースのメンバーではありません。あなたが管理していないワークスペースの管理者に追加を依頼します。`, + notAMemberInviteThemNow: (delegate: string) => `${delegate}は以下のワークスペースのメンバーではありません。今すぐ招待しますか?`, + notAMemberMixed: (delegate: string) => + `${delegate}は以下のワークスペースのメンバーではありません。あなたが管理していないワークスペースの管理者に追加を依頼します。あなたが管理者になっているワークスペースには今すぐ招待しますか?`, + youAreAMemberOf: 'あなたはこれらのワークスペースのメンバーです:', + youAreAnAdminOf: 'あなたはこれらのワークスペースの管理者です:', + }, }, stepCounter: (step: number, total?: number, text?: string) => { let result = `ステップ ${step}`; @@ -4630,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 1021e64e35e4..30c6d9ef8e17 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -3745,8 +3745,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: (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:', + }, }, stepCounter: (step: number, total?: number, text?: string) => { let result = `Stap ${step}`; @@ -4664,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 d6174dd76eb6..59fed0074ac0 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -3775,8 +3775,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: (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:', + }, }, stepCounter: (step: number, total?: number, text?: string) => { let result = `Krok ${step}`; @@ -4694,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 cc065ca59792..11f6f62b2cb4 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -3734,8 +3734,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: (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:', + }, }, stepCounter: (step: number, total?: number, text?: string) => { let result = `Etapa ${step}`; @@ -4658,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 783bfce75a28..07f81ad16d73 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -3627,8 +3627,14 @@ ${amount},商户:${merchant} - 日期:${date}`, vacationDelegateError: '更新你的休假代理时出错。', asVacationDelegate: (nameOrEmail: string) => `作为 ${nameOrEmail} 的休假代理`, toAsVacationDelegate: (submittedToName: string, vacationDelegateName: string) => `作为 ${vacationDelegateName} 的休假代理人提交给 ${submittedToName}`, - vacationDelegateWarning: (nameOrEmail: string) => - `您正在将 ${nameOrEmail} 设为您的休假代理人。TA 还未加入您所有的工作区。如果继续操作,将会向您所有工作区的管理员发送一封邮件,请他们将 TA 添加进来。`, + vacationDelegate: { + notAMemberAdminsWillBeAsked: (delegate: string) => `${delegate} 不是以下工作区的成员。系统会请求你未管理的工作区的管理员将其添加进来。`, + notAMemberInviteThemNow: (delegate: string) => `${delegate} 不是以下工作区的成员。你要现在邀请他们吗?`, + notAMemberMixed: (delegate: string) => + `${delegate} 不是以下工作区的成员。系统会请求你未管理的工作区的管理员将其添加进来。你要现在邀请他们加入你管理的工作区吗?`, + youAreAMemberOf: '你是这些工作区的成员:', + youAreAnAdminOf: '你是这些工作区的管理员:', + }, }, stepCounter: (step: number, total?: number, text?: string) => { let result = `步骤 ${step}`; @@ -4518,6 +4524,7 @@ ${amount},商户:${merchant} - 日期:${date}`, defaultDescription: '一个集中管理所有收据和报销费用的地方。', descriptionHint: '与所有成员共享此工作区的信息。', welcomeNote: '请使用 Expensify 提交报销收据,谢谢!', + invitedYouToWorkspace: (inviterName: string, workspaceName: string) => `# ${inviterName} 邀请你加入 ${workspaceName}`, subscription: '订阅', markAsEntered: '标记为手动输入', markAsExported: '标记为已导出', diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index b15ccac38be2..e46746ac5a05 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -668,6 +668,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', @@ -1150,6 +1151,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_POLICY_REQUIRE_MAP_OR_GPS]: Parameters.SetPolicyRequireMapOrGPSParams; [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 9b6bd6a29d0e..8744222b42a1 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -524,6 +524,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 8d15bf8fbc22..f104941ab082 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts @@ -12,6 +12,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 ca183bcd0e83..7c66216968db 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -329,6 +329,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/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/Domain.ts b/src/libs/actions/Domain.ts index def7b3e2b9a7..b388bc96dcbc 100644 --- a/src/libs/actions/Domain.ts +++ b/src/libs/actions/Domain.ts @@ -1491,9 +1491,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/libs/actions/VacationDelegate.ts b/src/libs/actions/VacationDelegate.ts index 5398ff068dba..9497a1c67975 100644 --- a/src/libs/actions/VacationDelegate.ts +++ b/src/libs/actions/VacationDelegate.ts @@ -1,18 +1,37 @@ +import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleContextProvider'; + import * as API 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'; -function setVacationDelegate(creator: string, delegate: string, shouldOverridePolicyDiffWarning = false, currentDelegate?: string) { +import type {CurrentUser} from './Policy/Policy'; + +import {addMembersToWorkspace} from './Policy/Member'; + +type SetVacationDelegateOptions = { + creator: string; + delegate: string; + currentDelegate?: string; + shouldOverridePolicyDiffWarning?: boolean; +}; + +async function setVacationDelegate({creator, delegate, currentDelegate, shouldOverridePolicyDiffWarning = false}: SetVacationDelegateOptions) { const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -22,7 +41,9 @@ function setVacationDelegate(creator: string, delegate: string, shouldOverridePo delegate, errors: null, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - previousDelegate: currentDelegate, + previousDelegate: currentDelegate ?? null, + policyDiff: null, + pendingDelegate: null, }, }, ]; @@ -35,6 +56,8 @@ function setVacationDelegate(creator: string, delegate: string, shouldOverridePo errors: null, pendingAction: null, previousDelegate: null, + policyDiff: null, + pendingDelegate: null, }, }, ]; @@ -45,6 +68,7 @@ function setVacationDelegate(creator: string, delegate: string, shouldOverridePo key: ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, value: { errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('statusPage.vacationDelegateError'), + pendingAction: null, }, }, ]; @@ -55,9 +79,42 @@ function setVacationDelegate(creator: string, delegate: string, shouldOverridePo overridePolicyDiffWarning: shouldOverridePolicyDiffWarning, }; - // 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; + } + + // 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. + // 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 + // 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}); + + if (response?.jsonCode === CONST.JSON_CODE.POLICY_DIFF_WARNING && response.data?.policyDiff) { + // 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, + }); + } else if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { + Onyx.update(failureData); + } + + return response; } function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { @@ -76,6 +133,8 @@ function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { errors: null, previousDelegate: vacationDelegate?.delegate, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + policyDiff: null, + pendingDelegate: null, }, }, ]; @@ -87,6 +146,7 @@ function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { value: { errors: null, pendingAction: null, + previousDelegate: null, }, }, ]; @@ -99,6 +159,7 @@ function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { creator, delegate, errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('statusPage.vacationDelegateError'), + pendingAction: null, }, }, ]; @@ -112,7 +173,64 @@ function clearVacationDelegateError(previousDelegate?: string) { pendingAction: null, delegate: previousDelegate ?? null, previousDelegate: null, + policyDiff: null, + pendingDelegate: null, }); } -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}, + `${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, + inviter, + policyExpenseChatReportActions, + ); + } +} + +export {setVacationDelegate, deleteVacationDelegate, clearVacationDelegateError, inviteVacationDelegateToWorkspaces}; 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 4319f583bfc6..813c0630b014 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/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesFooter.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesFooter.tsx new file mode 100644 index 000000000000..94ae45eecedc --- /dev/null +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesFooter.tsx @@ -0,0 +1,63 @@ +import Button from '@components/Button'; +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 inviting them into the workspaces the user administers, backing both Skip and Confirm */ + onSkip: () => void; +}; + +function MissingWorkspacesFooter({canInvite, isInviteDisabled, onInvite, onSkip}: MissingWorkspacesFooterProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + if (!canInvite) { + return ( + + + + ); + } + + return ( + + + + + ); +} + +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..9cb072a704c1 --- /dev/null +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/MissingWorkspacesIntro.tsx @@ -0,0 +1,48 @@ +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'; +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(); + const delegatePersonalDetails = useVacationDelegatePersonalDetails(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); + } else if (hasNonAdminWorkspaces) { + copy = translate('statusPage.vacationDelegate.notAMemberMixed', escapedDelegate); + } else { + copy = translate('statusPage.vacationDelegate.notAMemberInviteThemNow', escapedDelegate); + } + + return ( + + + + ); +} + +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..3231f3fa6ff8 --- /dev/null +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/WorkspaceRow.tsx @@ -0,0 +1,50 @@ +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')} + + + + + ); +} + +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..f83c95400f22 --- /dev/null +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/WorkspaceSection.tsx @@ -0,0 +1,48 @@ +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) => ( + + ))} +
+ ); +} + +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..9668e61dab40 --- /dev/null +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx @@ -0,0 +1,174 @@ +/** + * 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 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 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'; + +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 {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, {useEffect, 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?.pendingDelegate ?? ''; + 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])}); + + // 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 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 = (policiesToInvite?: Policy[]) => { + if (isSubmittingRef.current) { + return; + } + + isSubmittingRef.current = true; + + if (policiesToInvite?.length) { + inviteVacationDelegateToWorkspaces({ + delegate, + policies: policiesToInvite, + inviter: { + accountID: currentUserPersonalDetails.accountID, + displayName: currentUserPersonalDetails.displayName, + email: currentUserPersonalDetails.email, + avatar: currentUserPersonalDetails.avatar, + }, + translate, + formatPhoneNumber, + }); + } + + setSubmittedInput({delegate, policyDiff}); + setVacationDelegate({creator, delegate, currentDelegate: previousDelegate, shouldOverridePolicyDiffWarning: true}); + Navigation.goBack(ROUTES.SETTINGS_STATUS); + }; + + return ( + + Navigation.goBack(ROUTES.SETTINGS_VACATION_DELEGATE)} + /> + + 0} + /> + + + + submit(adminWorkspaces)} + onSkip={() => submit()} + /> + + ); +} + +export default VacationDelegateMissingWorkspacesPage; diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx index 1eef1249d08e..d87256cec670 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'; @@ -14,22 +13,22 @@ 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 {Str} from 'expensify-common'; -import React, {useCallback, useEffect, useRef} from 'react'; +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); - const vacationDelegateRef = useRef(vacationDelegate); - useEffect(() => { - vacationDelegateRef.current = vacationDelegate; - }, [vacationDelegate]); - const showErrorModal = async (message?: string) => { + const isSelectingRef = useRef(false); + + const showErrorModal = async (delegateToRestore?: string, message?: string) => { await showConfirmModal({ title: translate('statusPage.addVacationDelegate'), prompt: message ?? translate('statusPage.vacationDelegateError'), @@ -37,59 +36,58 @@ function VacationDelegatePage() { shouldShowCancelButton: false, }); - clearVacationDelegateError(vacationDelegateRef.current?.previousDelegate); + clearVacationDelegateError(delegateToRestore); }; - const showWarningModal = useCallback( - async (delegateLogin: string, delegateDisplayName: string | undefined) => { - const result = await showConfirmModal({ - title: translate('common.headsUp'), - prompt: translate('statusPage.vacationDelegateWarning', Str.removeSMSDomain(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) { - deleteVacationDelegate(vacationDelegate); - Navigation.goBack(ROUTES.SETTINGS_STATUS); - return; - } - - setVacationDelegate(currentUserLogin, option?.login ?? '', false, vacationDelegate?.delegate).then((response) => { - if (!response?.jsonCode) { - Navigation.goBack(ROUTES.SETTINGS_STATUS); + const onSelectRow = (option: Participant) => { + if (isSelectingRef.current) { + return; + } + + if (option?.login === vacationDelegate?.delegate) { + deleteVacationDelegate(vacationDelegate); + Navigation.goBack(ROUTES.SETTINGS_STATUS); + return; + } + + isSelectingRef.current = true; + 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()) { + if (response?.data?.policyDiff) { + clearVacationDelegateError(currentDelegate); + } return; } - if (response.jsonCode === CONST.JSON_CODE.EXP_ERROR) { - showErrorModal(response.message); + if (response?.data?.policyDiff) { + Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES); return; } - if (response.jsonCode === CONST.JSON_CODE.POLICY_DIFF_WARNING) { - showWarningModal(option?.login ?? '', option.text); + // 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; } Navigation.goBack(ROUTES.SETTINGS_STATUS); + }) + .catch(() => { + if (!navigation.isFocused()) { + clearVacationDelegateError(currentDelegate); + return; + } + + showErrorModal(currentDelegate); + }) + .finally(() => { + isSelectingRef.current = false; }); - }, - [currentUserLogin, vacationDelegate, showWarningModal, showErrorModal], - ); + }; return ( { }; }; +/** + * 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, @@ -528,6 +551,7 @@ export { createTimeSensitiveAdminPoliciesSelector, createHasWorkspaceToSubmitToSelector, createPoliciesForDomainCardsSelector, + createPoliciesByIDsSelector, policyTimeTrackingSelector, createIOURequestStartPoliciesSelector, policyMapper, diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts index 309fc2bca9a9..58f469b68d82 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..565181bce169 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,15 @@ 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; + + /** + * 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 */ @@ -24,4 +42,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 7da22f4968f2..457e273510cd 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -209,6 +209,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'; @@ -444,6 +445,7 @@ export type { ScheduleCallDraft, ValidateUserAndGetAccessiblePolicies, VacationDelegate, + VacationDelegatePolicyDiff, BillingReceiptDetails, ExportTemplate, HybridApp, diff --git a/tests/actions/DomainTest.ts b/tests/actions/DomainTest.ts index b2622ba5b3d2..a016e40dfd4a 100644 --- a/tests/actions/DomainTest.ts +++ b/tests/actions/DomainTest.ts @@ -26,7 +26,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'; @@ -486,7 +486,8 @@ 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()); + // 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; const creator = 'admin@test.com'; @@ -495,10 +496,14 @@ 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); - expect(apiSideEffectSpy).toHaveBeenCalledWith( - SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_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}, { optimisticData: expect.arrayContaining([ @@ -543,11 +548,12 @@ 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()); + // 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; const creator = 'admin@test.com'; @@ -555,10 +561,12 @@ 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); - expect(apiSideEffectSpy).toHaveBeenCalledWith( - SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE, + // 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), expect.objectContaining({ optimisticData: expect.arrayContaining([ @@ -570,7 +578,7 @@ describe('actions/Domain', () => { }), ); - apiSideEffectSpy.mockRestore(); + apiWriteSpy.mockRestore(); }); }); diff --git a/tests/actions/VacationDelegateTest.ts b/tests/actions/VacationDelegateTest.ts new file mode 100644 index 000000000000..9fb4c261e0c7 --- /dev/null +++ b/tests/actions/VacationDelegateTest.ts @@ -0,0 +1,355 @@ +import {clearVacationDelegateError, deleteVacationDelegate, 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'; +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 {createGlobalFetchMock, getFetchMockCalls} from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +OnyxUpdateManager(); +describe('actions/VacationDelegate', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(() => { + 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, + { + creator: 'admin@test.com', + vacationDelegateEmail: 'delegate@test.com', + overridePolicyDiffWarning: false, + }, + { + 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}), + }), + ]), + }, + ); + + // 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'); + }); + + 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(); + }); + + 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'}); + 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(); + + // 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(); + + // 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'); + + await mockFetch.resume(); + 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'); + expect(vacationDelegate?.policyDiff).toEqual(policyDiff); + }); + + 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({ + 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}}; + }); + + // 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(); + expect(vacationDelegate?.pendingDelegate).toBe('delegate@test.com'); + expect(vacationDelegate?.delegate).toBe('old@test.com'); + expect(vacationDelegate?.previousDelegate).toBe('old@test.com'); + + // 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); + }); + + 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 () => { + 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}}; + }); + + // When a delegate is picked + await setVacationDelegate({creator: 'admin@test.com', delegate: 'delegate@test.com', currentDelegate: 'old@test.com'}); + await waitForBatchedUpdates(); + + // 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'); + }); + + 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', + 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: []}}}; + }); + + // 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'); + }); + + 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. + 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; + }); + + // 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(); + + // 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'); + }); + + 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', + 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(); + + // 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(); + }); + + 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(); + }); + }); + + 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(); + + // 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(); + + const afterPick = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + clearVacationDelegateError(afterPick?.previousDelegate); + await waitForBatchedUpdates(); + + // Then the deleted delegate (A) does not come back + expect((await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE))?.delegate).toBeFalsy(); + }); + }); + + 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', + delegate: 'delegate@test.com', + previousDelegate: 'old@test.com', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + errors: {[timestamp]: 'Some error'}, + policyDiff: {adminPolicies: ['1'], nonAdminPolicies: []}, + 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(); + 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 new file mode 100644 index 000000000000..91959b13435b --- /dev/null +++ b/tests/ui/VacationDelegateMissingWorkspacesPageTest.tsx @@ -0,0 +1,612 @@ +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 PersonalDetailsByLoginProvider from '@components/PersonalDetailsByLoginProvider'; +import Text from '@components/Text'; + +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'; +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 {Policy} from '@src/types/onyx'; +import type {VacationDelegatePolicyDiff} from '@src/types/onyx/VacationDelegate'; + +import type * as ReactNavigation from '@react-navigation/native'; + +import {PortalProvider} from '@gorhom/portal'; +import {NavigationContainer, StackActions} 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'; + +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 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; + +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: {}, + 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, + }; +} + +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); +} + +/** + * 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; +} + +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; + + return render( + + + + + + + + + + , + ); +} + +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. 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: PREVIOUS_DELEGATE_EMAIL, + pendingDelegate: delegate, + previousDelegate: PREVIOUS_DELEGATE_EMAIL, + policyDiff, + }); + }); +} + +describe('VacationDelegateMissingWorkspacesPage', () => { + let apiSideEffectSpy: jest.SpyInstance; + let apiReadSpy: jest.SpyInstance; + let apiWriteSpy: jest.SpyInstance, ApiWriteCall>; + + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + mockPreventRemoveCallback = undefined; + 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()); + // 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}`, { + ...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}`, { + ...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(); + }); + + 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 () => { + // 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()); + }); + + 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}}); + }); + await seedVacationDelegate({adminPolicies: [], nonAdminPolicies: [MEMBER_POLICY_ID]}); + 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()); + 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}); + }); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // 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(); + + // 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. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.objectContaining({employees: expect.stringContaining(DELEGATE_EMAIL), reportCreationData: expect.stringContaining(DELEGATE_EMAIL)}), + expect.anything(), + ); + }); + + 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(); + + // 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. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.objectContaining({reportCreationData: expect.stringContaining(EXISTING_CHAT_REPORT_ID)}), + expect.anything(), + ); + + // 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); + + // Then every invite still seeds the optimistic delegate, so each policy expense chat renders them + expect(inviteCalls.every((onyxData) => hasPersonalDetailsUpdate(onyxData.optimisticData))).toBe(true); + + // 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()); + }); + + 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')); + }); + await seedVacationDelegate({adminPolicies: [ADMIN_POLICY_ID, SUMMARY_POLICY_ID], nonAdminPolicies: []}); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // 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(); + 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')); + }); + await seedVacationDelegate({adminPolicies: [SUMMARY_POLICY_ID], nonAdminPolicies: []}); + 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}}, + }); + }); + await waitForBatchedUpdatesWithAct(); + + 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(); + }); + + 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(); + + // 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(); + + // 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(); + expect(screen.queryByText(PREVIOUS_DELEGATE_EMAIL)).not.toBeOnTheScreen(); + }); + + 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); + const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + 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(); + }); +}); diff --git a/tests/ui/VacationDelegatePageTest.tsx b/tests/ui/VacationDelegatePageTest.tsx new file mode 100644 index 000000000000..7faeaf2f7cfd --- /dev/null +++ b/tests/ui/VacationDelegatePageTest.tsx @@ -0,0 +1,325 @@ +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 {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; +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 {createNavigationContainerRef, NavigationContainer} from '@react-navigation/native'; +import {createStackNavigator} from '@react-navigation/stack'; +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'; + +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(), + 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(), + }; +}); + +// 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', () => { + 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( + + + + + , + ); +} + +// 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; + + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + mockShowConfirmModal.mockReset().mockResolvedValue({action: 'CLOSE'}); + 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 () => { + // 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, + expect.objectContaining({creator: CREATOR_EMAIL, vacationDelegateEmail: DELEGATE_A_EMAIL}), + expect.anything(), + ); + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.SETTINGS_STATUS); + }); + + 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') + .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(); + + // 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(); + + const vacationDelegate = await getOnyxValue(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + expect(vacationDelegate?.delegate).toBeFalsy(); + expect(vacationDelegate?.errors).toBeFalsy(); + }); + + 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 () => { + // 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, + expect.objectContaining({vacationDelegateEmail: DELEGATE_A_EMAIL}), + expect.anything(), + ); + }); + + it('rolls back the optimistic delegate instead of leaving a stuck pending row when the request rejects', async () => { + // 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(); + + 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(); + + // 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 () => { + // 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(() => {})); + 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(); + + // When a different delegate is picked + fireEvent.press(screen.getByTestId('select-delegate-b')); + await waitForBatchedUpdatesWithAct(); + + // 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}), + expect.objectContaining({ + optimisticData: [expect.objectContaining({value: expect.objectContaining({delegate: DELEGATE_B_EMAIL, previousDelegate: ORIGINAL_DELEGATE_EMAIL})})], + }), + ); + }); + + 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( + () => + new Promise((resolve) => { + resolveSideEffect = resolve; + }), + ); + jest.mocked(Navigation.navigate).mockClear(); + + renderPageWithStack(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByTestId('select-delegate-a')); + await waitForBatchedUpdatesWithAct(); + + // 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(); + }); + + // 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); + expect(vacationDelegate?.delegate).toBeFalsy(); + expect(vacationDelegate?.policyDiff).toBeFalsy(); + expect(vacationDelegate?.pendingAction).toBeFalsy(); + expect(vacationDelegate?.errors).toBeFalsy(); + }); +}); diff --git a/tests/unit/PolicySelectorTest.ts b/tests/unit/PolicySelectorTest.ts index 47aaf4fe75f1..402918bd1d55 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,60 @@ 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', () => { + // 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); + }); +}); + describe('isAdminForPolicyByIDSelector', () => { const P = ONYXKEYS.COLLECTION.POLICY; diff --git a/tests/unit/components/VacationDelegateMenuItemTest.tsx b/tests/unit/components/VacationDelegateMenuItemTest.tsx index 69f541d595dd..76da533e9261 100644 --- a/tests/unit/components/VacationDelegateMenuItemTest.tsx +++ b/tests/unit/components/VacationDelegateMenuItemTest.tsx @@ -9,7 +9,9 @@ import VacationDelegateMenuItem from '@components/VacationDelegateMenuItem'; import useVacationDelegatePersonalDetails from '@hooks/useVacationDelegatePersonalDetails'; +import CONST from '@src/CONST'; import type {PersonalDetails} from '@src/types/onyx'; +import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import React from 'react'; @@ -49,14 +51,7 @@ jest.mock('@hooks/useLazyAsset', () => ({ 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 +// Capture the props passed to the MenuItemAvatar 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( , @@ -102,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, @@ -109,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, @@ -127,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, @@ -145,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, @@ -159,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', @@ -176,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(); @@ -186,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(); @@ -207,9 +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); }); + + 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 new file mode 100644 index 000000000000..dd34320b7b6b --- /dev/null +++ b/tests/unit/libs/getVacationDelegateErrors.test.ts @@ -0,0 +1,45 @@ +/** + * 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', () => { + // 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(); + }); +}); diff --git a/tests/unit/pages/settings/Profile/CustomStatus/VacationDelegatePageTest.tsx b/tests/unit/pages/settings/Profile/CustomStatus/VacationDelegatePageTest.tsx deleted file mode 100644 index 4e092a3140ed..000000000000 --- a/tests/unit/pages/settings/Profile/CustomStatus/VacationDelegatePageTest.tsx +++ /dev/null @@ -1,130 +0,0 @@ -/** - * These tests verify the prompt passed to the confirm modal for: - * - selected rows that carry a display name, for both email and phone-number logins - * - selected rows without a display name (e.g. right after a cache clear), for both email and phone-number logins - */ -import {act, render} from '@testing-library/react-native'; - -import {setVacationDelegate} from '@libs/actions/VacationDelegate'; - -import VacationDelegatePage from '@pages/settings/Profile/CustomStatus/VacationDelegatePage'; - -import CONST from '@src/CONST'; -import type {Participant} from '@src/types/onyx/IOU'; - -import React from 'react'; - -const mockShowConfirmModal = jest.fn(); - -jest.mock('@hooks/useConfirmModal', () => - jest.fn(() => ({ - showConfirmModal: mockShowConfirmModal, - })), -); - -jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({login: 'me@example.com'}))); - -jest.mock('@hooks/useOnyx', () => jest.fn(() => [undefined])); - -jest.mock('@hooks/useLocalize', () => - jest.fn(() => ({ - // Echo the param back into the key so tests can assert that the formatted value reached - // the prompt unchanged. - translate: (key: string, param?: string) => (param !== undefined ? `${key}(${param})` : key), - })), -); - -jest.mock('@hooks/useThemeStyles', () => - jest.fn( - () => - new Proxy( - {}, - { - get: () => ({}), - }, - ), - ), -); - -jest.mock('@libs/actions/VacationDelegate', () => ({ - setVacationDelegate: jest.fn(), - deleteVacationDelegate: jest.fn(), - clearVacationDelegateError: jest.fn(), -})); - -jest.mock('@libs/Navigation/Navigation', () => ({ - goBack: jest.fn(), - navigate: jest.fn(), -})); - -jest.mock('@components/ScreenWrapper', () => { - function MockScreenWrapper({children}: {children: React.ReactNode}) { - return children; - } - return MockScreenWrapper; -}); - -// Capture the `onSelectRow` callback the page passes to the selection component so the test can -// invoke it directly without rendering the full SelectionList tree. -let capturedOnSelectRow: ((option: Participant) => void) | undefined; - -jest.mock('@components/BaseVacationDelegateSelectionComponent', () => { - function MockBaseVacationDelegateSelectionComponent({onSelectRow}: {onSelectRow: (option: Participant) => void}) { - capturedOnSelectRow = onSelectRow; - return null; - } - return MockBaseVacationDelegateSelectionComponent; -}); - -const mockSetVacationDelegate = jest.mocked(setVacationDelegate); - -const EMAIL_DELEGATE = 'jane@example.com'; -const PHONE_DELEGATE_WITH_SMS_DOMAIN = '+919789942470@expensify.sms'; -const PHONE_DELEGATE_RAW = '+919789942470'; - -describe('VacationDelegatePage warning modal', () => { - beforeEach(() => { - jest.clearAllMocks(); - capturedOnSelectRow = undefined; - mockShowConfirmModal.mockResolvedValue({action: 'CLOSE'}); - mockSetVacationDelegate.mockResolvedValue({jsonCode: CONST.JSON_CODE.POLICY_DIFF_WARNING}); - }); - - async function selectRowAndFlush(login: string, text?: string) { - render(); - await act(async () => { - capturedOnSelectRow?.({login, text} as Participant); - }); - } - - describe('the selected row carries a display name', () => { - it('uses the display name for an email account in the warning prompt', async () => { - await selectRowAndFlush(EMAIL_DELEGATE, 'Jane Doe'); - - expect(mockShowConfirmModal).toHaveBeenCalledWith(expect.objectContaining({prompt: 'statusPage.vacationDelegateWarning(Jane Doe)'})); - }); - - it('strips `@expensify.sms` from the display name for a phone account', async () => { - await selectRowAndFlush(PHONE_DELEGATE_WITH_SMS_DOMAIN, PHONE_DELEGATE_WITH_SMS_DOMAIN); - - expect(mockShowConfirmModal).toHaveBeenCalledWith(expect.objectContaining({prompt: `statusPage.vacationDelegateWarning(${PHONE_DELEGATE_RAW})`})); - expect(JSON.stringify(mockShowConfirmModal.mock.calls)).not.toContain('@expensify.sms'); - }); - }); - - describe('the selected row has no display name (e.g. after cache clear)', () => { - it('falls back to the email login in the prompt', async () => { - await selectRowAndFlush(EMAIL_DELEGATE); - - expect(mockShowConfirmModal).toHaveBeenCalledWith(expect.objectContaining({prompt: `statusPage.vacationDelegateWarning(${EMAIL_DELEGATE})`})); - }); - - // Bug #89578 — the exact scenario reported. - it('strips `@expensify.sms` from the phone-number login in the prompt', async () => { - await selectRowAndFlush(PHONE_DELEGATE_WITH_SMS_DOMAIN); - - expect(mockShowConfirmModal).toHaveBeenCalledWith(expect.objectContaining({prompt: `statusPage.vacationDelegateWarning(${PHONE_DELEGATE_RAW})`})); - expect(JSON.stringify(mockShowConfirmModal.mock.calls)).not.toContain('@expensify.sms'); - }); - }); -});