From efcc12eb4e997c26f1c2a78d3c97fa85ab677340 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:16:27 +0530 Subject: [PATCH 01/56] Add approve-only workspace role --- src/CONST/index.ts | 1 + .../WorkspaceRowThreeDotsMenu.tsx | 3 +- src/components/WorkspaceMemberRoleList.tsx | 7 ++ src/hooks/usePolicyForMovingExpenses.ts | 2 + src/languages/de.ts | 12 ++++ src/languages/el.ts | 12 ++++ src/languages/en.ts | 11 +++ src/languages/es.ts | 12 ++++ src/languages/fr.ts | 12 ++++ src/languages/it.ts | 12 ++++ src/languages/ja.ts | 11 +++ src/languages/nl.ts | 11 +++ src/languages/pl.ts | 11 +++ src/languages/pt-BR.ts | 12 ++++ src/languages/zh-hans.ts | 11 +++ src/libs/API/types.ts | 2 + src/libs/OptionsListUtils/index.ts | 6 ++ src/libs/OptionsListUtils/types.ts | 2 + src/libs/PolicyUtils.ts | 21 ++++-- src/libs/ReportUtils.ts | 9 +++ src/libs/actions/Policy/Member.ts | 15 ++++- src/pages/SetDefaultWorkspacePage.tsx | 3 +- .../iou/request/ParticipantSearchResults.tsx | 1 + .../WorkspaceMemberDetailsRolePage.tsx | 33 +++++++++ src/types/onyx/Response.ts | 3 + tests/unit/PolicyUtilsTest.ts | 67 +++++++++++++++++++ tests/unit/ReportUtilsTest.ts | 18 +++++ 27 files changed, 312 insertions(+), 8 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 69bd3923072f..ebc76b40bbee 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -4507,6 +4507,7 @@ const CONST = { CARD_ADMIN: 'cardAdmin', PEOPLE_ADMIN: 'peopleAdmin', PAYMENTS_ADMIN: 'paymentsAdmin', + APPROVE_ONLY: 'approveOnly', }, WORKSPACE_STATUS: { ACTIVE: 'active', diff --git a/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx b/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx index aeaf0aad1e33..69fabc067424 100644 --- a/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx +++ b/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx @@ -125,7 +125,8 @@ function WorkspaceRowThreeDotsMenu({item, onDeleteWorkspace, pendingDeletePolicy } } - if (!isDefault && !item?.isJoinRequestPending && !isRestrictedToPreferredPolicy) { + // Approve-only members cannot create expenses, so making the workspace their default would break expense creation routing. + if (!isDefault && !item?.isJoinRequestPending && !isRestrictedToPreferredPolicy && item.role !== CONST.POLICY.ROLE.APPROVE_ONLY) { menuItems.push({ icon: icons.Star, text: translate('workspace.common.setAsDefault'), diff --git a/src/components/WorkspaceMemberRoleList.tsx b/src/components/WorkspaceMemberRoleList.tsx index b569350f1969..3704d138aa00 100644 --- a/src/components/WorkspaceMemberRoleList.tsx +++ b/src/components/WorkspaceMemberRoleList.tsx @@ -87,6 +87,13 @@ function WorkspaceMemberRoleList({role, policy, navigateBackTo = undefined, isLo isSelected: role === CONST.POLICY.ROLE.USER, keyForList: CONST.POLICY.ROLE.USER, }, + { + value: CONST.POLICY.ROLE.APPROVE_ONLY, + text: translate('workspace.common.roleName', CONST.POLICY.ROLE.APPROVE_ONLY), + alternateText: translate('workspace.common.approveOnlyAlternateText'), + isSelected: role === CONST.POLICY.ROLE.APPROVE_ONLY, + keyForList: CONST.POLICY.ROLE.APPROVE_ONLY, + }, ]; const availableRoleItems: ListItemType[] = workspaceRoles.filter( diff --git a/src/hooks/usePolicyForMovingExpenses.ts b/src/hooks/usePolicyForMovingExpenses.ts index be09beaa39f9..efffec9720b6 100644 --- a/src/hooks/usePolicyForMovingExpenses.ts +++ b/src/hooks/usePolicyForMovingExpenses.ts @@ -29,6 +29,8 @@ function isPolicyValidForMovingExpenses(policy: OnyxEntry, login: string return ( checkForUserPendingDelete(login, policy) && isPolicyMemberByRole(policy) && + // Moving an expense to a workspace creates it there, which approve-only members cannot do. + policy?.role !== CONST.POLICY.ROLE.APPROVE_ONLY && isGroupPolicy(policy) && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && // Teachers Unite doesn't support reimbursement, so it can never be a destination for moving/reporting an expense. diff --git a/src/languages/de.ts b/src/languages/de.ts index 77210bf49061..40c5959aa48d 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -4853,6 +4853,8 @@ ${amount} für ${merchant} – ${date}`, return 'Personalverwaltung'; case CONST.POLICY.ROLE.PAYMENTS_ADMIN: return 'Zahlungsadministrator'; + case CONST.POLICY.ROLE.APPROVE_ONLY: + return 'Nur Genehmigung'; case CONST.POLICY.ROLE.USER: return 'Mitglied'; default: @@ -4893,6 +4895,7 @@ ${amount} für ${merchant} – ${date}`, cardAdminAlternateText: 'Arbeitsbereichskarten verwalten.', peopleAdminAlternateText: 'Mitglieder und Genehmigungsabläufe verwalten.', paymentsAdminAlternateText: 'Workflow-Zahlungen verwalten.', + approveOnlyAlternateText: 'Berichte genehmigen, ohne sie einzureichen.', readOnlyActionTitle: 'Nicht so schnell …', readOnlyActionPrompt: 'Ihre Arbeitsbereichsrolle kann diese Einstellungen anzeigen, aber nicht bearbeiten.', noAccessActionPrompt: 'Ihre Arbeitsbereichsrolle hat keinen Zugriff auf diese Einstellungen. Bitten Sie eine/n Admin, falls Sie Zugriff benötigen.', @@ -7076,6 +7079,15 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, }, people: { genericFailureMessage: 'Beim Entfernen eines Mitglieds aus dem Workspace ist ein Fehler aufgetreten, bitte versuche es erneut', + approveOnlyRoleBlockedTitle: 'Diese Rolle kann nicht geändert werden', + approveOnlyRoleBlockedConfirm: 'Verstanden', + approveOnlyRoleBlockedDescription: 'Mitglieder mit der Rolle „Nur Genehmigung“ können in diesem Arbeitsbereich keine Ausgaben erstellen. Diese müssen zuerst behoben werden:', + approveOnlyRoleBlockedReasons: { + hasCardOnPolicy: 'Ihnen ist in diesem Arbeitsbereich eine Karte zugewiesen. Ein Arbeitsbereichs- oder Kartenadministrator muss sie zuerst entziehen.', + isRestrictedByDomainGroup: + 'Dieser Arbeitsbereich ist der bevorzugte Arbeitsbereich für eine Domänengruppe, in der sie sich befinden. Ein Domänenadministrator muss dies zuerst ändern.', + isDefaultPolicy: 'Dies ist ihr Standard-Arbeitsbereich. Sie müssen einen anderen festlegen, bevor Sie ihre Rolle aktualisieren können.', + }, removeMembersPrompt: ({memberName}: {memberName: string}) => ({ one: `Möchtest du ${memberName} wirklich entfernen?`, other: 'Sind Sie sicher, dass Sie diese Mitglieder entfernen möchten?', diff --git a/src/languages/el.ts b/src/languages/el.ts index da36ef996bf0..118a357d730c 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -4908,6 +4908,7 @@ ${amount} για ${merchant} - ${date}`, cardAdminAlternateText: 'Διαχειριστείτε τις κάρτες χώρου εργασίας.', peopleAdminAlternateText: 'Διαχειριστείτε μέλη και ροές έγκρισης.', paymentsAdminAlternateText: 'Διαχειριστείτε τις πληρωμές ροής εργασιών.', + approveOnlyAlternateText: 'Εγκρίνετε αναφορές χωρίς να τις υποβάλλετε.', reimbursementChoice: { [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES]: 'Άμεσο', [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO]: 'Κανένα', @@ -4929,6 +4930,8 @@ ${amount} για ${merchant} - ${date}`, return 'Διαχείριση προσώπων'; case CONST.POLICY.ROLE.PAYMENTS_ADMIN: return 'διαχείριση πληρωμών'; + case CONST.POLICY.ROLE.APPROVE_ONLY: + return 'Μόνο έγκριση'; case CONST.POLICY.ROLE.USER: return 'Μέλος'; default: @@ -7202,6 +7205,15 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ }, people: { genericFailureMessage: 'Παρουσιάστηκε σφάλμα κατά την αφαίρεση μέλους από τον χώρο εργασίας, δοκιμάστε ξανά', + approveOnlyRoleBlockedTitle: 'Δεν είναι δυνατή η αλλαγή αυτού του ρόλου', + approveOnlyRoleBlockedConfirm: 'Κατάλαβα', + approveOnlyRoleBlockedDescription: 'Τα μέλη με μόνο έγκριση δεν μπορούν να δημιουργήσουν έξοδα σε αυτόν τον χώρο εργασίας. Αυτά πρέπει να επιλυθούν πρώτα:', + approveOnlyRoleBlockedReasons: { + hasCardOnPolicy: 'Έχει εκχωρηθεί μια κάρτα σε αυτούς σε αυτόν τον χώρο εργασίας. Ένας διαχειριστής χώρου εργασίας ή καρτών πρέπει πρώτα να την αφαιρέσει.', + isRestrictedByDomainGroup: + 'Αυτός ο χώρος εργασίας είναι ο προτιμώμενος χώρος εργασίας για μια ομάδα τομέα στην οποία ανήκουν. Ένας διαχειριστής τομέα πρέπει πρώτα να τον αλλάξει.', + isDefaultPolicy: 'Αυτός είναι ο προεπιλεγμένος χώρος εργασίας τους. Θα πρέπει να ορίσουν έναν διαφορετικό πριν μπορέσετε να ενημερώσετε τον ρόλο τους.', + }, removeMembersPrompt: ({memberName}: {memberName: string}) => ({ one: `Είστε βέβαιοι ότι θέλετε να αφαιρέσετε τον/την ${memberName};`, other: 'Είστε βέβαιοι ότι θέλετε να αφαιρέσετε αυτά τα μέλη;', diff --git a/src/languages/en.ts b/src/languages/en.ts index 9b75c28f0d1f..e77781dc15c6 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4981,6 +4981,7 @@ const translations = { cardAdminAlternateText: 'Manage workspace cards.', peopleAdminAlternateText: 'Manage members and approval workflows.', paymentsAdminAlternateText: 'Manage workflow payments.', + approveOnlyAlternateText: 'Approve reports without submitting them.', reimbursementChoice: { [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES]: 'Direct', [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO]: 'None', @@ -5002,6 +5003,8 @@ const translations = { return 'People admin'; case CONST.POLICY.ROLE.PAYMENTS_ADMIN: return 'Payments admin'; + case CONST.POLICY.ROLE.APPROVE_ONLY: + return 'Approve-only'; case CONST.POLICY.ROLE.USER: return 'Member'; default: @@ -7375,6 +7378,14 @@ const translations = { }, people: { genericFailureMessage: 'An error occurred removing a member from the workspace, please try again', + approveOnlyRoleBlockedTitle: 'Can’t change this role', + approveOnlyRoleBlockedConfirm: 'Got it', + approveOnlyRoleBlockedDescription: 'Approve-only members can’t create expenses on this workspace. These need to be resolved first:', + approveOnlyRoleBlockedReasons: { + hasCardOnPolicy: 'A card is assigned to them on this workspace. A Workspace Admin or Card Admin needs to unassign it first.', + isRestrictedByDomainGroup: 'This workspace is the Preferred workspace for a domain group they’re in. A Domain Admin needs to change it first.', + isDefaultPolicy: 'This is their default workspace. They’ll need to set a different one before you can update their role.', + }, removeMembersPrompt: ({memberName}: {memberName: string}) => ({ one: `Are you sure you want to remove ${memberName}?`, other: 'Are you sure you want to remove these members?', diff --git a/src/languages/es.ts b/src/languages/es.ts index 5a7cdb7afe48..1663d88d8b2b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4784,6 +4784,8 @@ ${amount} para ${merchant} - ${date}`, return 'Administrador de personas'; case CONST.POLICY.ROLE.PAYMENTS_ADMIN: return 'Administrador de pagos'; + case CONST.POLICY.ROLE.APPROVE_ONLY: + return 'Solo aprobación'; case CONST.POLICY.ROLE.USER: return 'Miembro'; default: @@ -4825,6 +4827,7 @@ ${amount} para ${merchant} - ${date}`, cardAdminAlternateText: 'Gestiona tarjetas del espacio de trabajo.', peopleAdminAlternateText: 'Gestiona miembros y flujos de aprobación.', paymentsAdminAlternateText: 'Gestiona los pagos del flujo de trabajo.', + approveOnlyAlternateText: 'Aprueba informes sin presentarlos.', noAccessActionPrompt: 'Tu rol en el espacio de trabajo no tiene acceso a estos ajustes. Pide a un administrador que te lo habilite si lo necesitas.', }, createdForClient: { @@ -6943,6 +6946,15 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, }, people: { genericFailureMessage: 'Se ha producido un error al intentar eliminar a un miembro del espacio de trabajo. Por favor, inténtalo más tarde.', + approveOnlyRoleBlockedTitle: 'No se puede cambiar este rol', + approveOnlyRoleBlockedConfirm: 'Entendido', + approveOnlyRoleBlockedDescription: 'Los miembros de solo aprobación no pueden crear gastos en este espacio de trabajo. Primero deben resolverse lo siguiente:', + approveOnlyRoleBlockedReasons: { + hasCardOnPolicy: 'Tienen una tarjeta asignada en este espacio de trabajo. Un administrador del espacio de trabajo o de tarjetas debe desasignarla primero.', + isRestrictedByDomainGroup: + 'Este espacio de trabajo es el espacio de trabajo preferido de un grupo de dominio al que pertenecen. Un administrador de dominio debe cambiarlo primero.', + isDefaultPolicy: 'Este es su espacio de trabajo predeterminado. Deberán establecer uno diferente antes de que puedas actualizar su rol.', + }, removeMembersPrompt: ({memberName}) => ({ one: `¿Estás seguro de que deseas eliminar ${memberName}`, other: '¿Estás seguro de que deseas eliminar a estos miembros?', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 4c69c6e0f201..fc55a48b691f 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -4859,6 +4859,8 @@ ${amount} pour ${merchant} - ${date}`, return 'Administration des personnes'; case CONST.POLICY.ROLE.PAYMENTS_ADMIN: return 'Administrateur des paiements'; + case CONST.POLICY.ROLE.APPROVE_ONLY: + return 'Approbation uniquement'; case CONST.POLICY.ROLE.USER: return 'Membre'; default: @@ -4899,6 +4901,7 @@ ${amount} pour ${merchant} - ${date}`, cardAdminAlternateText: 'Gérer les cartes de l’espace de travail.', peopleAdminAlternateText: 'Gérez les membres et les workflows d’approbation.', paymentsAdminAlternateText: 'Gérer les paiements de workflow.', + approveOnlyAlternateText: 'Approuver les rapports sans les soumettre.', readOnlyActionTitle: 'Pas si vite...', readOnlyActionPrompt: 'Votre rôle dans cet espace de travail peut afficher ces paramètres, mais ne peut pas les modifier.', noAccessActionPrompt: 'Votre rôle dans l’espace de travail n’a pas accès à ces paramètres. Demandez à un administrateur si vous en avez besoin.', @@ -7094,6 +7097,15 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, }, people: { genericFailureMessage: "Une erreur s'est produite lors de la suppression d'un membre de l'espace de travail, veuillez réessayer.", + approveOnlyRoleBlockedTitle: 'Impossible de modifier ce rôle', + approveOnlyRoleBlockedConfirm: 'Compris', + approveOnlyRoleBlockedDescription: 'Les membres en approbation uniquement ne peuvent pas créer de dépenses sur cet espace de travail. Ces points doivent d’abord être résolus :', + approveOnlyRoleBlockedReasons: { + hasCardOnPolicy: 'Une carte leur est attribuée sur cet espace de travail. Un administrateur de l’espace de travail ou des cartes doit d’abord la désattribuer.', + isRestrictedByDomainGroup: + 'Cet espace de travail est l’espace de travail préféré d’un groupe de domaine dont ils font partie. Un administrateur de domaine doit d’abord le modifier.', + isDefaultPolicy: 'C’est leur espace de travail par défaut. Ils devront en définir un autre avant que vous puissiez mettre à jour leur rôle.', + }, removeMembersPrompt: ({memberName}: {memberName: string}) => ({ one: `Voulez-vous vraiment supprimer ${memberName} ?`, other: 'Voulez-vous vraiment supprimer ces membres ?', diff --git a/src/languages/it.ts b/src/languages/it.ts index 72700cfbafd2..1bb73a015eb6 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -4822,6 +4822,8 @@ ${amount} per ${merchant} - ${date}`, return 'Amministrazione persone'; case CONST.POLICY.ROLE.PAYMENTS_ADMIN: return 'Amministrazione pagamenti'; + case CONST.POLICY.ROLE.APPROVE_ONLY: + return 'Solo approvazione'; case CONST.POLICY.ROLE.USER: return 'Membro'; default: @@ -4862,6 +4864,7 @@ ${amount} per ${merchant} - ${date}`, cardAdminAlternateText: 'Gestisci le carte dello spazio di lavoro.', peopleAdminAlternateText: 'Gestisci i membri e i flussi di approvazione.', paymentsAdminAlternateText: 'Gestisci i pagamenti del flusso di lavoro.', + approveOnlyAlternateText: 'Approva i report senza inviarli.', readOnlyActionTitle: 'Non così in fretta...', readOnlyActionPrompt: 'Il tuo ruolo nello spazio di lavoro può visualizzare queste impostazioni, ma non può modificarle.', noAccessActionPrompt: 'Il tuo ruolo nello spazio di lavoro non ha accesso a queste impostazioni. Chiedi a un amministratore se ne hai bisogno.', @@ -7034,6 +7037,15 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, }, people: { genericFailureMessage: 'Si è verificato un errore durante la rimozione di un membro dallo spazio di lavoro, riprova per favore', + approveOnlyRoleBlockedTitle: 'Impossibile modificare questo ruolo', + approveOnlyRoleBlockedConfirm: 'Capito', + approveOnlyRoleBlockedDescription: 'I membri con solo approvazione non possono creare spese in questo spazio di lavoro. Questi problemi devono essere risolti prima:', + approveOnlyRoleBlockedReasons: { + hasCardOnPolicy: 'Hanno una carta assegnata in questo spazio di lavoro. Un amministratore dello spazio di lavoro o delle carte deve prima rimuoverla.', + isRestrictedByDomainGroup: + 'Questo spazio di lavoro è lo spazio di lavoro preferito per un gruppo di dominio di cui fanno parte. Un amministratore di dominio deve prima modificarlo.', + isDefaultPolicy: 'Questo è il loro spazio di lavoro predefinito. Dovranno impostarne uno diverso prima che tu possa aggiornare il loro ruolo.', + }, removeMembersPrompt: ({memberName}: {memberName: string}) => ({ one: `Sei sicuro di voler rimuovere ${memberName}?`, other: 'Sei sicuro di voler rimuovere questi membri?', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 1275388be355..99d8805b3f51 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -4787,6 +4787,8 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' return 'メンバー管理'; case CONST.POLICY.ROLE.PAYMENTS_ADMIN: return '支払い管理者'; + case CONST.POLICY.ROLE.APPROVE_ONLY: + return '承認のみ'; case CONST.POLICY.ROLE.USER: return 'メンバー'; default: @@ -4827,6 +4829,7 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' cardAdminAlternateText: 'ワークスペースカードを管理します。', peopleAdminAlternateText: 'メンバーと承認ワークフローを管理します。', paymentsAdminAlternateText: 'ワークフローの支払いを管理します。', + approveOnlyAlternateText: '提出せずにレポートを承認します。', readOnlyActionTitle: 'ちょっと待ってください…', readOnlyActionPrompt: 'このワークスペースでのあなたのロールは、これらの設定を表示できますが、編集することはできません。', noAccessActionPrompt: 'このワークスペースでのあなたのロールでは、これらの設定にアクセスできません。必要な場合は管理者に依頼してください。', @@ -6947,6 +6950,14 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から }, people: { genericFailureMessage: 'ワークスペースからメンバーを削除する際にエラーが発生しました。もう一度お試しください', + approveOnlyRoleBlockedTitle: 'この役割は変更できません', + approveOnlyRoleBlockedConfirm: '了解', + approveOnlyRoleBlockedDescription: '承認のみのメンバーはこのワークスペースで経費を作成できません。先に以下を解決する必要があります:', + approveOnlyRoleBlockedReasons: { + hasCardOnPolicy: 'このワークスペースでカードが割り当てられています。ワークスペース管理者またはカード管理者が先に割り当てを解除する必要があります。', + isRestrictedByDomainGroup: 'このワークスペースは、彼らが所属するドメイングループの優先ワークスペースです。ドメイン管理者が先に変更する必要があります。', + isDefaultPolicy: 'これは彼らのデフォルトのワークスペースです。役割を更新する前に、別のワークスペースを設定する必要があります。', + }, removeMembersPrompt: ({memberName}: {memberName: string}) => ({ one: `${memberName} さんを削除してもよろしいですか?`, other: 'これらのメンバーを本当に削除しますか?', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 2ea5aa4ad286..b2ada9785a34 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -4824,6 +4824,8 @@ ${amount} voor ${merchant} - ${date}`, return 'Beheer personen'; case CONST.POLICY.ROLE.PAYMENTS_ADMIN: return 'Beheerder betalingen'; + case CONST.POLICY.ROLE.APPROVE_ONLY: + return 'Alleen goedkeuren'; case CONST.POLICY.ROLE.USER: return 'Lid'; default: @@ -4864,6 +4866,7 @@ ${amount} voor ${merchant} - ${date}`, cardAdminAlternateText: 'Werkruimtekaarten beheren.', peopleAdminAlternateText: 'Beheer leden en goedkeuringsworkflows.', paymentsAdminAlternateText: 'Workflowsbetalingen beheren.', + approveOnlyAlternateText: 'Keur rapporten goed zonder ze in te dienen.', readOnlyActionTitle: 'Niet zo snel...', readOnlyActionPrompt: 'Je rol in de workspace kan deze instellingen bekijken, maar niet bewerken.', noAccessActionPrompt: 'Je rol in deze workspace heeft geen toegang tot deze instellingen. Vraag een admin als je die nodig hebt.', @@ -7019,6 +7022,14 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, }, people: { genericFailureMessage: 'Er is een fout opgetreden bij het verwijderen van een lid uit de werkruimte, probeer het opnieuw.', + approveOnlyRoleBlockedTitle: 'Kan deze rol niet wijzigen', + approveOnlyRoleBlockedConfirm: 'Begrepen', + approveOnlyRoleBlockedDescription: 'Leden met alleen goedkeuren kunnen geen uitgaven aanmaken in deze werkruimte. Deze moeten eerst worden opgelost:', + approveOnlyRoleBlockedReasons: { + hasCardOnPolicy: 'Er is een kaart aan hen toegewezen in deze werkruimte. Een werkruimte- of kaartbeheerder moet deze eerst ontkoppelen.', + isRestrictedByDomainGroup: 'Deze werkruimte is de voorkeurswerkruimte voor een domeingroep waarvan ze deel uitmaken. Een domeinbeheerder moet dit eerst wijzigen.', + isDefaultPolicy: 'Dit is hun standaardwerkruimte. Ze moeten een andere instellen voordat je hun rol kunt bijwerken.', + }, removeMembersPrompt: ({memberName}: {memberName: string}) => ({ one: `Weet je zeker dat je ${memberName} wilt verwijderen?`, other: 'Weet je zeker dat je deze leden wilt verwijderen?', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 4e2df173645b..c18981d878f5 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -4852,6 +4852,8 @@ ${amount} dla ${merchant} - ${date}`, return 'Administrator osób'; case CONST.POLICY.ROLE.PAYMENTS_ADMIN: return 'Administrator płatności'; + case CONST.POLICY.ROLE.APPROVE_ONLY: + return 'Tylko zatwierdzanie'; case CONST.POLICY.ROLE.USER: return 'Członek'; default: @@ -4892,6 +4894,7 @@ ${amount} dla ${merchant} - ${date}`, cardAdminAlternateText: 'Zarządzaj kartami przestrzeni roboczej.', peopleAdminAlternateText: 'Zarządzaj członkami i procesami akceptacji.', paymentsAdminAlternateText: 'Zarządzaj płatnościami w przepływie pracy.', + approveOnlyAlternateText: 'Zatwierdzaj raporty bez ich przesyłania.', readOnlyActionTitle: 'Nie tak szybko…', readOnlyActionPrompt: 'Twoja rola w przestrzeni roboczej może wyświetlać te ustawienia, ale nie może ich edytować.', noAccessActionPrompt: 'Twoja rola w tym zespole nie ma dostępu do tych ustawień. Jeśli go potrzebujesz, poproś administratora.', @@ -7044,6 +7047,14 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, }, people: { genericFailureMessage: 'Wystąpił błąd podczas usuwania członka z przestrzeni roboczej, spróbuj ponownie', + approveOnlyRoleBlockedTitle: 'Nie można zmienić tej roli', + approveOnlyRoleBlockedConfirm: 'Rozumiem', + approveOnlyRoleBlockedDescription: 'Członkowie z rolą tylko zatwierdzania nie mogą tworzyć wydatków w tym obszarze roboczym. Należy najpierw rozwiązać następujące kwestie:', + approveOnlyRoleBlockedReasons: { + hasCardOnPolicy: 'Mają przypisaną kartę w tym obszarze roboczym. Administrator obszaru roboczego lub kart musi ją najpierw usunąć.', + isRestrictedByDomainGroup: 'Ten obszar roboczy jest preferowanym obszarem roboczym dla grupy domen, do której należą. Administrator domeny musi to najpierw zmienić.', + isDefaultPolicy: 'To jest ich domyślny obszar roboczy. Muszą ustawić inny, zanim będzie można zaktualizować ich rolę.', + }, removeMembersPrompt: ({memberName}: {memberName: string}) => ({ one: `Czy na pewno chcesz usunąć ${memberName}?`, other: 'Czy na pewno chcesz usunąć tych członków?', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 4b9a4c81d21f..cde43416629a 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -4815,6 +4815,8 @@ ${amount} para ${merchant} - ${date}`, return 'Administração de pessoas'; case CONST.POLICY.ROLE.PAYMENTS_ADMIN: return 'Admin de pagamentos'; + case CONST.POLICY.ROLE.APPROVE_ONLY: + return 'Somente aprovação'; case CONST.POLICY.ROLE.USER: return 'Membro'; default: @@ -4855,6 +4857,7 @@ ${amount} para ${merchant} - ${date}`, cardAdminAlternateText: 'Gerenciar cartões do workspace.', peopleAdminAlternateText: 'Gerencie membros e fluxos de aprovação.', paymentsAdminAlternateText: 'Gerencie pagamentos de fluxo de trabalho.', + approveOnlyAlternateText: 'Aprove relatórios sem enviá-los.', readOnlyActionTitle: 'Calma aí...', readOnlyActionPrompt: 'Sua função no workspace pode ver essas configurações, mas não pode editá-las.', noAccessActionPrompt: 'Sua função no espaço de trabalho não tem acesso a essas configurações. Peça a um administrador se você precisar disso.', @@ -7021,6 +7024,15 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, }, people: { genericFailureMessage: 'Ocorreu um erro ao remover um membro do workspace, tente novamente', + approveOnlyRoleBlockedTitle: 'Não é possível alterar esta função', + approveOnlyRoleBlockedConfirm: 'Entendi', + approveOnlyRoleBlockedDescription: 'Membros somente de aprovação não podem criar despesas neste espaço de trabalho. Estes itens precisam ser resolvidos primeiro:', + approveOnlyRoleBlockedReasons: { + hasCardOnPolicy: 'Um cartão está atribuído a eles neste espaço de trabalho. Um administrador do espaço de trabalho ou de cartões precisa desatribuí-lo primeiro.', + isRestrictedByDomainGroup: + 'Este espaço de trabalho é o espaço de trabalho preferido de um grupo de domínio do qual eles fazem parte. Um administrador de domínio precisa alterá-lo primeiro.', + isDefaultPolicy: 'Este é o espaço de trabalho padrão deles. Eles precisarão definir um diferente antes que você possa atualizar a função deles.', + }, removeMembersPrompt: ({memberName}: {memberName: string}) => ({ one: `Tem certeza de que deseja remover ${memberName}?`, other: 'Tem certeza de que deseja remover estes membros?', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 3e66dcc9fc45..81e4ff2299ff 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -4678,6 +4678,8 @@ ${amount},商户:${merchant} - 日期:${date}`, return '人员管理员'; case CONST.POLICY.ROLE.PAYMENTS_ADMIN: return '付款管理员'; + case CONST.POLICY.ROLE.APPROVE_ONLY: + return '仅审批'; case CONST.POLICY.ROLE.USER: return '成员'; default: @@ -4717,6 +4719,7 @@ ${amount},商户:${merchant} - 日期:${date}`, cardAdminAlternateText: '管理工作区卡片。', peopleAdminAlternateText: '管理成员和审批流程。', paymentsAdminAlternateText: '管理工作流付款。', + approveOnlyAlternateText: '审批报告而无需提交。', readOnlyActionTitle: '别急……', readOnlyActionPrompt: '你的工作区角色可以查看这些设置,但不能编辑。', noAccessActionPrompt: '您的工作区角色无权访问这些设置。如需访问,请联系管理员。', @@ -6779,6 +6782,14 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM }, people: { genericFailureMessage: '从工作区移除成员时出错,请重试', + approveOnlyRoleBlockedTitle: '无法更改此角色', + approveOnlyRoleBlockedConfirm: '知道了', + approveOnlyRoleBlockedDescription: '仅审批成员无法在此工作区创建费用。需要先解决以下问题:', + approveOnlyRoleBlockedReasons: { + hasCardOnPolicy: '他们在此工作区中分配了一张卡。工作区管理员或卡管理员需要先取消分配。', + isRestrictedByDomainGroup: '此工作区是他们所在域组的首选工作区。域管理员需要先更改它。', + isDefaultPolicy: '这是他们的默认工作区。他们需要先设置一个不同的工作区,然后您才能更新他们的角色。', + }, removeMembersPrompt: ({memberName}: {memberName: string}) => ({ one: `确定要移除 ${memberName} 吗?`, other: '确定要移除这些成员吗?', diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 47c602369c93..3114947b7c6c 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -1813,6 +1813,7 @@ const SIDE_EFFECT_REQUEST_COMMANDS = { REVEAL_CARD_PIN: 'RevealCardPIN', CHANGE_CARD_PIN: 'ChangeCardPIN', OPEN_SEARCH_TAG_FILTERS_PAGE: 'OpenSearchTagFiltersPage', + UPDATE_WORKSPACE_MEMBERS_ROLE: 'UpdateWorkspaceMembersRole', } as const; type SideEffectRequestCommand = ValueOf; @@ -1863,6 +1864,7 @@ type SideEffectRequestCommandParameters = { [SIDE_EFFECT_REQUEST_COMMANDS.REVEAL_CARD_PIN]: Parameters.RevealCardPINParams; [SIDE_EFFECT_REQUEST_COMMANDS.CHANGE_CARD_PIN]: Parameters.ChangeCardPINParams; [SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE]: Parameters.OpenSearchTagFiltersPageParams; + [SIDE_EFFECT_REQUEST_COMMANDS.UPDATE_WORKSPACE_MEMBERS_ROLE]: Parameters.UpdateWorkspaceMembersRoleParams; }; type ApiRequestCommandParameters = WriteCommandParameters & ReadCommandParameters & SideEffectRequestCommandParameters; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index fa2f8401a280..c12a0827ce96 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -1848,6 +1848,7 @@ function isValidReport( isDefaultRoomsBetaEnabled = false, includeMultipleParticipantReports = false, includeOwnedWorkspaceChats = false, + excludeApproveOnlyWorkspaces = false, includeThreads = false, includeTasks = false, includeMoneyRequests = false, @@ -1911,6 +1912,11 @@ function isValidReport( return false; } + // Approve-only members cannot create expenses, so their workspace chat is not a valid submit destination. + if (isPolicyExpenseChat && excludeApproveOnlyWorkspaces && policy?.role === CONST.POLICY.ROLE.APPROVE_ONLY) { + return false; + } + if (isPolicyExpenseChat && isRestrictedToPreferredPolicy && option.policyID !== preferredPolicyID) { return false; } diff --git a/src/libs/OptionsListUtils/types.ts b/src/libs/OptionsListUtils/types.ts index 68b950dba45e..ef5cb8476be9 100644 --- a/src/libs/OptionsListUtils/types.ts +++ b/src/libs/OptionsListUtils/types.ts @@ -216,6 +216,7 @@ type GetValidReportsConfig = { forcePolicyNamePreview?: boolean; includeSelfDM?: boolean; includeOwnedWorkspaceChats?: boolean; + excludeApproveOnlyWorkspaces?: boolean; includeThreads?: boolean; includeTasks?: boolean; includeMoneyRequests?: boolean; @@ -249,6 +250,7 @@ type IsValidReportsConfig = Pick< | 'isDefaultRoomsBetaEnabled' | 'includeMultipleParticipantReports' | 'includeOwnedWorkspaceChats' + | 'excludeApproveOnlyWorkspaces' | 'includeThreads' | 'includeTasks' | 'includeMoneyRequests' diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 127d09b54957..3fb904fce2f1 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -162,6 +162,8 @@ function getActivePoliciesWithExpenseChat(policies: OnyxCollection | nul !!policy.name && !!policy.id && !!getPolicyRole(policy, currentUserLogin) && + // Approve-only members cannot create expenses, so their workspaces are not expense destinations. + getPolicyRole(policy, currentUserLogin) !== CONST.POLICY.ROLE.APPROVE_ONLY && (isPaidGroupPolicy(policy) || isSubmitPolicy(policy)) && !isArchivedPolicy(policy), ); @@ -244,6 +246,12 @@ const ROLE_PERMISSION_BUNDLES: Record controlPolicyOnlyRole === role); @@ -310,10 +318,11 @@ function canMemberAssignRole(policy: OnyxInputOrEntry, login: string, ro return true; } - // Reaching here: USER always, plus AUDITOR only on corporate policies (control-only roles are - // already filtered out on non-corporate policies above). Assigning USER/AUDITOR needs the - // MEMBERS permission, and only on corporate policies. - const isNonElevatedRole = role === CONST.POLICY.ROLE.USER || role === CONST.POLICY.ROLE.AUDITOR; + // Reaching here: USER always, plus AUDITOR and APPROVE_ONLY on corporate policies (control-only roles are + // already filtered out on non-corporate policies above). Assigning these needs the + // MEMBERS permission, and only on corporate policies. APPROVE_ONLY stays within a People Admin's + // authority because it is strictly less permissive than USER. + const isNonElevatedRole = role === CONST.POLICY.ROLE.USER || role === CONST.POLICY.ROLE.AUDITOR || role === CONST.POLICY.ROLE.APPROVE_ONLY; return isCorporatePolicy && canMemberWrite(policy, login, CONST.POLICY.POLICY_FEATURE.MEMBERS) && isNonElevatedRole; } @@ -3292,6 +3301,8 @@ function getGroupPoliciesWhereReportCanBeCreated(policies: OnyxCollection, policy: OnyxEntry, o return false; } + // Approve-only members cannot create expenses on the workspace, so its chats and reports offer no create options. + if (policy?.role === CONST.POLICY.ROLE.APPROVE_ONLY && !!policy?.id && report?.policyID === policy.id) { + return false; + } + let isOwnPolicyExpenseChat = report?.isOwnPolicyExpenseChat ?? false; if (isExpenseReport(report) && getParentReport(report)) { isOwnPolicyExpenseChat = !!getParentReport(report)?.isOwnPolicyExpenseChat; @@ -13629,6 +13634,10 @@ function isWorkspaceEligibleForReportChange(submitterEmail: string | undefined, if (report?.stateNum === CONST.REPORT.STATE_NUM.APPROVED && report.statusNum === CONST.REPORT.STATUS_NUM.CLOSED && !isPolicyAdminPolicyUtils(newPolicy)) { return false; } + // Moving a report to a workspace creates expenses there, which an approve-only submitter cannot do. + if (getPolicyRole(newPolicy, submitterEmail) === CONST.POLICY.ROLE.APPROVE_ONLY) { + return false; + } return !!newPolicy.role && !isPendingDeletePolicy(newPolicy); } diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index 28ce92be4fec..e2bf3cf124b5 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -715,7 +715,7 @@ function buildUpdateWorkspaceMembersRoleOnyxData(policy: OnyxEntry, sele return {optimisticData, successData, failureData, memberRoles}; } -function updateWorkspaceMembersRole(policy: OnyxEntry, selectedMemberEmails: string[], selectedMemberAccountIDs: number[], newRole: ValueOf) { +async function updateWorkspaceMembersRole(policy: OnyxEntry, selectedMemberEmails: string[], selectedMemberAccountIDs: number[], newRole: ValueOf) { if (!policy?.id) { return; } @@ -731,6 +731,19 @@ function updateWorkspaceMembersRole(policy: OnyxEntry, selectedMemberEma employees: JSON.stringify(memberRoles.map((item) => ({email: item.email, role: item.role}))), }; + // The backend can refuse the approve-only role and list why in response.data.blockedReasons. API.write never + // exposes the response, so this role goes through a side-effects request to show those reasons in a modal. + if (newRole === CONST.POLICY.ROLE.APPROVE_ONLY) { + // eslint-disable-next-line rulesdir/no-api-side-effects-method + const response = await API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.UPDATE_WORKSPACE_MEMBERS_ROLE, params, {optimisticData, successData}); + if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { + Onyx.update(failureData); + } + return response; + } + + // Only one request runs per call: the approve-only branch above returns early, so this is not a chained call. + // eslint-disable-next-line rulesdir/no-multiple-api-calls API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_MEMBERS_ROLE, params, {optimisticData, successData, failureData}); } diff --git a/src/pages/SetDefaultWorkspacePage.tsx b/src/pages/SetDefaultWorkspacePage.tsx index ec722df36abb..c6dc91b5b09b 100644 --- a/src/pages/SetDefaultWorkspacePage.tsx +++ b/src/pages/SetDefaultWorkspacePage.tsx @@ -83,7 +83,8 @@ function SetDefaultWorkspacePage({route}: SetDefaultWorkspacePageProps) { shouldSortSelectedToTop: false, searchTerm: debouncedSearchTerm, localeCompare, - additionalFilter: (newPolicy) => isGroupPolicy(newPolicy), + // Approve-only members cannot create expenses, so their workspaces cannot be the default expense destination. + additionalFilter: (newPolicy) => isGroupPolicy(newPolicy) && newPolicy.role !== CONST.POLICY.ROLE.APPROVE_ONLY, }); const confirmButtonOptions = { diff --git a/src/pages/iou/request/ParticipantSearchResults.tsx b/src/pages/iou/request/ParticipantSearchResults.tsx index ed8a2f02d019..7eb2a9cf48f7 100644 --- a/src/pages/iou/request/ParticipantSearchResults.tsx +++ b/src/pages/iou/request/ParticipantSearchResults.tsx @@ -200,6 +200,7 @@ function ParticipantSearchResults({ selectedOptions: participants as Participant[], excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, includeOwnedWorkspaceChats: iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.CREATE || iouType === CONST.IOU.TYPE.SPLIT || iouType === CONST.IOU.TYPE.TRACK, + excludeApproveOnlyWorkspaces: iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.CREATE || iouType === CONST.IOU.TYPE.SPLIT || iouType === CONST.IOU.TYPE.TRACK, excludeNonAdminWorkspaces: action === CONST.IOU.ACTION.SHARE, includeP2P: !isCategorizeOrShareAction && !isPerDiemRequest && !isTimeRequest && !isTransactionFromCreditCardImport && !shouldExcludeP2P, includeInvoiceRooms: iouType === CONST.IOU.TYPE.INVOICE, diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index 05ff7f67751b..b6c36be120c2 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -2,7 +2,9 @@ import ScreenWrapper from '@components/ScreenWrapper'; import WorkspaceMemberRoleList from '@components/WorkspaceMemberRoleList'; import type {ListItemType} from '@components/WorkspaceMemberRoleList'; +import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; import useRedirectSubmitWorkspaceFeatureUpgrade from '@hooks/useRedirectSubmitWorkspaceFeatureUpgrade'; import useRuleBotGuardModal from '@hooks/useRuleBotGuardModal'; @@ -35,6 +37,8 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp const accountID = Number(route.params.accountID); const policyID = route.params.policyID; const showRuleBotGuardModal = useRuleBotGuardModal(); + const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); const memberLogin = personalDetails?.[accountID]?.login ?? ''; const member = policy?.employeeList?.[memberLogin]; @@ -49,6 +53,24 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp upgradeFeatureAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.roles.alias, }); + const showApproveOnlyBlockedModal = (blockedReasons: string[]) => { + const reasonLines = blockedReasons.map((reason) => { + if (reason === 'hasCardOnPolicy') { + return `• ${translate('workspace.people.approveOnlyRoleBlockedReasons.hasCardOnPolicy')}`; + } + if (reason === 'isRestrictedByDomainGroup') { + return `• ${translate('workspace.people.approveOnlyRoleBlockedReasons.isRestrictedByDomainGroup')}`; + } + return `• ${translate('workspace.people.approveOnlyRoleBlockedReasons.isDefaultPolicy')}`; + }); + showConfirmModal({ + title: translate('workspace.people.approveOnlyRoleBlockedTitle'), + prompt: [translate('workspace.people.approveOnlyRoleBlockedDescription'), ...reasonLines].join('\n'), + confirmText: translate('workspace.people.approveOnlyRoleBlockedConfirm'), + shouldShowCancelButton: false, + }); + }; + const changeRole = ({value}: ListItemType) => { if (value === member?.role) { return; @@ -64,6 +86,17 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp showRuleBotGuardModal('changeRole', policyID); return; } + if (value === CONST.POLICY.ROLE.APPROVE_ONLY) { + updateWorkspaceMembersRole(policy, [memberLogin], [accountID], value).then((response) => { + const blockedReasons = response?.data?.blockedReasons ?? []; + if (blockedReasons.length > 0) { + showApproveOnlyBlockedModal(blockedReasons); + return; + } + Navigation.goBack(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); + }); + return; + } updateWorkspaceMembersRole(policy, [memberLogin], [accountID], value); Navigation.goBack(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); }; diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts index 58f469b68d82..90306fe1268c 100644 --- a/src/types/onyx/Response.ts +++ b/src/types/onyx/Response.ts @@ -15,6 +15,9 @@ type Data = { /** Workspaces the vacation delegate is missing from, returned with jsonCode 305 by SetVacationDelegate */ policyDiff?: VacationDelegatePolicyDiff; + + /** Reasons the backend refused the approve-only role, returned by UpdateWorkspaceMembersRole */ + blockedReasons?: string[]; }; /** Model of server response */ diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 8ebd29a2beda..11e03b57731b 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -64,6 +64,7 @@ import { matchesParentTagPath, matchesParentTagsFilter, getGLCodeFromPolicyTag, + getGroupPoliciesWhereReportCanBeCreated, getTagList, getTagListByOrderWeight, getUberConnectionErrorDirectlyFromPolicy, @@ -402,6 +403,19 @@ describe('PolicyUtils', () => { expect(canMemberAssignRole(policy, memberLogin, CONST.POLICY.ROLE.PAYMENTS_ADMIN)).toBe(false); }); + it('allows People Admins to assign the approve-only role', () => { + const policy = buildPolicy(CONST.POLICY.ROLE.PEOPLE_ADMIN); + + expect(canMemberAssignRole(policy, memberLogin, CONST.POLICY.ROLE.APPROVE_ONLY)).toBe(true); + }); + + it('gives approve-only members read-only access like members', () => { + const policy = buildPolicy(CONST.POLICY.ROLE.APPROVE_ONLY); + + expect(canMemberRead(policy, memberLogin, CONST.POLICY.POLICY_FEATURE.OVERVIEW)).toBe(true); + expect(canMemberWrite(policy, memberLogin, CONST.POLICY.POLICY_FEATURE.OVERVIEW)).toBe(false); + }); + it('allows Submit workspace editors to manage editor memberships without assigning roles', () => { const policy = { ...buildPolicy(CONST.POLICY.ROLE.EDITOR), @@ -790,6 +804,41 @@ describe('PolicyUtils', () => { expect(result).toHaveLength(1); expect(result.at(0)?.id).toBe(activePolicy.id); }); + + it('excludes policies where the current user is approve-only', () => { + const approveOnlyPolicy = createMock({ + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + name: 'approveOnly', + role: CONST.POLICY.ROLE.APPROVE_ONLY, + pendingAction: null, + }); + const memberPolicy = createMock({...createRandomPolicy(2, CONST.POLICY.TYPE.CORPORATE), name: 'member', role: CONST.POLICY.ROLE.USER, pendingAction: null}); + const policies = createCollection( + (item) => `${ONYXKEYS.COLLECTION.POLICY}${item.id}`, + (index) => (index === 0 ? approveOnlyPolicy : memberPolicy), + 2, + ); + + const result = getActivePoliciesWithExpenseChat(policies, employeeEmail); + expect(result).toHaveLength(1); + expect(result.at(0)?.id).toBe(memberPolicy.id); + }); + }); + + describe('getGroupPoliciesWhereReportCanBeCreated', () => { + it('excludes workspaces where the current user is approve-only', () => { + const approveOnlyPolicy = createMock({...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), role: CONST.POLICY.ROLE.APPROVE_ONLY, pendingAction: null}); + const memberPolicy = createMock({...createRandomPolicy(2, CONST.POLICY.TYPE.TEAM), role: CONST.POLICY.ROLE.USER, pendingAction: null}); + const policies = createCollection( + (item) => `${ONYXKEYS.COLLECTION.POLICY}${item.id}`, + (index) => (index === 0 ? approveOnlyPolicy : memberPolicy), + 2, + ); + + const result = getGroupPoliciesWhereReportCanBeCreated(policies, employeeEmail); + expect(result).toHaveLength(1); + expect(result.at(0)?.id).toBe(memberPolicy.id); + }); }); describe('getCustomUnitsForDuplication', () => { const perDiemUnit = { @@ -1943,6 +1992,24 @@ describe('PolicyUtils', () => { const result = isWorkspaceEligibleForReportChange(currentUserLogin, newPolicy); expect(result).toBe(false); }); + + it('returns false if the submitter is approve-only on the new policy', async () => { + const currentUserLogin = employeeEmail; + + const newPolicy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + role: undefined, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, + employeeList: { + [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.APPROVE_ONLY}, + }, + pendingAction: null, + }; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${newPolicy.id}`, newPolicy); + + const result = isWorkspaceEligibleForReportChange(currentUserLogin, newPolicy); + expect(result).toBe(false); + }); }); describe('getTagList', () => { diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 16252e5c3736..25cc13043324 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -14874,6 +14874,24 @@ describe('ReportUtils', () => { // Then account 3 (passed explicitly) cannot request money on it expect(canRequestMoney(iouReport, undefined, [1, 2], undefined, 3)).toBe(false); }); + + it('should return false for a workspace chat when the current user is approve-only on that workspace', () => { + const policyID = 'approveOnlyPolicyID'; + const report: Report = { + ...createRandomReport(1, undefined), + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + policyID, + isOwnPolicyExpenseChat: true, + }; + const approveOnlyPolicy = createMock({ + ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + id: policyID, + role: CONST.POLICY.ROLE.APPROVE_ONLY, + }); + + expect(canRequestMoney(report, approveOnlyPolicy, [1], undefined)).toBe(false); + }); }); // Note: shouldShowFlagComment() calls isArchivedNonExpenseReport() which has it's own unit tests, so whether From 3ae8818d7d7819f44e06336b61837c3425c510c6 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:27:50 +0530 Subject: [PATCH 02/56] Gate expense creation on canRoleCreateExpenses helper --- .../WorkspaceRowThreeDotsMenu.tsx | 3 ++- src/hooks/usePolicyForMovingExpenses.ts | 4 ++-- src/libs/OptionsListUtils/index.ts | 3 ++- src/libs/PolicyUtils.ts | 13 +++++++++++-- src/libs/ReportUtils.ts | 5 +++-- src/pages/SetDefaultWorkspacePage.tsx | 4 ++-- tests/unit/PolicyUtilsTest.ts | 13 ++++++++++++- 7 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx b/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx index 69fabc067424..14a7748523f1 100644 --- a/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx +++ b/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx @@ -11,6 +11,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {clearCopyPolicySettings} from '@libs/actions/Policy/CopyPolicySettings'; import {callFunctionIfActionIsAllowed} from '@libs/actions/Session'; import Navigation from '@libs/Navigation/Navigation'; +import {canRoleCreateExpenses} from '@libs/PolicyUtils'; import shouldRenderTransferOwnerButton from '@libs/shouldRenderTransferOwnerButton'; import {setNameValuePair} from '@userActions/User'; @@ -126,7 +127,7 @@ function WorkspaceRowThreeDotsMenu({item, onDeleteWorkspace, pendingDeletePolicy } // Approve-only members cannot create expenses, so making the workspace their default would break expense creation routing. - if (!isDefault && !item?.isJoinRequestPending && !isRestrictedToPreferredPolicy && item.role !== CONST.POLICY.ROLE.APPROVE_ONLY) { + if (!isDefault && !item?.isJoinRequestPending && !isRestrictedToPreferredPolicy && canRoleCreateExpenses(item.role)) { menuItems.push({ icon: icons.Star, text: translate('workspace.common.setAsDefault'), diff --git a/src/hooks/usePolicyForMovingExpenses.ts b/src/hooks/usePolicyForMovingExpenses.ts index efffec9720b6..14be18612471 100644 --- a/src/hooks/usePolicyForMovingExpenses.ts +++ b/src/hooks/usePolicyForMovingExpenses.ts @@ -2,7 +2,7 @@ import {useActivePolicyContext} from '@components/ActivePolicyProvider'; import {useSession} from '@components/OnyxListItemProvider'; import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; -import {canSubmitPerDiemExpenseFromWorkspace, isGroupPolicy, isPolicyMemberWithoutPendingDelete, isTimeTrackingEnabled} from '@libs/PolicyUtils'; +import {canRoleCreateExpenses, canSubmitPerDiemExpenseFromWorkspace, isGroupPolicy, isPolicyMemberWithoutPendingDelete, isTimeTrackingEnabled} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -30,7 +30,7 @@ function isPolicyValidForMovingExpenses(policy: OnyxEntry, login: string checkForUserPendingDelete(login, policy) && isPolicyMemberByRole(policy) && // Moving an expense to a workspace creates it there, which approve-only members cannot do. - policy?.role !== CONST.POLICY.ROLE.APPROVE_ONLY && + canRoleCreateExpenses(policy?.role) && isGroupPolicy(policy) && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && // Teachers Unite doesn't support reimbursement, so it can never be a destination for moving/reporting an expense. diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index c12a0827ce96..e310f7c2fcaf 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -20,6 +20,7 @@ import type {OptionData as PersonalDetailOptionData} from '@libs/PersonalDetailO import {getLoginByAccountID, getPersonalDetailForAccountID, getPersonalDetailsForAccountIDs, temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {addSMSDomainIfPhoneNumber, parsePhoneNumber} from '@libs/PhoneNumber'; import { + canRoleCreateExpenses, canSendInvoiceFromWorkspace, canSubmitPerDiemExpenseFromWorkspace, getCountOfEnabledTagsOfList, @@ -1913,7 +1914,7 @@ function isValidReport( } // Approve-only members cannot create expenses, so their workspace chat is not a valid submit destination. - if (isPolicyExpenseChat && excludeApproveOnlyWorkspaces && policy?.role === CONST.POLICY.ROLE.APPROVE_ONLY) { + if (isPolicyExpenseChat && excludeApproveOnlyWorkspaces && !canRoleCreateExpenses(policy?.role)) { return false; } diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 3fb904fce2f1..650471a20dad 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -163,7 +163,7 @@ function getActivePoliciesWithExpenseChat(policies: OnyxCollection | nul !!policy.id && !!getPolicyRole(policy, currentUserLogin) && // Approve-only members cannot create expenses, so their workspaces are not expense destinations. - getPolicyRole(policy, currentUserLogin) !== CONST.POLICY.ROLE.APPROVE_ONLY && + canRoleCreateExpenses(getPolicyRole(policy, currentUserLogin)) && (isPaidGroupPolicy(policy) || isSubmitPolicy(policy)) && !isArchivedPolicy(policy), ); @@ -794,6 +794,14 @@ function canRolePay(role: string | undefined): boolean { return !!role && ROLE_PERMISSION_BUNDLES[role]?.[CONST.POLICY.POLICY_FEATURE.WORKFLOWS_PAYMENTS] === CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE; } +/** + * Whether a role allows creating expenses on a workspace. Approve-only members can only approve, so every + * expense-creation entry point gates on this. + */ +function canRoleCreateExpenses(role: string | undefined): boolean { + return role !== CONST.POLICY.ROLE.APPROVE_ONLY; +} + /** * The roles that are allowed to pay (reimburse) on a workspace, derived from the WORKFLOWS_PAYMENTS permission. The * Authorized Payer (reimburser) must always hold one of these, so any role change for a payer is restricted to this set. @@ -3302,7 +3310,7 @@ function getGroupPoliciesWhereReportCanBeCreated(policies: OnyxCollection, policy: OnyxEntry, o } // Approve-only members cannot create expenses on the workspace, so its chats and reports offer no create options. - if (policy?.role === CONST.POLICY.ROLE.APPROVE_ONLY && !!policy?.id && report?.policyID === policy.id) { + if (!!policy?.id && report?.policyID === policy.id && !canRoleCreateExpenses(policy.role)) { return false; } @@ -13635,7 +13636,7 @@ function isWorkspaceEligibleForReportChange(submitterEmail: string | undefined, return false; } // Moving a report to a workspace creates expenses there, which an approve-only submitter cannot do. - if (getPolicyRole(newPolicy, submitterEmail) === CONST.POLICY.ROLE.APPROVE_ONLY) { + if (!canRoleCreateExpenses(getPolicyRole(newPolicy, submitterEmail))) { return false; } return !!newPolicy.role && !isPendingDeletePolicy(newPolicy); diff --git a/src/pages/SetDefaultWorkspacePage.tsx b/src/pages/SetDefaultWorkspacePage.tsx index c6dc91b5b09b..629c0e3f8bf3 100644 --- a/src/pages/SetDefaultWorkspacePage.tsx +++ b/src/pages/SetDefaultWorkspacePage.tsx @@ -19,7 +19,7 @@ import useWorkspaceList from '@hooks/useWorkspaceList'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import {isGroupPolicy} from '@libs/PolicyUtils'; +import {canRoleCreateExpenses, isGroupPolicy} from '@libs/PolicyUtils'; import type {MoneyRequestNavigatorParamList} from '@navigation/types'; @@ -84,7 +84,7 @@ function SetDefaultWorkspacePage({route}: SetDefaultWorkspacePageProps) { searchTerm: debouncedSearchTerm, localeCompare, // Approve-only members cannot create expenses, so their workspaces cannot be the default expense destination. - additionalFilter: (newPolicy) => isGroupPolicy(newPolicy) && newPolicy.role !== CONST.POLICY.ROLE.APPROVE_ONLY, + additionalFilter: (newPolicy) => isGroupPolicy(newPolicy) && canRoleCreateExpenses(newPolicy.role), }); const confirmButtonOptions = { diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 11e03b57731b..ca49b1aeee05 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -13,6 +13,7 @@ import { canMemberManageMemberWithRole, canMemberRead, canMemberWrite, + canRoleCreateExpenses, canSendInvoiceFromWorkspace, evaluateApprovalWorkflowRule, findVendorByID, @@ -416,6 +417,13 @@ describe('PolicyUtils', () => { expect(canMemberWrite(policy, memberLogin, CONST.POLICY.POLICY_FEATURE.OVERVIEW)).toBe(false); }); + it('allows every role except approve-only to create expenses', () => { + expect(canRoleCreateExpenses(CONST.POLICY.ROLE.USER)).toBe(true); + expect(canRoleCreateExpenses(CONST.POLICY.ROLE.ADMIN)).toBe(true); + expect(canRoleCreateExpenses(CONST.POLICY.ROLE.AUDITOR)).toBe(true); + expect(canRoleCreateExpenses(CONST.POLICY.ROLE.APPROVE_ONLY)).toBe(false); + }); + it('allows Submit workspace editors to manage editor memberships without assigning roles', () => { const policy = { ...buildPolicy(CONST.POLICY.ROLE.EDITOR), @@ -787,10 +795,11 @@ describe('PolicyUtils', () => { }); describe('getActivePoliciesWithExpenseChat', () => { it('excludes archived policies while keeping the active sibling', () => { - const activePolicy = createMock({...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), name: 'active', pendingAction: null}); + const activePolicy = createMock({...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), name: 'active', role: CONST.POLICY.ROLE.USER, pendingAction: null}); const archivedPolicy = createMock({ ...createRandomPolicy(2, CONST.POLICY.TYPE.CORPORATE), name: 'archived', + role: CONST.POLICY.ROLE.USER, pendingAction: null, archivedDate: '2024-01-01', }); @@ -1937,6 +1946,7 @@ describe('PolicyUtils', () => { const newPolicy = { ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), + role: CONST.POLICY.ROLE.ADMIN, reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, employeeList: { [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.ADMIN}, @@ -1971,6 +1981,7 @@ describe('PolicyUtils', () => { const newPolicy = { ...createRandomPolicy(1, CONST.POLICY.TYPE.SUBMIT), + role: CONST.POLICY.ROLE.ADMIN, reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, employeeList: { [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.ADMIN}, From 737b1f30221672fae6e749abff91a6930aa449a5 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:31:31 +0530 Subject: [PATCH 03/56] Gate expense creation on CREATE_EXPENSES permission instead of role checks --- src/CONST/index.ts | 1 + src/libs/PolicyUtils.ts | 17 +++++++++++------ tests/unit/PolicyUtilsTest.ts | 4 ++-- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index ebc76b40bbee..7ab044444a11 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -4536,6 +4536,7 @@ const CONST = { REPORT_FIELDS: 'reportFields', ACCOUNTING: 'accounting', MORE_FEATURES: 'moreFeatures', + CREATE_EXPENSES: 'createExpenses', }, POLICY_FEATURE_ACCESS: { READ: 'read', diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 650471a20dad..62653e0542be 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -241,13 +241,15 @@ const EDITOR_POLICY_FEATURES = Object.fromEntries( const ROLE_PERMISSION_BUNDLES: Record>> = { [CONST.POLICY.ROLE.ADMIN]: WRITE_ALL_POLICY_FEATURES, [CONST.POLICY.ROLE.EDITOR]: EDITOR_POLICY_FEATURES, - [CONST.POLICY.ROLE.AUDITOR]: READ_ALL_POLICY_FEATURES, + // Auditors get read-only access to workspace settings, but they can still create expenses. + [CONST.POLICY.ROLE.AUDITOR]: {...READ_ALL_POLICY_FEATURES, [CONST.POLICY.POLICY_FEATURE.CREATE_EXPENSES]: CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE}, [CONST.POLICY.ROLE.USER]: { [CONST.POLICY.POLICY_FEATURE.OVERVIEW]: CONST.POLICY.POLICY_FEATURE_ACCESS.READ, [CONST.POLICY.POLICY_FEATURE.MEMBERS]: CONST.POLICY.POLICY_FEATURE_ACCESS.READ, + [CONST.POLICY.POLICY_FEATURE.CREATE_EXPENSES]: CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE, }, - // Approve-only members act on reports routed to them but cannot create expenses, so their workspace - // feature access matches a Member. The no-create rule is enforced by the creation entry points, not here. + // Approve-only members act on reports routed to them. Their workspace feature access matches a Member, + // except for CREATE_EXPENSES, which every expense-creation entry point gates on. [CONST.POLICY.ROLE.APPROVE_ONLY]: { [CONST.POLICY.POLICY_FEATURE.OVERVIEW]: CONST.POLICY.POLICY_FEATURE_ACCESS.READ, [CONST.POLICY.POLICY_FEATURE.MEMBERS]: CONST.POLICY.POLICY_FEATURE_ACCESS.READ, @@ -257,18 +259,21 @@ const ROLE_PERMISSION_BUNDLES: Record { expect(canMemberWrite(policy, memberLogin, CONST.POLICY.POLICY_FEATURE.ASSIGN_ELEVATED_ROLES)).toBe(false); }); - it('allows auditors to read but not write every policy feature', () => { + it('allows auditors to read every policy feature but only write expense creation', () => { const policy = buildPolicy(CONST.POLICY.ROLE.AUDITOR); for (const feature of Object.values(CONST.POLICY.POLICY_FEATURE)) { expect(canMemberRead(policy, memberLogin, feature)).toBe(true); - expect(canMemberWrite(policy, memberLogin, feature)).toBe(false); + expect(canMemberWrite(policy, memberLogin, feature)).toBe(feature === CONST.POLICY.POLICY_FEATURE.CREATE_EXPENSES); } }); From 3a47f562429435afb8f7b3a35148e6fc9f0de4db Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:04:58 +0530 Subject: [PATCH 04/56] Update approve-only role picker subtext copy --- src/languages/de.ts | 2 +- src/languages/el.ts | 2 +- src/languages/en.ts | 2 +- src/languages/es.ts | 2 +- src/languages/fr.ts | 2 +- src/languages/it.ts | 2 +- src/languages/ja.ts | 2 +- src/languages/nl.ts | 2 +- src/languages/pl.ts | 2 +- src/languages/pt-BR.ts | 2 +- src/languages/zh-hans.ts | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 40c5959aa48d..790e5d53fdd4 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -4895,7 +4895,7 @@ ${amount} für ${merchant} – ${date}`, cardAdminAlternateText: 'Arbeitsbereichskarten verwalten.', peopleAdminAlternateText: 'Mitglieder und Genehmigungsabläufe verwalten.', paymentsAdminAlternateText: 'Workflow-Zahlungen verwalten.', - approveOnlyAlternateText: 'Berichte genehmigen, ohne sie einzureichen.', + approveOnlyAlternateText: 'Nur Berichte genehmigen.', readOnlyActionTitle: 'Nicht so schnell …', readOnlyActionPrompt: 'Ihre Arbeitsbereichsrolle kann diese Einstellungen anzeigen, aber nicht bearbeiten.', noAccessActionPrompt: 'Ihre Arbeitsbereichsrolle hat keinen Zugriff auf diese Einstellungen. Bitten Sie eine/n Admin, falls Sie Zugriff benötigen.', diff --git a/src/languages/el.ts b/src/languages/el.ts index 118a357d730c..290919ae84cf 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -4908,7 +4908,7 @@ ${amount} για ${merchant} - ${date}`, cardAdminAlternateText: 'Διαχειριστείτε τις κάρτες χώρου εργασίας.', peopleAdminAlternateText: 'Διαχειριστείτε μέλη και ροές έγκρισης.', paymentsAdminAlternateText: 'Διαχειριστείτε τις πληρωμές ροής εργασιών.', - approveOnlyAlternateText: 'Εγκρίνετε αναφορές χωρίς να τις υποβάλλετε.', + approveOnlyAlternateText: 'Μόνο έγκριση αναφορών.', reimbursementChoice: { [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES]: 'Άμεσο', [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO]: 'Κανένα', diff --git a/src/languages/en.ts b/src/languages/en.ts index e77781dc15c6..577a16a04b89 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4981,7 +4981,7 @@ const translations = { cardAdminAlternateText: 'Manage workspace cards.', peopleAdminAlternateText: 'Manage members and approval workflows.', paymentsAdminAlternateText: 'Manage workflow payments.', - approveOnlyAlternateText: 'Approve reports without submitting them.', + approveOnlyAlternateText: 'Approve reports only.', reimbursementChoice: { [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES]: 'Direct', [CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO]: 'None', diff --git a/src/languages/es.ts b/src/languages/es.ts index 1663d88d8b2b..608338c4316c 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4827,7 +4827,7 @@ ${amount} para ${merchant} - ${date}`, cardAdminAlternateText: 'Gestiona tarjetas del espacio de trabajo.', peopleAdminAlternateText: 'Gestiona miembros y flujos de aprobación.', paymentsAdminAlternateText: 'Gestiona los pagos del flujo de trabajo.', - approveOnlyAlternateText: 'Aprueba informes sin presentarlos.', + approveOnlyAlternateText: 'Solo aprobar informes.', noAccessActionPrompt: 'Tu rol en el espacio de trabajo no tiene acceso a estos ajustes. Pide a un administrador que te lo habilite si lo necesitas.', }, createdForClient: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index fc55a48b691f..da342a5ca510 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -4901,7 +4901,7 @@ ${amount} pour ${merchant} - ${date}`, cardAdminAlternateText: 'Gérer les cartes de l’espace de travail.', peopleAdminAlternateText: 'Gérez les membres et les workflows d’approbation.', paymentsAdminAlternateText: 'Gérer les paiements de workflow.', - approveOnlyAlternateText: 'Approuver les rapports sans les soumettre.', + approveOnlyAlternateText: 'Approuver uniquement les rapports.', readOnlyActionTitle: 'Pas si vite...', readOnlyActionPrompt: 'Votre rôle dans cet espace de travail peut afficher ces paramètres, mais ne peut pas les modifier.', noAccessActionPrompt: 'Votre rôle dans l’espace de travail n’a pas accès à ces paramètres. Demandez à un administrateur si vous en avez besoin.', diff --git a/src/languages/it.ts b/src/languages/it.ts index 1bb73a015eb6..10bd14386c6b 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -4864,7 +4864,7 @@ ${amount} per ${merchant} - ${date}`, cardAdminAlternateText: 'Gestisci le carte dello spazio di lavoro.', peopleAdminAlternateText: 'Gestisci i membri e i flussi di approvazione.', paymentsAdminAlternateText: 'Gestisci i pagamenti del flusso di lavoro.', - approveOnlyAlternateText: 'Approva i report senza inviarli.', + approveOnlyAlternateText: 'Approva solo i report.', readOnlyActionTitle: 'Non così in fretta...', readOnlyActionPrompt: 'Il tuo ruolo nello spazio di lavoro può visualizzare queste impostazioni, ma non può modificarle.', noAccessActionPrompt: 'Il tuo ruolo nello spazio di lavoro non ha accesso a queste impostazioni. Chiedi a un amministratore se ne hai bisogno.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 99d8805b3f51..f563cb0a6f30 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -4829,7 +4829,7 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' cardAdminAlternateText: 'ワークスペースカードを管理します。', peopleAdminAlternateText: 'メンバーと承認ワークフローを管理します。', paymentsAdminAlternateText: 'ワークフローの支払いを管理します。', - approveOnlyAlternateText: '提出せずにレポートを承認します。', + approveOnlyAlternateText: 'レポートの承認のみ。', readOnlyActionTitle: 'ちょっと待ってください…', readOnlyActionPrompt: 'このワークスペースでのあなたのロールは、これらの設定を表示できますが、編集することはできません。', noAccessActionPrompt: 'このワークスペースでのあなたのロールでは、これらの設定にアクセスできません。必要な場合は管理者に依頼してください。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index b2ada9785a34..05547652a6af 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -4866,7 +4866,7 @@ ${amount} voor ${merchant} - ${date}`, cardAdminAlternateText: 'Werkruimtekaarten beheren.', peopleAdminAlternateText: 'Beheer leden en goedkeuringsworkflows.', paymentsAdminAlternateText: 'Workflowsbetalingen beheren.', - approveOnlyAlternateText: 'Keur rapporten goed zonder ze in te dienen.', + approveOnlyAlternateText: 'Alleen rapporten goedkeuren.', readOnlyActionTitle: 'Niet zo snel...', readOnlyActionPrompt: 'Je rol in de workspace kan deze instellingen bekijken, maar niet bewerken.', noAccessActionPrompt: 'Je rol in deze workspace heeft geen toegang tot deze instellingen. Vraag een admin als je die nodig hebt.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index c18981d878f5..61aa514a11ef 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -4894,7 +4894,7 @@ ${amount} dla ${merchant} - ${date}`, cardAdminAlternateText: 'Zarządzaj kartami przestrzeni roboczej.', peopleAdminAlternateText: 'Zarządzaj członkami i procesami akceptacji.', paymentsAdminAlternateText: 'Zarządzaj płatnościami w przepływie pracy.', - approveOnlyAlternateText: 'Zatwierdzaj raporty bez ich przesyłania.', + approveOnlyAlternateText: 'Tylko zatwierdzanie raportów.', readOnlyActionTitle: 'Nie tak szybko…', readOnlyActionPrompt: 'Twoja rola w przestrzeni roboczej może wyświetlać te ustawienia, ale nie może ich edytować.', noAccessActionPrompt: 'Twoja rola w tym zespole nie ma dostępu do tych ustawień. Jeśli go potrzebujesz, poproś administratora.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index cde43416629a..a8d2ae898b1d 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -4857,7 +4857,7 @@ ${amount} para ${merchant} - ${date}`, cardAdminAlternateText: 'Gerenciar cartões do workspace.', peopleAdminAlternateText: 'Gerencie membros e fluxos de aprovação.', paymentsAdminAlternateText: 'Gerencie pagamentos de fluxo de trabalho.', - approveOnlyAlternateText: 'Aprove relatórios sem enviá-los.', + approveOnlyAlternateText: 'Apenas aprovar relatórios.', readOnlyActionTitle: 'Calma aí...', readOnlyActionPrompt: 'Sua função no workspace pode ver essas configurações, mas não pode editá-las.', noAccessActionPrompt: 'Sua função no espaço de trabalho não tem acesso a essas configurações. Peça a um administrador se você precisar disso.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 81e4ff2299ff..cfb2692f6c5a 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -4719,7 +4719,7 @@ ${amount},商户:${merchant} - 日期:${date}`, cardAdminAlternateText: '管理工作区卡片。', peopleAdminAlternateText: '管理成员和审批流程。', paymentsAdminAlternateText: '管理工作流付款。', - approveOnlyAlternateText: '审批报告而无需提交。', + approveOnlyAlternateText: '仅审批报告。', readOnlyActionTitle: '别急……', readOnlyActionPrompt: '你的工作区角色可以查看这些设置,但不能编辑。', noAccessActionPrompt: '您的工作区角色无权访问这些设置。如需访问,请联系管理员。', From 7c67c2b68c0354beeaa96030784047c8a144e1f6 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:17:19 +0530 Subject: [PATCH 05/56] Hide create report option in workspace chat for approve-only members --- .../ReportActionCompose/AttachmentPickerWithMenuItems.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index 29c3411aeba5..e56ed9c7ea2a 100644 --- a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -28,7 +28,7 @@ import getButtonState from '@libs/getButtonState'; import getIconForAction from '@libs/getIconForAction'; import Navigation from '@libs/Navigation/Navigation'; import {getDistanceExpenseTypeForPolicy} from '@libs/PolicyDistanceRatesUtils'; -import {isGroupPolicyByType} from '@libs/PolicyUtils'; +import {canRoleCreateExpenses, isGroupPolicyByType} from '@libs/PolicyUtils'; import { canCreateTaskInReport, getPayeeName, @@ -337,7 +337,8 @@ function AttachmentPickerWithMenuItems({ ]); const createReportOption: PopoverMenuItem[] = useMemo(() => { - if (!isPolicyExpenseChat(report) || !isGroupPolicyByType(policy?.type) || !isReportOwner(report)) { + // Approve-only members cannot create expenses, so they cannot create reports on the workspace either. + if (!isPolicyExpenseChat(report) || !isGroupPolicyByType(policy?.type) || !isReportOwner(report) || !canRoleCreateExpenses(policy?.role)) { return []; } @@ -350,7 +351,7 @@ function AttachmentPickerWithMenuItems({ onSelected: () => selectOption(() => handleCreateReport(), true), }, ]; - }, [icons.Document, handleCreateReport, policy?.type, report, selectOption, shouldUseNarrowLayout, translate]); + }, [icons.Document, handleCreateReport, policy?.role, policy?.type, report, selectOption, shouldUseNarrowLayout, translate]); /** * Determines if we can show the task option From 1caa665e25f87cd9f1883c74f1f74b31543d7930 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:30:03 +0530 Subject: [PATCH 06/56] Show empty state on workspace rooms page when no rooms exist --- src/components/Tables/WorkspaceRoomsTable/index.tsx | 4 ++++ src/languages/de.ts | 2 ++ src/languages/el.ts | 2 ++ src/languages/en.ts | 2 ++ src/languages/es.ts | 2 ++ src/languages/fr.ts | 2 ++ src/languages/it.ts | 2 ++ src/languages/ja.ts | 2 ++ src/languages/nl.ts | 2 ++ src/languages/pl.ts | 2 ++ src/languages/pt-BR.ts | 2 ++ src/languages/zh-hans.ts | 2 ++ 12 files changed, 26 insertions(+) diff --git a/src/components/Tables/WorkspaceRoomsTable/index.tsx b/src/components/Tables/WorkspaceRoomsTable/index.tsx index 7c0f73981d70..da29b2783ae4 100644 --- a/src/components/Tables/WorkspaceRoomsTable/index.tsx +++ b/src/components/Tables/WorkspaceRoomsTable/index.tsx @@ -154,6 +154,10 @@ function WorkspaceRoomsTable({rooms, policyID, highlightedReportID, onSearchStri ListFooterComponent={listFooterComponent} > {tableHeaderComponent} + diff --git a/src/languages/de.ts b/src/languages/de.ts index 790e5d53fdd4..16c0f8db878f 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -4737,6 +4737,8 @@ ${amount} für ${merchant} – ${date}`, archived: 'Archiviert', workspaceStatus: 'Workspace-Status', findRoom: 'Raum finden', + emptyRoomsTitle: 'Noch keine Räume', + emptyRoomsSubtitle: 'Räume, die in diesem Arbeitsbereich erstellt werden, erscheinen hier.', edit: 'Arbeitsbereich bearbeiten', enabled: 'Aktiviert', disabled: 'Deaktiviert', diff --git a/src/languages/el.ts b/src/languages/el.ts index 290919ae84cf..62d53a09d154 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -4803,6 +4803,8 @@ ${amount} για ${merchant} - ${date}`, workspaceStatus: 'Κατάσταση χώρου εργασίας', findDomain: 'Εύρεση τομέα', findRoom: 'Βρείτε δωμάτιο', + emptyRoomsTitle: 'Δεν υπάρχουν ακόμη δωμάτια', + emptyRoomsSubtitle: 'Τα δωμάτια που δημιουργούνται σε αυτόν τον χώρο εργασίας θα εμφανίζονται εδώ.', edit: 'Επεξεργασία χώρου εργασίας', enabled: 'Ενεργοποιημένο', disabled: 'Απενεργοποιημένο', diff --git a/src/languages/en.ts b/src/languages/en.ts index 577a16a04b89..87e234db6a44 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4872,6 +4872,8 @@ const translations = { workspaceStatus: 'Workspace status', findDomain: 'Find domain', findRoom: 'Find room', + emptyRoomsTitle: 'No rooms yet', + emptyRoomsSubtitle: 'Rooms created in this workspace will appear here.', edit: 'Edit workspace', enabled: 'Enabled', disabled: 'Disabled', diff --git a/src/languages/es.ts b/src/languages/es.ts index 608338c4316c..5d7a2680754d 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4659,6 +4659,8 @@ ${amount} para ${merchant} - ${date}`, archived: 'Archivado', workspaceStatus: 'Estado del espacio de trabajo', findRoom: 'Encontrar sala', + emptyRoomsTitle: 'Aún no hay salas', + emptyRoomsSubtitle: 'Las salas creadas en este espacio de trabajo aparecerán aquí.', edit: 'Editar espacio de trabajo', enabled: 'Activada', disabled: 'Desactivada', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index da342a5ca510..48ba2fa55df0 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -4744,6 +4744,8 @@ ${amount} pour ${merchant} - ${date}`, archived: 'Archivé', workspaceStatus: "Statut de l'espace de travail", findRoom: 'Trouver un salon', + emptyRoomsTitle: 'Aucun salon pour le moment', + emptyRoomsSubtitle: 'Les salons créés dans cet espace de travail apparaîtront ici.', edit: 'Modifier l’espace de travail', enabled: 'Activé', disabled: 'Désactivé', diff --git a/src/languages/it.ts b/src/languages/it.ts index 10bd14386c6b..0c20d8d679e6 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -4707,6 +4707,8 @@ ${amount} per ${merchant} - ${date}`, archived: 'Archiviato', workspaceStatus: 'Stato dello spazio di lavoro', findRoom: 'Trova stanza', + emptyRoomsTitle: 'Ancora nessuna stanza', + emptyRoomsSubtitle: 'Le stanze create in questo spazio di lavoro appariranno qui.', edit: 'Modifica spazio di lavoro', enabled: 'Abilitato', disabled: 'Disattivato', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index f563cb0a6f30..cdc57531d039 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -4672,6 +4672,8 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' archived: 'アーカイブ済み', workspaceStatus: 'ワークスペースのステータス', findRoom: 'ルームを探す', + emptyRoomsTitle: 'ルームはまだありません', + emptyRoomsSubtitle: 'このワークスペースで作成されたルームはここに表示されます。', edit: 'ワークスペースを編集', enabled: '有効', disabled: '無効', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 05547652a6af..447b4de59172 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -4708,6 +4708,8 @@ ${amount} voor ${merchant} - ${date}`, archived: 'Gearchiveerd', workspaceStatus: 'Werkruimtestatus', findRoom: 'Kamer zoeken', + emptyRoomsTitle: 'Nog geen kamers', + emptyRoomsSubtitle: 'Kamers die in deze werkruimte worden aangemaakt, verschijnen hier.', edit: 'Werkruimte bewerken', enabled: 'Ingeschakeld', disabled: 'Uitgeschakeld', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 61aa514a11ef..6d1dd84c54ed 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -4737,6 +4737,8 @@ ${amount} dla ${merchant} - ${date}`, archived: 'Zarchiwizowana', workspaceStatus: 'Status przestrzeni roboczej', findRoom: 'Znajdź pokój', + emptyRoomsTitle: 'Brak pokoi', + emptyRoomsSubtitle: 'Pokoje utworzone w tym obszarze roboczym pojawią się tutaj.', edit: 'Edytuj przestrzeń roboczą', enabled: 'Włączone', disabled: 'Wyłączone', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index a8d2ae898b1d..0410c663ee70 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -4700,6 +4700,8 @@ ${amount} para ${merchant} - ${date}`, archived: 'Arquivado', workspaceStatus: 'Status do espaço de trabalho', findRoom: 'Encontrar sala', + emptyRoomsTitle: 'Ainda não há salas', + emptyRoomsSubtitle: 'As salas criadas neste espaço de trabalho aparecerão aqui.', edit: 'Editar espaço de trabalho', enabled: 'Ativado', disabled: 'Desativado', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index cfb2692f6c5a..d47eb0f56653 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -4564,6 +4564,8 @@ ${amount},商户:${merchant} - 日期:${date}`, archived: '已归档', workspaceStatus: '工作区状态', findRoom: '查找房间', + emptyRoomsTitle: '暂无房间', + emptyRoomsSubtitle: '在此工作区创建的房间将显示在这里。', edit: '编辑工作区', enabled: '已启用', disabled: '已禁用', From 304de0c6675baecb048b0907c68ed12deab7d869 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:30:17 +0530 Subject: [PATCH 07/56] Skip optimistic workspace chat for approve-only invites --- src/libs/actions/Policy/Member.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index e2bf3cf124b5..a35f039c244d 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -21,7 +21,7 @@ import Parser from '@libs/Parser'; import {buildPersonalDetailsUpdate} from '@libs/PersonalDetailsUtils'; import type {PersonalDetailsOnyxUpdate} from '@libs/PersonalDetailsUtils'; import * as PhoneNumber from '@libs/PhoneNumber'; -import {getDefaultApprover, getOwnerChangePayerSuccessData, isControlPolicy, isPolicyAdmin, isSubmitPolicy} from '@libs/PolicyUtils'; +import {canRoleCreateExpenses, getDefaultApprover, getOwnerChangePayerSuccessData, isControlPolicy, isPolicyAdmin, isSubmitPolicy} from '@libs/PolicyUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; @@ -883,15 +883,17 @@ function buildAddMembersToWorkspaceOnyxData( doesPersonalDetailExistByAccountID[accountID] = !newPersonalDetailAccountIDs.has(accountID); } - // create onyx data for policy expense chats for each new member - const membersChats = createPolicyExpenseChats({ - policyID, - invitedEmailsToAccountIDs, - currentUser, - reportActionsList, - notificationPreference: policyExpenseChatNotificationPreference, - doesPersonalDetailExistByAccountID, - }); + // Approve-only members get no workspace chat from the backend, so building an optimistic one would leave a phantom report in Onyx. + const membersChats = canRoleCreateExpenses(effectiveRole) + ? createPolicyExpenseChats({ + policyID, + invitedEmailsToAccountIDs, + currentUser, + reportActionsList, + notificationPreference: policyExpenseChatNotificationPreference, + doesPersonalDetailExistByAccountID, + }) + : {onyxSuccessData: [], onyxOptimisticData: [], onyxFailureData: [], reportCreationData: {}}; const optimisticMembersState: OnyxCollectionInputValue = {}; const successMembersState: OnyxCollectionInputValue = {}; From a04e1934a4a398cf4d2db5d6ac904dce209f7a1e Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:52:05 +0530 Subject: [PATCH 08/56] Test approve-only workspace exclusion from recipient picker --- tests/unit/OptionsListUtilsTest.tsx | 93 +++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index b291f2330ef8..191c374b34a9 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -1381,6 +1381,99 @@ describe('OptionsListUtils', () => { expect(results.recentReports).toEqual(expect.arrayContaining([expect.objectContaining({login: 'concierge@expensify.com'})])); }); + describe('excludeApproveOnlyWorkspaces', () => { + const approveOnlyPolicyID = 'APPROVEONLYPOLICY1'; + const approveOnlyChatReportID = '901'; + + const buildPolicyWithRole = (role: string): Policy => ({ + id: approveOnlyPolicyID, + name: 'Approve Only Workspace', + role, + type: CONST.POLICY.TYPE.CORPORATE, + owner: 'reedrichards@expensify.com', + outputCurrency: 'USD', + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + employeeList: { + [CURRENT_USER_EMAIL]: { + email: CURRENT_USER_EMAIL, + role, + }, + }, + }); + + const workspaceChatReports: OnyxCollection = { + [approveOnlyChatReportID]: { + reportID: approveOnlyChatReportID, + reportName: 'Approve Only Workspace', + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + isOwnPolicyExpenseChat: true, + type: CONST.REPORT.TYPE.CHAT, + policyID: approveOnlyPolicyID, + policyName: 'Approve Only Workspace', + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + lastVisibleActionCreated: '2022-11-22 03:26:02.001', + participants: { + [CURRENT_USER_ACCOUNT_ID]: { + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, + }, + }, + }, + }; + + // Mirrors the config ParticipantSearchResults passes for create/submit flows + const getOptionsForPolicy = (policy: Policy) => { + const policies: OnyxCollection = { + [`${ONYXKEYS.COLLECTION.POLICY}${approveOnlyPolicyID}`]: policy, + }; + const optionList = createFilteredOptionList( + PERSONAL_DETAILS, + workspaceChatReports, + createMockReportAttributesDerived(workspaceChatReports, PERSONAL_DETAILS, CURRENT_USER_ACCOUNT_ID), + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + policies, + {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, isSearching: true}, + undefined, + ); + + return getValidOptions( + optionList, + policies, + {}, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + undefined, + { + getReportByID: getReportByIDFromOnyx, + dateFnsLocale: undefined, + convertToDisplayString, + includeRecentReports: true, + includeP2P: true, + includeOwnedWorkspaceChats: true, + excludeApproveOnlyWorkspaces: true, + shouldSeparateWorkspaceChat: true, + action: CONST.IOU.ACTION.CREATE, + personalDetails: PERSONAL_DETAILS, + }, + translateLocal, + undefined, + ); + }; + + it('should exclude the workspace chat when the member role is approveOnly', () => { + const {options: results} = getOptionsForPolicy(buildPolicyWithRole(CONST.POLICY.ROLE.APPROVE_ONLY)); + + expect(results.workspaceChats).toEqual([]); + expect(results.recentReports.find((report) => report.reportID === approveOnlyChatReportID)).toBeUndefined(); + }); + + it('should include the workspace chat when the member role can create expenses', () => { + const {options: results} = getOptionsForPolicy(buildPolicyWithRole(CONST.POLICY.ROLE.USER)); + + expect(results.workspaceChats).toEqual(expect.arrayContaining([expect.objectContaining({reportID: approveOnlyChatReportID})])); + }); + }); + it.each([ { description: 'Vietnamese diacritics', From 9d74ab4ddd891709036d63a77c6c26ef43197b0b Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:19:30 +0530 Subject: [PATCH 09/56] Show the new role in the demoted-from-workspace message --- src/languages/de.ts | 4 ++-- src/languages/el.ts | 4 ++-- src/languages/en.ts | 4 ++-- src/languages/es.ts | 2 +- src/languages/fr.ts | 4 ++-- src/languages/it.ts | 4 ++-- src/languages/ja.ts | 4 ++-- src/languages/nl.ts | 4 ++-- src/languages/pl.ts | 4 ++-- src/languages/pt-BR.ts | 4 ++-- src/languages/zh-hans.ts | 3 ++- src/libs/ReportActionsUtils.ts | 3 ++- src/types/onyx/OriginalMessage.ts | 3 +++ 13 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 16c0f8db878f..04cbbd0e20d7 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -9258,8 +9258,8 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc other: `hat Sie aus den Genehmigungs-Workflows und Spesen-Chats von ${joinedNames} entfernt. Zuvor eingereichte Berichte bleiben in Ihrem Posteingang zur Genehmigung verfügbar.`, }; }, - demotedFromWorkspace: (policyName: string, oldRole: string) => - `hat Ihre Rolle in ${policyName} von ${oldRole} zu Nutzer geändert. Sie wurden aus allen Einreicher-Spesen-Chats entfernt, außer aus Ihrem eigenen.`, + demotedFromWorkspace: (policyName: string, oldRole: string, newRole: string) => + `hat Ihre Rolle in ${policyName} von ${oldRole} zu ${newRole} geändert. Sie wurden aus allen Einreicher-Spesen-Chats entfernt, außer aus Ihrem eigenen.`, updatedWorkspaceCurrencyAction: (oldCurrency: string, newCurrency: string) => `Standardwährung auf ${newCurrency} aktualisiert (zuvor ${oldCurrency})`, updatedWorkspaceFrequencyAction: (oldFrequency: string, newFrequency: string) => `die automatische Berichtshäufigkeit auf „${newFrequency}“ aktualisiert (zuvor „${oldFrequency}“)`, updateApprovalMode: (newValue: string, oldValue?: string) => `hat den Genehmigungsmodus auf „${newValue}“ aktualisiert (zuvor „${oldValue}“)`, diff --git a/src/languages/el.ts b/src/languages/el.ts index 62d53a09d154..44c54f60bb9a 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -9539,8 +9539,8 @@ ${reportName}`, other: `σας αφαίρεσε από τις ροές έγκρισης και τις συνομιλίες εξόδων του/της ${joinedNames}. Οι αναφορές που υποβλήθηκαν παλαιότερα θα παραμείνουν διαθέσιμες για έγκριση στα εισερχόμενά σας.`, }; }, - demotedFromWorkspace: (policyName: string, oldRole: string) => - `ενημέρωσε τον ρόλο σας στην πολιτική ${policyName} από ${oldRole} σε χρήστη. Έχετε αφαιρεθεί από όλες τις συνομιλίες εξόδων υποβολέων, εκτός από τις δικές σας.`, + demotedFromWorkspace: (policyName: string, oldRole: string, newRole: string) => + `ενημέρωσε τον ρόλο σας στην πολιτική ${policyName} από ${oldRole} σε ${newRole}. Έχετε αφαιρεθεί από όλες τις συνομιλίες εξόδων υποβολέων, εκτός από τις δικές σας.`, updatedWorkspaceCurrencyAction: (oldCurrency: string, newCurrency: string) => `ενημέρωσε το προεπιλεγμένο νόμισμα σε ${newCurrency} (προηγουμένως ${oldCurrency})`, updatedWorkspaceFrequencyAction: (oldFrequency: string, newFrequency: string) => `ενημέρωσε τη συχνότητα αυτόματης αναφοράς σε «${newFrequency}» (προηγουμένως «${oldFrequency}»)`, updateApprovalMode: (newValue, oldValue) => `ενημέρωσε τη λειτουργία έγκρισης σε «${newValue}» (προηγουμένως «${oldValue}»)`, diff --git a/src/languages/en.ts b/src/languages/en.ts index 87e234db6a44..f9a02613191b 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -9520,8 +9520,8 @@ const translations = { other: `removed you from ${joinedNames}'s approval workflows and expense chats. Previously submitted reports will remain available for approval in your Inbox.`, }; }, - demotedFromWorkspace: (policyName: string, oldRole: string) => - `updated your role in ${policyName} from ${oldRole} to user. You have been removed from all submitter expense chats except for you own.`, + demotedFromWorkspace: (policyName: string, oldRole: string, newRole: string) => + `updated your role in ${policyName} from ${oldRole} to ${newRole}. You have been removed from all submitter expense chats except for you own.`, updatedWorkspaceCurrencyAction: (oldCurrency: string, newCurrency: string) => `updated the default currency to ${newCurrency} (previously ${oldCurrency})`, updatedWorkspaceFrequencyAction: (oldFrequency: string, newFrequency: string) => `updated the auto-reporting frequency to "${newFrequency}" (previously "${oldFrequency}")`, updateApprovalMode: (newValue: string, oldValue?: string) => `updated the approval mode to "${newValue}" (previously "${oldValue}")`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 5d7a2680754d..310c8d70a6e4 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -9179,7 +9179,7 @@ ${reportName}`, other: `te eliminó de los flujos de trabajo de aprobaciones y de los chats de gastos de ${joinedNames}. Los informes enviados anteriormente seguirán estando disponibles para su aprobación en tu bandeja de entrada.`, }; }, - demotedFromWorkspace: (policyName, oldRole) => `cambió tu rol en ${policyName} de ${oldRole} a miembro. Te eliminamos de todos los chats de gastos, excepto el suyo.`, + demotedFromWorkspace: (policyName, oldRole, newRole) => `cambió tu rol en ${policyName} de ${oldRole} a ${newRole}. Te eliminamos de todos los chats de gastos, excepto el suyo.`, updatedWorkspaceCurrencyAction: (oldCurrency, newCurrency) => `actualizó la moneda predeterminada a ${newCurrency} (previamente ${oldCurrency})`, updatedWorkspaceFrequencyAction: (oldFrequency, newFrequency) => `actualizó la frecuencia de generación automática de informes a "${newFrequency}" (previamente "${oldFrequency}")`, updateApprovalMode: (newValue, oldValue) => `actualizó el modo de aprobación a "${newValue}" (previamente "${oldValue}")`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 48ba2fa55df0..11afb70da06c 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -9278,8 +9278,8 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e other: `vous a retiré des workflows d’approbation et des discussions de dépenses de ${joinedNames}. Les notes de frais déjà soumises resteront disponibles pour approbation dans votre boîte de réception.`, }; }, - demotedFromWorkspace: (policyName: string, oldRole: string) => - `a mis à jour votre rôle dans ${policyName}, passant de ${oldRole} à utilisateur. Vous avez été retiré de toutes les discussions de dépenses des déclarants, sauf de la vôtre.`, + demotedFromWorkspace: (policyName: string, oldRole: string, newRole: string) => + `a mis à jour votre rôle dans ${policyName}, passant de ${oldRole} à ${newRole}. Vous avez été retiré de toutes les discussions de dépenses des déclarants, sauf de la vôtre.`, updatedWorkspaceCurrencyAction: (oldCurrency: string, newCurrency: string) => `a mis à jour la devise par défaut en ${newCurrency} (auparavant ${oldCurrency})`, updatedWorkspaceFrequencyAction: (oldFrequency: string, newFrequency: string) => `a mis à jour la fréquence de création automatique de notes de frais sur « ${newFrequency} » (auparavant « ${oldFrequency} »)`, diff --git a/src/languages/it.ts b/src/languages/it.ts index 0c20d8d679e6..8226a9264a34 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -9201,8 +9201,8 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, other: `ti ha rimosso dai flussi di approvazione e dalle chat spese di ${joinedNames}. Le note spese già inviate resteranno disponibili per l'approvazione nella tua Posta in arrivo.`, }; }, - demotedFromWorkspace: (policyName: string, oldRole: string) => - `ha aggiornato il tuo ruolo in ${policyName} da ${oldRole} a utente. Sei stato rimosso da tutte le chat delle note spese dei richiedenti, tranne che dalla tua.`, + demotedFromWorkspace: (policyName: string, oldRole: string, newRole: string) => + `ha aggiornato il tuo ruolo in ${policyName} da ${oldRole} a ${newRole}. Sei stato rimosso da tutte le chat delle note spese dei richiedenti, tranne che dalla tua.`, updatedWorkspaceCurrencyAction: (oldCurrency: string, newCurrency: string) => `ha aggiornato la valuta predefinita in ${newCurrency} (precedentemente ${oldCurrency})`, updatedWorkspaceFrequencyAction: (oldFrequency: string, newFrequency: string) => `ha aggiornato la frequenza di creazione automatica dei report a "${newFrequency}" (in precedenza "${oldFrequency}")`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index cdc57531d039..235e855ebd07 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -9077,8 +9077,8 @@ ${reportName}`, other: `${joinedNames} の承認ワークフローと経費チャットからあなたを削除しました。これまでに提出されたレポートは、引き続き受信トレイで承認可能です。`, }; }, - demotedFromWorkspace: (policyName: string, oldRole: string) => - `${policyName} でのあなたのロールを ${oldRole} からユーザーに更新しました。あなた自身のものを除き、すべての申請者経費チャットから削除されました。`, + demotedFromWorkspace: (policyName: string, oldRole: string, newRole: string) => + `${policyName} でのあなたのロールを ${oldRole} から${newRole}に更新しました。あなた自身のものを除き、すべての申請者経費チャットから削除されました。`, updatedWorkspaceCurrencyAction: (oldCurrency: string, newCurrency: string) => `デフォルト通貨を${newCurrency}(以前は${oldCurrency})に更新しました`, updatedWorkspaceFrequencyAction: (oldFrequency: string, newFrequency: string) => `自動レポート頻度を「${newFrequency}」(以前は「${oldFrequency}」)に更新しました`, updateApprovalMode: (newValue: string, oldValue?: string) => `承認モードを「${newValue}」(以前は「${oldValue}」)に更新しました`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 447b4de59172..684b9a6592ec 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -9175,8 +9175,8 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, other: `heeft je verwijderd uit de goedkeuringsworkflows en onkostenchats van ${joinedNames}. Eerder ingediende rapporten blijven beschikbaar voor goedkeuring in je inbox.`, }; }, - demotedFromWorkspace: (policyName: string, oldRole: string) => - `heeft je rol in ${policyName} bijgewerkt van ${oldRole} naar gebruiker. Je bent verwijderd uit alle inzendingsdeclaratiechats, behalve uit je eigen.`, + demotedFromWorkspace: (policyName: string, oldRole: string, newRole: string) => + `heeft je rol in ${policyName} bijgewerkt van ${oldRole} naar ${newRole}. Je bent verwijderd uit alle inzendingsdeclaratiechats, behalve uit je eigen.`, updatedWorkspaceCurrencyAction: (oldCurrency: string, newCurrency: string) => `heeft de standaardvaluta bijgewerkt naar ${newCurrency} (voorheen ${oldCurrency})`, updatedWorkspaceFrequencyAction: (oldFrequency: string, newFrequency: string) => `heeft de frequentie van automatisch rapporteren gewijzigd naar ‘${newFrequency}’ (voorheen ‘${oldFrequency}’)`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 6d1dd84c54ed..e779f33496d0 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -9188,8 +9188,8 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, other: `usunął(-ę) Cię z obiegów akceptacji i czatów kosztowych użytkownika ${joinedNames}. Wcześniej przesłane raporty pozostaną dostępne do zatwierdzenia w Twojej skrzynce odbiorczej.`, }; }, - demotedFromWorkspace: (policyName: string, oldRole: string) => - `zaktualizowano Twoją rolę w ${policyName} z ${oldRole} na użytkownika. Zostałeś usunięty ze wszystkich czatów wydatków osób rozliczających się, z wyjątkiem własnego czatu.`, + demotedFromWorkspace: (policyName: string, oldRole: string, newRole: string) => + `zaktualizowano Twoją rolę w ${policyName} z ${oldRole} na ${newRole}. Zostałeś usunięty ze wszystkich czatów wydatków osób rozliczających się, z wyjątkiem własnego czatu.`, updatedWorkspaceCurrencyAction: (oldCurrency: string, newCurrency: string) => `zaktualizowano domyślną walutę na ${newCurrency} (wcześniej ${oldCurrency})`, updatedWorkspaceFrequencyAction: (oldFrequency: string, newFrequency: string) => `zaktualizowano częstotliwość automatycznego raportowania na „${newFrequency}” (poprzednio „${oldFrequency}”)`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 0410c663ee70..6e6b33044991 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -9178,8 +9178,8 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, other: `removeu você dos fluxos de aprovação e chats de despesas de ${joinedNames}. Relatórios enviados anteriormente continuarão disponíveis para aprovação na sua Caixa de entrada.`, }; }, - demotedFromWorkspace: (policyName: string, oldRole: string) => - `atualizou sua função em ${policyName} de ${oldRole} para usuário. Você foi removido de todos os chats de despesas de solicitantes, exceto do seu próprio.`, + demotedFromWorkspace: (policyName: string, oldRole: string, newRole: string) => + `atualizou sua função em ${policyName} de ${oldRole} para ${newRole}. Você foi removido de todos os chats de despesas de solicitantes, exceto do seu próprio.`, updatedWorkspaceCurrencyAction: (oldCurrency: string, newCurrency: string) => `atualizou a moeda padrão para ${newCurrency} (anteriormente ${oldCurrency})`, updatedWorkspaceFrequencyAction: (oldFrequency: string, newFrequency: string) => `atualizou a frequência de preenchimento automático para "${newFrequency}" (antes "${oldFrequency}")`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index d47eb0f56653..222cfecd2ff2 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8844,7 +8844,8 @@ ${reportName}`, other: `已将你从${joinedNames}的审批工作流和费用聊天中移除。你之前提交的报告仍可在收件箱中供你审批。`, }; }, - demotedFromWorkspace: (policyName: string, oldRole: string) => `已将你在 ${policyName} 中的角色从 ${oldRole} 更新为用户。你已从所有报销人费用聊天中移除,但你自己的除外。`, + demotedFromWorkspace: (policyName: string, oldRole: string, newRole: string) => + `已将你在 ${policyName} 中的角色从 ${oldRole} 更新为${newRole}。你已从所有报销人费用聊天中移除,但你自己的除外。`, updatedWorkspaceCurrencyAction: (oldCurrency: string, newCurrency: string) => `已将默认货币更新为 ${newCurrency}(之前为 ${oldCurrency})`, updatedWorkspaceFrequencyAction: (oldFrequency: string, newFrequency: string) => `已将自动报表频率更新为“${newFrequency}”(此前为“${oldFrequency}”)`, updateApprovalMode: (newValue: string, oldValue?: string) => `将审批模式更新为“${newValue}”(之前为“${oldValue}”)`, diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 74e8ae7399b3..3d554c12936f 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -4446,7 +4446,8 @@ function getDemotedFromWorkspaceMessage(translate: LocalizedTranslate, reportAct const originalMessage = getOriginalMessage(reportAction); const policyName = originalMessage?.policyName ?? translate('workspace.common.workspace'); const oldRole = translate('workspace.common.roleName', originalMessage?.oldRole).toLowerCase(); - return translate('workspaceActions.demotedFromWorkspace', policyName, oldRole); + const newRole = translate('workspace.common.roleName', originalMessage?.newRole).toLowerCase(); + return translate('workspaceActions.demotedFromWorkspace', policyName, oldRole, newRole); } function getUpdatedAuditRateMessage(translate: LocalizedTranslate, reportAction: OnyxEntry) { diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index ddd24aba6519..79eec775507d 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -1375,6 +1375,9 @@ type OriginalMessageDemotedFromWorkspace = { /** The old role of the employee that is being demoted */ oldRole: string; + /** The new role of the employee that is being demoted. Missing on actions created before the role was included */ + newRole?: string; + /** The accountID of the member who was demoted from workspace */ whisperedTo: number[]; }; From 2e1a84f66513424b03e7e152ccdd00712c8ab081 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:41:49 +0530 Subject: [PATCH 10/56] Add a save button to the member role page and space out the blocked modal --- .../WorkspaceMemberDetailsRolePage.tsx | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index b6c36be120c2..48ac473c1cbe 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -1,3 +1,5 @@ +import Button from '@components/Button'; +import FixedFooter from '@components/FixedFooter'; import ScreenWrapper from '@components/ScreenWrapper'; import WorkspaceMemberRoleList from '@components/WorkspaceMemberRoleList'; import type {ListItemType} from '@components/WorkspaceMemberRoleList'; @@ -25,8 +27,9 @@ import type SCREENS from '@src/SCREENS'; import type {PersonalDetailsList} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; -import React from 'react'; +import React, {useState} from 'react'; type WorkspaceMemberDetailsRolePageProps = Omit & PlatformStackScreenProps & { @@ -42,6 +45,7 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); const memberLogin = personalDetails?.[accountID]?.login ?? ''; const member = policy?.employeeList?.[memberLogin]; + const [selectedRole, setSelectedRole] = useState>(); const canManageSelectedMemberRole = canMemberAssignRole(policy, currentUserLogin, member?.role); // The Authorized Payer (reimburser) must stay a valid payer, so restrict them to the roles that can pay (for example Admin or Payments Admin). const reimburserEmail = getReimburserEmail(policy); @@ -65,14 +69,15 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp }); showConfirmModal({ title: translate('workspace.people.approveOnlyRoleBlockedTitle'), - prompt: [translate('workspace.people.approveOnlyRoleBlockedDescription'), ...reasonLines].join('\n'), + prompt: [translate('workspace.people.approveOnlyRoleBlockedDescription'), '', ...reasonLines].join('\n'), confirmText: translate('workspace.people.approveOnlyRoleBlockedConfirm'), shouldShowCancelButton: false, }); }; - const changeRole = ({value}: ListItemType) => { - if (value === member?.role) { + const saveRole = () => { + const value = selectedRole ?? member?.role; + if (!value || value === member?.role) { return; } if (!canMemberAssignRole(policy, currentUserLogin, value)) { @@ -113,12 +118,22 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp enableEdgeToEdgeBottomSafeAreaPadding > setSelectedRole(item.value)} allowedRoles={allowedRoles} navigateBackTo={ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)} /> + + + ); From 1de8c2ba40c5d386234b8759986295b6f91f8395 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:09:21 +0530 Subject: [PATCH 11/56] Fix type errors in role gates and tests --- src/pages/SetDefaultWorkspacePage.tsx | 2 +- src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx | 3 ++- tests/unit/OptionsListUtilsTest.tsx | 2 +- tests/unit/PolicyUtilsTest.ts | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/pages/SetDefaultWorkspacePage.tsx b/src/pages/SetDefaultWorkspacePage.tsx index 629c0e3f8bf3..53796e3018ed 100644 --- a/src/pages/SetDefaultWorkspacePage.tsx +++ b/src/pages/SetDefaultWorkspacePage.tsx @@ -84,7 +84,7 @@ function SetDefaultWorkspacePage({route}: SetDefaultWorkspacePageProps) { searchTerm: debouncedSearchTerm, localeCompare, // Approve-only members cannot create expenses, so their workspaces cannot be the default expense destination. - additionalFilter: (newPolicy) => isGroupPolicy(newPolicy) && canRoleCreateExpenses(newPolicy.role), + additionalFilter: (newPolicy) => isGroupPolicy(newPolicy) && canRoleCreateExpenses(newPolicy?.role), }); const confirmButtonOptions = { diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index 48ac473c1cbe..5526719e01a9 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -76,7 +76,8 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp }; const saveRole = () => { - const value = selectedRole ?? member?.role; + // The save button stays disabled until a different role is picked, so selectedRole is always set here. + const value = selectedRole; if (!value || value === member?.role) { return; } diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 191c374b34a9..bcb338c9ce30 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -1385,7 +1385,7 @@ describe('OptionsListUtils', () => { const approveOnlyPolicyID = 'APPROVEONLYPOLICY1'; const approveOnlyChatReportID = '901'; - const buildPolicyWithRole = (role: string): Policy => ({ + const buildPolicyWithRole = (role: Policy['role']): Policy => ({ id: approveOnlyPolicyID, name: 'Approve Only Workspace', role, diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 77f103f0a09f..f23f220fe00c 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -2009,7 +2009,7 @@ describe('PolicyUtils', () => { const newPolicy = { ...createRandomPolicy(1, CONST.POLICY.TYPE.TEAM), - role: undefined, + role: CONST.POLICY.ROLE.APPROVE_ONLY, reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL, employeeList: { [currentUserLogin]: {email: currentUserLogin, role: CONST.POLICY.ROLE.APPROVE_ONLY}, From 9157b954e7e7dd098c2496190853648188655f07 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:14:28 +0530 Subject: [PATCH 12/56] Use currentRole for the approve-only entry after upstream refactor --- src/components/WorkspaceMemberRoleList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/WorkspaceMemberRoleList.tsx b/src/components/WorkspaceMemberRoleList.tsx index fa86232ff5a3..2403e4b2af9f 100644 --- a/src/components/WorkspaceMemberRoleList.tsx +++ b/src/components/WorkspaceMemberRoleList.tsx @@ -98,7 +98,7 @@ function getAssignableWorkspaceMemberRoleItems( value: CONST.POLICY.ROLE.APPROVE_ONLY, text: translate('workspace.common.roleName', CONST.POLICY.ROLE.APPROVE_ONLY), alternateText: translate('workspace.common.approveOnlyAlternateText'), - isSelected: role === CONST.POLICY.ROLE.APPROVE_ONLY, + isSelected: currentRole === CONST.POLICY.ROLE.APPROVE_ONLY, keyForList: CONST.POLICY.ROLE.APPROVE_ONLY, }, ]; From 690ed5c5d03d3ea0625787b5ed51351dc996d065 Mon Sep 17 00:00:00 2001 From: Shridhar Goel <35566748+ShridharGoel@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:20:25 +0530 Subject: [PATCH 13/56] Update OptionsListUtilsTest.tsx --- tests/unit/OptionsListUtilsTest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index ffd119f9f5c5..dff05a4b1a54 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -1387,7 +1387,7 @@ describe('OptionsListUtils', () => { }); describe('excludeApproveOnlyWorkspaces', () => { - const approveOnlyPolicyID = 'APPROVEONLYPOLICY1'; + const approveOnlyPolicyID = 'APPROVE_ONLY_POLICY_1'; const approveOnlyChatReportID = '901'; const buildPolicyWithRole = (role: Policy['role']): Policy => ({ From 5384b6538591e53cf9c8a4cb372ea136b5cf82fb Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:40:58 +0530 Subject: [PATCH 14/56] Make gate comments permission-oriented --- .../Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx | 2 +- src/hooks/usePolicyForMovingExpenses.ts | 2 +- src/libs/OptionsListUtils/index.ts | 2 +- src/libs/PolicyUtils/index.ts | 6 +++--- src/libs/ReportUtils.ts | 4 ++-- src/pages/SetDefaultWorkspacePage.tsx | 2 +- .../ReportActionCompose/AttachmentPickerWithMenuItems.tsx | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx b/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx index 56f3e343fc0b..d625752b3f2e 100644 --- a/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx +++ b/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx @@ -133,7 +133,7 @@ function WorkspaceRowThreeDotsMenu({item, onDeleteWorkspace, onArchiveWorkspace, } } - // Approve-only members cannot create expenses, so making the workspace their default would break expense creation routing. + // Making the workspace the default would break expense creation routing for members without create-expenses permission. if (!isDefault && !item?.isJoinRequestPending && !isRestrictedToPreferredPolicy && canRoleCreateExpenses(item.role)) { menuItems.push({ icon: icons.Star, diff --git a/src/hooks/usePolicyForMovingExpenses.ts b/src/hooks/usePolicyForMovingExpenses.ts index 14be18612471..2adf6a7f8cf8 100644 --- a/src/hooks/usePolicyForMovingExpenses.ts +++ b/src/hooks/usePolicyForMovingExpenses.ts @@ -29,7 +29,7 @@ function isPolicyValidForMovingExpenses(policy: OnyxEntry, login: string return ( checkForUserPendingDelete(login, policy) && isPolicyMemberByRole(policy) && - // Moving an expense to a workspace creates it there, which approve-only members cannot do. + // Moving an expense to a workspace creates it there, which requires create-expenses permission. canRoleCreateExpenses(policy?.role) && isGroupPolicy(policy) && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 4bec34d531e4..ec089de2618a 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -1970,7 +1970,7 @@ function isValidReport( return false; } - // Approve-only members cannot create expenses, so their workspace chat is not a valid submit destination. + // A workspace chat is not a valid submit destination when the member lacks create-expenses permission. if (isPolicyExpenseChat && excludeApproveOnlyWorkspaces && !canRoleCreateExpenses(policy?.role)) { return false; } diff --git a/src/libs/PolicyUtils/index.ts b/src/libs/PolicyUtils/index.ts index e32c866efb81..222dbc117f42 100644 --- a/src/libs/PolicyUtils/index.ts +++ b/src/libs/PolicyUtils/index.ts @@ -148,7 +148,7 @@ function getActivePoliciesWithExpenseChat(policies: OnyxCollection | nul !!policy.name && !!policy.id && !!getPolicyRole(policy, currentUserLogin) && - // Approve-only members cannot create expenses, so their workspaces are not expense destinations. + // Workspaces where the member lacks create-expenses permission are not expense destinations. canRoleCreateExpenses(getPolicyRole(policy, currentUserLogin)) && (isPaidGroupPolicy(policy) || isSubmitPolicy(policy)) && !isArchivedPolicy(policy), @@ -787,7 +787,7 @@ function canRolePay(role: string | undefined): boolean { /** * Whether a role allows creating expenses on a workspace, derived from the CREATE_EXPENSES permission. - * Approve-only members can only approve, so every expense-creation entry point gates on this. + * Every expense-creation entry point gates on this. */ function canRoleCreateExpenses(role: string | undefined): boolean { return !!role && ROLE_PERMISSION_BUNDLES[role]?.[CONST.POLICY.POLICY_FEATURE.CREATE_EXPENSES] === CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE; @@ -2462,7 +2462,7 @@ function getGroupPoliciesWhereReportCanBeCreated(policies: OnyxCollection, policy: OnyxEntry, o return false; } - // Approve-only members cannot create expenses on the workspace, so its chats and reports offer no create options. + // Members without create-expenses permission get no create options in the workspace chats and reports. if (!!policy?.id && report?.policyID === policy.id && !canRoleCreateExpenses(policy.role)) { return false; } @@ -13635,7 +13635,7 @@ function isWorkspaceEligibleForReportChange(submitterEmail: string | undefined, if (report?.stateNum === CONST.REPORT.STATE_NUM.APPROVED && report.statusNum === CONST.REPORT.STATUS_NUM.CLOSED && !isPolicyAdminPolicyUtils(newPolicy)) { return false; } - // Moving a report to a workspace creates expenses there, which an approve-only submitter cannot do. + // Moving a report to a workspace creates expenses there, which requires create-expenses permission. if (!canRoleCreateExpenses(getPolicyRole(newPolicy, submitterEmail))) { return false; } diff --git a/src/pages/SetDefaultWorkspacePage.tsx b/src/pages/SetDefaultWorkspacePage.tsx index 53796e3018ed..05feaeebc17f 100644 --- a/src/pages/SetDefaultWorkspacePage.tsx +++ b/src/pages/SetDefaultWorkspacePage.tsx @@ -83,7 +83,7 @@ function SetDefaultWorkspacePage({route}: SetDefaultWorkspacePageProps) { shouldSortSelectedToTop: false, searchTerm: debouncedSearchTerm, localeCompare, - // Approve-only members cannot create expenses, so their workspaces cannot be the default expense destination. + // A workspace cannot be the default expense destination when the member lacks create-expenses permission. additionalFilter: (newPolicy) => isGroupPolicy(newPolicy) && canRoleCreateExpenses(newPolicy?.role), }); diff --git a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index e56ed9c7ea2a..dbf1b6b83382 100644 --- a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -337,7 +337,7 @@ function AttachmentPickerWithMenuItems({ ]); const createReportOption: PopoverMenuItem[] = useMemo(() => { - // Approve-only members cannot create expenses, so they cannot create reports on the workspace either. + // Members without create-expenses permission cannot create reports on the workspace either. if (!isPolicyExpenseChat(report) || !isGroupPolicyByType(policy?.type) || !isReportOwner(report) || !canRoleCreateExpenses(policy?.role)) { return []; } From dcf82571ff0ccd54adbd96f3c65502e3e401d0e7 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:17:59 +0530 Subject: [PATCH 15/56] Handle role update failures and name blocked-reason constants --- src/CONST/index.ts | 6 +++ .../WorkspaceMemberDetailsRolePage.tsx | 51 ++++++++++++------- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 4d0d622c5868..b5c709de2ad8 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -4524,6 +4524,12 @@ const CONST = { PAYMENTS_ADMIN: 'paymentsAdmin', APPROVE_ONLY: 'approveOnly', }, + // Values returned by UpdateWorkspaceMembersRole in response.data.blockedReasons when it refuses the approve-only role. + APPROVE_ONLY_BLOCKED_REASONS: { + HAS_CARD_ON_POLICY: 'hasCardOnPolicy', + IS_RESTRICTED_BY_DOMAIN_GROUP: 'isRestrictedByDomainGroup', + IS_DEFAULT_POLICY: 'isDefaultPolicy', + }, WORKSPACE_STATUS: { ACTIVE: 'active', ARCHIVED: 'archived', diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index 4b2cbb579191..0788400a03fa 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -23,6 +23,7 @@ import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullsc import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {PersonalDetailsList} from '@src/types/onyx'; @@ -58,15 +59,13 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp }); const showApproveOnlyBlockedModal = (blockedReasons: string[]) => { - const reasonLines = blockedReasons.map((reason) => { - if (reason === 'hasCardOnPolicy') { - return `• ${translate('workspace.people.approveOnlyRoleBlockedReasons.hasCardOnPolicy')}`; - } - if (reason === 'isRestrictedByDomainGroup') { - return `• ${translate('workspace.people.approveOnlyRoleBlockedReasons.isRestrictedByDomainGroup')}`; - } - return `• ${translate('workspace.people.approveOnlyRoleBlockedReasons.isDefaultPolicy')}`; - }); + const reasonTranslationKeys: Record = { + [CONST.POLICY.APPROVE_ONLY_BLOCKED_REASONS.HAS_CARD_ON_POLICY]: 'workspace.people.approveOnlyRoleBlockedReasons.hasCardOnPolicy', + [CONST.POLICY.APPROVE_ONLY_BLOCKED_REASONS.IS_RESTRICTED_BY_DOMAIN_GROUP]: 'workspace.people.approveOnlyRoleBlockedReasons.isRestrictedByDomainGroup', + [CONST.POLICY.APPROVE_ONLY_BLOCKED_REASONS.IS_DEFAULT_POLICY]: 'workspace.people.approveOnlyRoleBlockedReasons.isDefaultPolicy', + }; + // Skip reasons outside the known set instead of showing wrong copy for a value the backend added later. + const reasonLines = blockedReasons.filter((reason) => reason in reasonTranslationKeys).map((reason) => `• ${translate(reasonTranslationKeys[reason])}`); showConfirmModal({ title: translate('workspace.people.approveOnlyRoleBlockedTitle'), prompt: [translate('workspace.people.approveOnlyRoleBlockedDescription'), '', ...reasonLines].join('\n'), @@ -75,6 +74,15 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp }); }; + const showRoleUpdateErrorModal = () => { + showConfirmModal({ + title: translate('workspace.people.approveOnlyRoleBlockedTitle'), + prompt: translate('common.genericErrorMessage'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + }; + const saveRole = () => { // The save button stays disabled until a different role is picked, so selectedRole is always set here. const value = selectedRole; @@ -93,14 +101,23 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp return; } if (value === CONST.POLICY.ROLE.APPROVE_ONLY) { - updateWorkspaceMembersRole(policy, [memberLogin], [accountID], value).then((response) => { - const blockedReasons = response?.data?.blockedReasons ?? []; - if (blockedReasons.length > 0) { - showApproveOnlyBlockedModal(blockedReasons); - return; - } - Navigation.goBack(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); - }); + updateWorkspaceMembersRole(policy, [memberLogin], [accountID], value) + .then((response) => { + const blockedReasons = response?.data?.blockedReasons ?? []; + if (blockedReasons.length > 0) { + showApproveOnlyBlockedModal(blockedReasons); + return; + } + // The action already set the failure on the member row, so only keep the user here to see the error modal. + if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { + showRoleUpdateErrorModal(); + return; + } + Navigation.goBack(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); + }) + .catch(() => { + showRoleUpdateErrorModal(); + }); return; } updateWorkspaceMembersRole(policy, [memberLogin], [accountID], value); From 95ec4ce6b28c42127bda9dc073895faaa0519f90 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:07:12 +0530 Subject: [PATCH 16/56] Allow promise-returning onSave in usePopoverEditState --- src/components/EditableCell/usePopoverEditState.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/EditableCell/usePopoverEditState.ts b/src/components/EditableCell/usePopoverEditState.ts index 1a3bc0424e6d..f32e8c7928ff 100644 --- a/src/components/EditableCell/usePopoverEditState.ts +++ b/src/components/EditableCell/usePopoverEditState.ts @@ -8,6 +8,8 @@ import type {ValueOf} from 'type-fest'; import {useEffect, useRef, useState} from 'react'; +import type {InlineEditSaveResult} from './types'; + type PopoverPosition = { horizontal: number; vertical: number; @@ -21,7 +23,7 @@ type UsePopoverEditStateOptions = { value?: unknown; /** Callback when the value is saved */ - onSave?: (value: unknown) => void; + onSave?: (value: unknown) => InlineEditSaveResult; /** Custom equality function. If not provided, Object.is is used. */ isEqual?: (newValue: unknown, originalValue: unknown) => boolean; @@ -116,7 +118,7 @@ function usePopoverEditStateImpl({ type UsePopoverEditStateOptionsGeneric = { canEdit: boolean | undefined; value?: T; - onSave?: (value: T) => void; + onSave?: (value: T) => InlineEditSaveResult; isEqual?: (newValue: T, originalValue: T) => boolean; popoverHeight?: number; padding?: number; @@ -137,6 +139,9 @@ type UsePopoverEditStateOptionsGeneric = { * - Value comparison to prevent no-op saves */ function usePopoverEditState(options: UsePopoverEditStateOptionsGeneric) { + // The non-generic impl cannot accept the generic options type directly, and OXC's React Compiler bails on + // type params inside hooks, so the options cannot be retyped at the impl boundary. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return usePopoverEditStateImpl(options as UsePopoverEditStateOptions) as ReturnType & { handleSave: (newValue: T) => void; }; From 1077fc37aa0e7d91cfe24681d17e8e51e314c10b Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:25:08 +0530 Subject: [PATCH 17/56] Add approve-only to bulk role actions and block add-expense without create permission --- src/CONST/index.ts | 1 + src/hooks/useApproveOnlyRoleBlockedModal.ts | 43 +++++++++++++++++++ src/languages/de.ts | 4 ++ src/languages/el.ts | 4 ++ src/languages/en.ts | 4 ++ src/languages/es.ts | 4 ++ src/languages/fr.ts | 4 ++ src/languages/it.ts | 4 ++ src/languages/ja.ts | 4 ++ src/languages/nl.ts | 4 ++ src/languages/pl.ts | 4 ++ src/languages/pt-BR.ts | 4 ++ src/languages/zh-hans.ts | 4 ++ src/libs/ReportUtils.ts | 5 +++ src/pages/workspace/WorkspaceMembersPage.tsx | 40 ++++++++++++++++- .../WorkspaceMemberDetailsRolePage.tsx | 29 +------------ 16 files changed, 134 insertions(+), 28 deletions(-) create mode 100644 src/hooks/useApproveOnlyRoleBlockedModal.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index d79b992f767d..6e277c646bd5 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -4746,6 +4746,7 @@ const CONST = { MAKE_CARD_ADMIN: 'makeCardAdmin', MAKE_PEOPLE_ADMIN: 'makePeopleAdmin', MAKE_PAYMENTS_ADMIN: 'makePaymentsAdmin', + MAKE_APPROVE_ONLY: 'makeApproveOnly', }, BULK_ACTION_TYPES: { DELETE: 'delete', diff --git a/src/hooks/useApproveOnlyRoleBlockedModal.ts b/src/hooks/useApproveOnlyRoleBlockedModal.ts new file mode 100644 index 000000000000..b0f338b47bc4 --- /dev/null +++ b/src/hooks/useApproveOnlyRoleBlockedModal.ts @@ -0,0 +1,43 @@ +/** + * Shared modals for approve-only role changes. The backend can refuse the role and list why in + * `response.data.blockedReasons`, so both the single-member page and bulk actions show the same copy. + */ +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; + +import useConfirmModal from './useConfirmModal'; +import useLocalize from './useLocalize'; + +function useApproveOnlyRoleBlockedModal() { + const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); + + const showApproveOnlyBlockedModal = (blockedReasons: string[]) => { + const reasonTranslationKeys: Record = { + [CONST.POLICY.APPROVE_ONLY_BLOCKED_REASONS.HAS_CARD_ON_POLICY]: 'workspace.people.approveOnlyRoleBlockedReasons.hasCardOnPolicy', + [CONST.POLICY.APPROVE_ONLY_BLOCKED_REASONS.IS_RESTRICTED_BY_DOMAIN_GROUP]: 'workspace.people.approveOnlyRoleBlockedReasons.isRestrictedByDomainGroup', + [CONST.POLICY.APPROVE_ONLY_BLOCKED_REASONS.IS_DEFAULT_POLICY]: 'workspace.people.approveOnlyRoleBlockedReasons.isDefaultPolicy', + }; + // Skip reasons outside the known set instead of showing wrong copy for a value the backend added later. + const reasonLines = blockedReasons.filter((reason) => reason in reasonTranslationKeys).map((reason) => `• ${translate(reasonTranslationKeys[reason])}`); + showConfirmModal({ + title: translate('workspace.people.approveOnlyRoleBlockedTitle'), + prompt: [translate('workspace.people.approveOnlyRoleBlockedDescription'), '', ...reasonLines].join('\n'), + confirmText: translate('workspace.people.approveOnlyRoleBlockedConfirm'), + shouldShowCancelButton: false, + }); + }; + + const showRoleUpdateErrorModal = () => { + showConfirmModal({ + title: translate('workspace.people.approveOnlyRoleBlockedTitle'), + prompt: translate('common.genericErrorMessage'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + }; + + return {showApproveOnlyBlockedModal, showRoleUpdateErrorModal}; +} + +export default useApproveOnlyRoleBlockedModal; diff --git a/src/languages/de.ts b/src/languages/de.ts index f948dc6b5a4f..b9d9dac0d502 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7219,6 +7219,10 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, cannotRemove: 'Du kannst dich selbst oder den/die Workspace-Inhaber:in nicht entfernen', genericRemove: 'Beim Entfernen dieses Workspace-Mitglieds ist ein Problem aufgetreten', }, + makeApproveOnly: () => ({ + one: 'Auf „Nur Genehmigung“ setzen', + other: 'Als „Nur Genehmigung“ festlegen', + }), addedWithPrimary: 'Einige Mitglieder wurden mit ihren primären Anmeldungen hinzugefügt.', invitedBySecondaryLogin: (secondaryLogin: string) => `Hinzugefügt durch sekundären Login ${secondaryLogin}.`, workspaceMembersCount: (count: number) => `Gesamtzahl der Workspace-Mitglieder: ${count}`, diff --git a/src/languages/el.ts b/src/languages/el.ts index 5361bbe96465..b721bca85b38 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -7343,6 +7343,10 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ cannotRemove: 'Δεν μπορείτε να αφαιρέσετε τον εαυτό σας ή τον ιδιοκτήτη του χώρου εργασίας', genericRemove: 'Προέκυψε πρόβλημα κατά την αφαίρεση αυτού του μέλους του χώρου εργασίας', }, + makeApproveOnly: () => ({ + one: 'Ορισμός ως μόνο έγκριση', + other: 'Ορισμός ως μόνο έγκριση', + }), addedWithPrimary: 'Ορισμένα μέλη προστέθηκαν με τους κύριους λογαριασμούς σύνδεσής τους.', invitedBySecondaryLogin: (secondaryLogin: string) => `Προστέθηκε από το δευτερεύον στοιχείο σύνδεσης ${secondaryLogin}.`, workspaceMembersCount: (count: number) => `Σύνολο μελών χώρου εργασίας: ${count}`, diff --git a/src/languages/en.ts b/src/languages/en.ts index eea5d1cc3456..b63097414db7 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7571,6 +7571,10 @@ const translations = { cannotRemove: "You can't remove yourself or the workspace owner", genericRemove: 'There was a problem removing that workspace member', }, + makeApproveOnly: () => ({ + one: 'Make approve only', + other: 'Make approve only members', + }), addedWithPrimary: 'Some members were added with their primary logins.', invitedBySecondaryLogin: (secondaryLogin: string) => `Added by secondary login ${secondaryLogin}.`, workspaceMembersCount: (count: number) => `Total workspace members: ${count}`, diff --git a/src/languages/es.ts b/src/languages/es.ts index a13ccaca6efd..a25f1643ff3d 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7080,6 +7080,10 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, cannotRemove: 'No puedes eliminarte ni a ti mismo ni al dueño del espacio de trabajo', genericRemove: 'Ha ocurrido un problema al eliminar al miembro del espacio de trabajo', }, + makeApproveOnly: () => ({ + one: 'Convertir en solo aprobación', + other: 'Convertir en miembros de solo aprobación', + }), addedWithPrimary: 'Se agregaron algunos miembros con sus nombres de usuario principales.', invitedBySecondaryLogin: (secondaryLogin) => `Agregado por nombre de usuario secundario ${secondaryLogin}.`, workspaceMembersCount: (count) => `Total de miembros del espacio de trabajo: ${count}`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 4285893a4f93..a23bff9e834b 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7236,6 +7236,10 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, cannotRemove: 'Vous ne pouvez pas vous supprimer vous-même ni supprimer le responsable de l’espace de travail', genericRemove: 'Un problème est survenu lors de la suppression de ce membre de l’espace de travail', }, + makeApproveOnly: () => ({ + one: 'Définir en approbation uniquement', + other: 'Définir en approbation uniquement', + }), addedWithPrimary: 'Certains membres ont été ajoutés avec leurs identifiants principaux.', invitedBySecondaryLogin: (secondaryLogin: string) => `Ajouté par la connexion secondaire ${secondaryLogin}.`, workspaceMembersCount: (count: number) => `Nombre total de membres de l'espace de travail : ${count}`, diff --git a/src/languages/it.ts b/src/languages/it.ts index 6206f1bcf772..25a73a4db01b 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7177,6 +7177,10 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, cannotRemove: 'Non puoi rimuovere te stessə o il proprietario dello spazio di lavoro', genericRemove: 'Si è verificato un problema durante la rimozione di quel membro dello spazio di lavoro', }, + makeApproveOnly: () => ({ + one: 'Imposta come solo approvazione', + other: 'Imposta come solo approvazione', + }), addedWithPrimary: 'Alcuni membri sono stati aggiunti con i loro accessi principali.', invitedBySecondaryLogin: (secondaryLogin: string) => `Aggiunto dall’accesso secondario ${secondaryLogin}.`, workspaceMembersCount: (count: number) => `Totale membri dello spazio di lavoro: ${count}`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 078dcfdcdda0..03fee37c88b3 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7088,6 +7088,10 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から cannotRemove: '自分自身またはワークスペースのオーナーを削除することはできません', genericRemove: 'そのワークスペースメンバーを削除する際に問題が発生しました', }, + makeApproveOnly: () => ({ + one: '承認のみに設定', + other: '承認のみに設定', + }), addedWithPrimary: '一部のメンバーは、プライマリーログインで追加されました。', invitedBySecondaryLogin: (secondaryLogin: string) => `セカンダリログイン${secondaryLogin}によって追加されました。`, workspaceMembersCount: (count: number) => `ワークスペースメンバー合計:${count}`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index d1fd2b4266fb..4bbf9c07dd2f 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7161,6 +7161,10 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, cannotRemove: 'Je kunt jezelf of de eigenaar van de werkruimte niet verwijderen', genericRemove: 'Er is een probleem opgetreden bij het verwijderen van dat werkpleklid', }, + makeApproveOnly: () => ({ + one: 'Alleen goedkeuren maken', + other: 'Alleen goedkeuren maken', + }), addedWithPrimary: 'Sommige leden zijn toegevoegd met hun primaire login.', invitedBySecondaryLogin: (secondaryLogin: string) => `Toegevoegd door secundair login ${secondaryLogin}.`, workspaceMembersCount: (count: number) => `Totaal aantal werkruimteleden: ${count}`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index cb6fbf34e9e3..2f73cfd6b219 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7186,6 +7186,10 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, cannotRemove: 'Nie możesz usunąć siebie ani właściciela przestrzeni roboczej', genericRemove: 'Wystąpił problem z usunięciem tego członka przestrzeni roboczej', }, + makeApproveOnly: () => ({ + one: 'Ustaw jako tylko zatwierdzanie', + other: 'Ustaw jako tylko zatwierdzanie', + }), addedWithPrimary: 'Niektóre osoby zostały dodane za pomocą swoich głównych loginów.', invitedBySecondaryLogin: (secondaryLogin: string) => `Dodane przez dodatkowy login ${secondaryLogin}.`, workspaceMembersCount: (count: number) => `Łączna liczba członków przestrzeni roboczej: ${count}`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index f5128645735e..c8bd2dea74e6 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7164,6 +7164,10 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, cannotRemove: 'Você não pode remover a si mesmo nem o proprietário do workspace', genericRemove: 'Houve um problema ao remover esse membro do workspace', }, + makeApproveOnly: () => ({ + one: 'Tornar somente de aprovação', + other: 'Tornar membros somente de aprovação', + }), addedWithPrimary: 'Alguns membros foram adicionados com seus logins principais.', invitedBySecondaryLogin: (secondaryLogin: string) => `Adicionado pelo login secundário ${secondaryLogin}.`, workspaceMembersCount: (count: number) => `Total de membros no workspace: ${count}`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 78b801f05e9c..b673e27085cc 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6918,6 +6918,10 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM cannotRemove: '你无法将自己或工作区所有者移除', genericRemove: '移除该工作区成员时出现问题', }, + makeApproveOnly: () => ({ + one: '设为仅审批', + other: '设为仅审批', + }), addedWithPrimary: '某些成员已通过其主登录名添加。', invitedBySecondaryLogin: (secondaryLogin: string) => `由次要登录账号 ${secondaryLogin} 添加。`, workspaceMembersCount: (count: number) => `工作区成员总数:${count}`, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index af8f89670b03..cceece55baed 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -3127,6 +3127,11 @@ function canAddTransaction(moneyRequestReport: OnyxEntry, rules: OnyxCol return false; } + // Members without create-expenses permission cannot add expenses to their reports, including tracked ones. + if (!!policy?.role && !canRoleCreateExpenses(policy.role)) { + return false; + } + if ( isInstantSubmitEnabled(policy) && isSubmitAndClose(policy) && diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index de849f37ed32..5007d6d8e833 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -12,6 +12,7 @@ import WorkspaceMembersTable from '@components/Tables/WorkspaceMembersTable'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; +import useApproveOnlyRoleBlockedModal from '@hooks/useApproveOnlyRoleBlockedModal'; import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLayoutSpacing from '@hooks/useLayoutSpacing'; @@ -119,7 +120,7 @@ function invertObject(object: Record): Record { function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembersPageProps) { useWorkspaceDocumentTitle(policy?.name, 'common.members'); const tableRef = useRef>(null); - const icons = useMemoizedLazyExpensifyIcons(['Download', 'FallbackAvatar', 'MakeAdmin', 'Plus', 'RemoveMembers', 'Sync', 'Table', 'User', 'UserEye']); + const icons = useMemoizedLazyExpensifyIcons(['Download', 'FallbackAvatar', 'MakeAdmin', 'Plus', 'RemoveMembers', 'Sync', 'Table', 'User', 'UserEye', 'Checkmark']); const employeePersonalDetails = usePersonalDetailsByLogins(Object.keys(policy?.employeeList ?? {})); const policyMemberEmailsToAccountIDs = useMemo( () => getMemberAccountIDsForWorkspace(policy?.employeeList, employeePersonalDetails, true), @@ -141,6 +142,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const [isDownloadFailureModalVisible, setIsDownloadFailureModalVisible] = useState(false); const isOfflineAndNoMemberDataAvailable = isEmptyObject(policy?.employeeList) && isOffline; const {translate, formatPhoneNumber, localeCompare} = useLocalize(); + const {showApproveOnlyBlockedModal, showRoleUpdateErrorModal} = useApproveOnlyRoleBlockedModal(); const {isAccountLocked} = useLockedAccountState(); const {showLockedAccountModal} = useLockedAccountActions(); const [selectedEmployees, setSelectedEmployees] = useState([]); @@ -603,6 +605,24 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const accountIDsToUpdate = loginsToUpdate.map((login) => policyMemberEmailsToAccountIDs[login]).filter((id) => id !== undefined); setSelectedEmployees([]); + if (role === CONST.POLICY.ROLE.APPROVE_ONLY) { + updateWorkspaceMembersRole(policy, loginsToUpdate, accountIDsToUpdate, role) + .then((response) => { + const blockedReasons = response?.data?.blockedReasons ?? []; + if (blockedReasons.length > 0) { + showApproveOnlyBlockedModal(blockedReasons); + return; + } + // The action already reverted the optimistic role changes on failure, so only surface the error modal here. + if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { + showRoleUpdateErrorModal(); + } + }) + .catch(() => { + showRoleUpdateErrorModal(); + }); + return; + } updateWorkspaceMembersRole(policy, loginsToUpdate, accountIDsToUpdate, role); }; @@ -655,6 +675,13 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers shouldSkipFocusRestore: hasSelectedRuleBot, onSelected: () => changeUserRole(CONST.POLICY.ROLE.GUEST), }; + const approveOnlyOption = { + text: translate('workspace.people.makeApproveOnly', {count: selectedEmployees.length}), + value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_APPROVE_ONLY, + icon: icons.Checkmark, + shouldSkipFocusRestore: hasSelectedRuleBot, + onSelected: () => changeUserRole(CONST.POLICY.ROLE.APPROVE_ONLY), + }; const cardAdminOption = { text: translate('workspace.people.makeCardAdmin', {count: selectedEmployees.length}), value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_CARD_ADMIN, @@ -679,6 +706,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const hasAtLeastOneNonAuditorRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.AUDITOR); const hasAtLeastOneNonGuestRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.GUEST); + const hasAtLeastOneNonApproveOnlyRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.APPROVE_ONLY); const hasAtLeastOneNonCardAdminRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.CARD_ADMIN); const hasAtLeastOneNonPeopleAdminRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.PEOPLE_ADMIN); const hasAtLeastOneNonPaymentsAdminRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.PAYMENTS_ADMIN); @@ -716,6 +744,16 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers options.push(guestOption); } + if ( + hasAtLeastOneNonApproveOnlyRole && + isControlPolicy(policy) && + !hasAtLeastOnePayer && + canManageSelectedEmployees && + canMemberAssignRole(policy, currentUserLogin ?? '', CONST.POLICY.ROLE.APPROVE_ONLY) + ) { + options.push(approveOnlyOption); + } + if (hasAtLeastOneNonCardAdminRole && isControlPolicy(policy) && !hasAtLeastOnePayer && canAssignElevatedRoles) { options.push(cardAdminOption); } diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index 0788400a03fa..841cd808cdce 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -4,7 +4,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import WorkspaceMemberRoleList from '@components/WorkspaceMemberRoleList'; import type {ListItemType} from '@components/WorkspaceMemberRoleList'; -import useConfirmModal from '@hooks/useConfirmModal'; +import useApproveOnlyRoleBlockedModal from '@hooks/useApproveOnlyRoleBlockedModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useRedirectSubmitWorkspaceFeatureUpgrade from '@hooks/useRedirectSubmitWorkspaceFeatureUpgrade'; @@ -23,7 +23,6 @@ import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullsc import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {PersonalDetailsList} from '@src/types/onyx'; @@ -43,7 +42,6 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp const policyID = route.params.policyID; const showRuleBotGuardModal = useRuleBotGuardModal(); const {translate} = useLocalize(); - const {showConfirmModal} = useConfirmModal(); const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); const memberLogin = personalDetails?.[accountID]?.login ?? ''; const member = policy?.employeeList?.[memberLogin]; @@ -58,30 +56,7 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp upgradeFeatureAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.roles.alias, }); - const showApproveOnlyBlockedModal = (blockedReasons: string[]) => { - const reasonTranslationKeys: Record = { - [CONST.POLICY.APPROVE_ONLY_BLOCKED_REASONS.HAS_CARD_ON_POLICY]: 'workspace.people.approveOnlyRoleBlockedReasons.hasCardOnPolicy', - [CONST.POLICY.APPROVE_ONLY_BLOCKED_REASONS.IS_RESTRICTED_BY_DOMAIN_GROUP]: 'workspace.people.approveOnlyRoleBlockedReasons.isRestrictedByDomainGroup', - [CONST.POLICY.APPROVE_ONLY_BLOCKED_REASONS.IS_DEFAULT_POLICY]: 'workspace.people.approveOnlyRoleBlockedReasons.isDefaultPolicy', - }; - // Skip reasons outside the known set instead of showing wrong copy for a value the backend added later. - const reasonLines = blockedReasons.filter((reason) => reason in reasonTranslationKeys).map((reason) => `• ${translate(reasonTranslationKeys[reason])}`); - showConfirmModal({ - title: translate('workspace.people.approveOnlyRoleBlockedTitle'), - prompt: [translate('workspace.people.approveOnlyRoleBlockedDescription'), '', ...reasonLines].join('\n'), - confirmText: translate('workspace.people.approveOnlyRoleBlockedConfirm'), - shouldShowCancelButton: false, - }); - }; - - const showRoleUpdateErrorModal = () => { - showConfirmModal({ - title: translate('workspace.people.approveOnlyRoleBlockedTitle'), - prompt: translate('common.genericErrorMessage'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); - }; + const {showApproveOnlyBlockedModal, showRoleUpdateErrorModal} = useApproveOnlyRoleBlockedModal(); const saveRole = () => { // The save button stays disabled until a different role is picked, so selectedRole is always set here. From 91753244b95260bb863e980d61033c068207749b Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:40:48 +0530 Subject: [PATCH 18/56] Gate add-expense entry points on create-expenses permission --- .../MoneyRequestReportListFooter.tsx | 4 ++-- .../SearchMoneyRequestReportEmptyState.tsx | 3 ++- .../MoneyRequestReportPreview/AddExpenseActionButton.tsx | 6 ++++++ src/libs/PolicyUtils/permissions.ts | 9 +++++++++ src/libs/ReportSecondaryActionUtils.ts | 3 ++- src/libs/ReportUtils.ts | 5 ----- 6 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportListFooter.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportListFooter.tsx index e0fd15a511c0..28188ecbe4f0 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportListFooter.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportListFooter.tsx @@ -14,7 +14,7 @@ import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; import useThemeStyles from '@hooks/useThemeStyles'; import {getDistanceExpenseTypeForPolicy} from '@libs/PolicyDistanceRatesUtils'; -import {isPolicyTaxEnabled} from '@libs/PolicyUtils'; +import {canCreateExpensesOnPolicy, isPolicyTaxEnabled} from '@libs/PolicyUtils'; import {canAddTransaction, getBillableAndTaxTotal, getMoneyRequestSpendBreakdown, getReportOfflinePendingActionAndErrors, isCurrentUserSubmitter} from '@libs/ReportUtils'; import {hasNonReimbursableTransactions} from '@libs/TransactionUtils'; @@ -86,7 +86,7 @@ function MoneyRequestReportListFooter({report, policy, transactions, hasPendingA const formattedTaxAmount = convertToDisplayString(taxTotal, report?.currency); const shouldShowExpenseReportBreakDown = hasNonReimbursableTransactions(transactions); const shouldShowBreakdown = shouldShowExpenseReportBreakDown || !!billableTotal || (!!taxTotal && isTaxEnabled); - const shouldShowAddExpenseButton = canAddTransaction(report, rules, isReportArchived) && isCurrentUserSubmitter(report); + const shouldShowAddExpenseButton = canCreateExpensesOnPolicy(policy) && canAddTransaction(report, rules, isReportArchived) && isCurrentUserSubmitter(report); const breakdownRows: Array<{text: TranslationPaths; value: string; shouldShow: boolean}> = [ {text: 'cardTransactions.outOfPocket', value: formattedOutOfPocketAmount, shouldShow: !!nonReimbursableSpend}, diff --git a/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx b/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx index b8e51f2d52df..50a683db990e 100644 --- a/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx +++ b/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx @@ -8,6 +8,7 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {getDistanceExpenseTypeForPolicy} from '@libs/PolicyDistanceRatesUtils'; +import {canCreateExpensesOnPolicy} from '@libs/PolicyUtils'; import {canAddTransaction, isArchivedReport, isTeachersUniteReport} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import {cancelSpan} from '@libs/telemetry/activeSpans'; @@ -47,7 +48,7 @@ function SearchMoneyRequestReportEmptyState({report, policy, onLayout}: {report: const reportId = report.reportID; const isReportArchived = isArchivedReport(reportNameValuePairs); const icons = useMemoizedLazyExpensifyIcons(['ReceiptPlus']); - const canAddTransactionToReport = canAddTransaction(report, rules, isReportArchived); + const canAddTransactionToReport = canCreateExpensesOnPolicy(policy) && canAddTransaction(report, rules, isReportArchived); const blockDistanceRequestIfNeeded = useBlockDistanceRequest({ policyID: policy?.id, isDistanceRequest: true, diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/AddExpenseActionButton.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/AddExpenseActionButton.tsx index fdb0981e9f65..0a3272f6cef2 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/AddExpenseActionButton.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/AddExpenseActionButton.tsx @@ -7,6 +7,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {getDistanceExpenseTypeForPolicy} from '@libs/PolicyDistanceRatesUtils'; +import {canCreateExpensesOnPolicy} from '@libs/PolicyUtils'; import {getAddExpenseDropdownOptions} from '@userActions/IOU/StartExpenseFlows'; @@ -35,6 +36,11 @@ function AddExpenseActionButton() { isDistanceRequest: true, }); + // Members without create-expenses permission get no add-expense entry point on the report preview. + if (!canCreateExpensesOnPolicy(policy)) { + return null; + } + return ( >): boolean { + return !policy?.role || canRoleCreateExpenses(policy.role); +} + /** Check if the passed employee is an approver in the policy's employeeList */ function isPolicyApprover(policy: OnyxInputOrEntry, employeeLogin: string) { if (policy?.approver === employeeLogin) { @@ -327,6 +335,7 @@ export { getPolicyRole, canRolePay, canRoleCreateExpenses, + canCreateExpensesOnPolicy, PAYER_ROLES, isPolicyApprover, getPolicyApproverLogins, diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index 8bc716e0f830..47cdbb47b66d 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -29,6 +29,7 @@ import { getValidConnectedIntegration, hasDynamicExternalWorkflow, isArchivedOrPendingDeletePolicy, + canCreateExpensesOnPolicy, isGroupPolicy, isInstantSubmitEnabled, isPolicyAdmin, @@ -1066,7 +1067,7 @@ function getSecondaryReportActions({ options.push(CONST.REPORT.SECONDARY_ACTIONS.PAY); } - if (isAddExpenseAction(report, reportTransactions, rules, isChatReportArchived || isArchivedReport(reportNameValuePairs))) { + if (isAddExpenseAction(report, reportTransactions, rules, isChatReportArchived || isArchivedReport(reportNameValuePairs)) && canCreateExpensesOnPolicy(policy)) { options.push(CONST.REPORT.SECONDARY_ACTIONS.ADD_EXPENSE); } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index cceece55baed..af8f89670b03 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -3127,11 +3127,6 @@ function canAddTransaction(moneyRequestReport: OnyxEntry, rules: OnyxCol return false; } - // Members without create-expenses permission cannot add expenses to their reports, including tracked ones. - if (!!policy?.role && !canRoleCreateExpenses(policy.role)) { - return false; - } - if ( isInstantSubmitEnabled(policy) && isSubmitAndClose(policy) && From be58a72fdd70421cfcc8562f309023a3e16d9120 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:49:01 +0530 Subject: [PATCH 19/56] Fix create-expenses gates resolving random test roles --- src/libs/PolicyUtils/index.ts | 4 ++-- src/libs/PolicyUtils/permissions.ts | 5 +---- src/libs/ReportUtils.ts | 4 ++-- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/libs/PolicyUtils/index.ts b/src/libs/PolicyUtils/index.ts index 16d5c49ceb26..f90f8efeebec 100644 --- a/src/libs/PolicyUtils/index.ts +++ b/src/libs/PolicyUtils/index.ts @@ -142,8 +142,8 @@ function getActivePoliciesWithExpenseChat(policies: OnyxCollection | nul !!policy.name && !!policy.id && !!getPolicyRole(policy, currentUserLogin) && - // Workspaces where the member lacks create-expenses permission are not expense destinations. - canRoleCreateExpenses(getPolicyRole(policy, currentUserLogin)) && + // No login means no member to resolve, so the create-expenses gate only applies per member. + (!currentUserLogin || canRoleCreateExpenses(getPolicyRole(policy, currentUserLogin))) && (isPaidGroupPolicy(policy) || isSubmitPolicy(policy)) && !isArchivedPolicy(policy), ); diff --git a/src/libs/PolicyUtils/permissions.ts b/src/libs/PolicyUtils/permissions.ts index 34e4fef123f6..5c900439239f 100644 --- a/src/libs/PolicyUtils/permissions.ts +++ b/src/libs/PolicyUtils/permissions.ts @@ -240,10 +240,7 @@ function canRoleCreateExpenses(role: string | undefined): boolean { return !!role && ROLE_PERMISSION_BUNDLES[role]?.[CONST.POLICY.POLICY_FEATURE.CREATE_EXPENSES] === CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE; } -/** - * Whether the viewer role stored on the policy allows creating expenses. A policy summary without a loaded - * role does not gate here, so partially loaded data cannot hide the entry points from members who may create. - */ +/** Whether the policy's viewer role allows creating expenses. A missing role does not gate. */ function canCreateExpensesOnPolicy(policy: OnyxInputOrEntry>): boolean { return !policy?.role || canRoleCreateExpenses(policy.role); } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index af8f89670b03..e24b00cada2f 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -14071,8 +14071,8 @@ function isWorkspaceEligibleForReportChange(submitterEmail: string | undefined, if (report?.stateNum === CONST.REPORT.STATE_NUM.APPROVED && report.statusNum === CONST.REPORT.STATUS_NUM.CLOSED && !isPolicyAdminPolicyUtils(newPolicy)) { return false; } - // Moving a report to a workspace creates expenses there, which requires create-expenses permission. - if (!canRoleCreateExpenses(getPolicyRole(newPolicy, submitterEmail))) { + // Moving a report creates expenses there, so the submitter must hold create-expenses permission. + if (!canRoleCreateExpenses(getPolicyRole(newPolicy, submitterEmail, false) ?? newPolicy.role)) { return false; } return !!newPolicy.role && !isPendingDeletePolicy(newPolicy); From 91f6a9fe4ee78b7d373e5d42ebe8f7ca4f050452 Mon Sep 17 00:00:00 2001 From: Shridhar Goel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:02:52 +0530 Subject: [PATCH 20/56] Update usePopoverEditState.ts --- src/components/EditableCell/usePopoverEditState.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/components/EditableCell/usePopoverEditState.ts b/src/components/EditableCell/usePopoverEditState.ts index f32e8c7928ff..3d5d5e433703 100644 --- a/src/components/EditableCell/usePopoverEditState.ts +++ b/src/components/EditableCell/usePopoverEditState.ts @@ -139,9 +139,6 @@ type UsePopoverEditStateOptionsGeneric = { * - Value comparison to prevent no-op saves */ function usePopoverEditState(options: UsePopoverEditStateOptionsGeneric) { - // The non-generic impl cannot accept the generic options type directly, and OXC's React Compiler bails on - // type params inside hooks, so the options cannot be retyped at the impl boundary. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return usePopoverEditStateImpl(options as UsePopoverEditStateOptions) as ReturnType & { handleSave: (newValue: T) => void; }; From 5286f297d9ce1a895a574a64ceed910c52e54db9 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:07:22 +0530 Subject: [PATCH 21/56] Shorten approve-only request comments --- .claude/skills/app-coding-standards/SKILL.md | 1 - .../rules/consistency-20-function-names.md | 48 - .github/workflows/nativeDependencies.yml | 52 - Mobile-Expensify | 2 +- android/app/dependency-usage.json | 59 - config/eslint/eslint.seatbelt.tsv | 4 +- config/rsbuild/rsbuild.common.ts | 6 + cspell.json | 11 - .../netsuite/Connect-To-NetSuite.md | 32 +- .../certinia/Configure-Certinia.md | 6 +- .../Manage-Vendor-Matching-for-Certinia.md | 105 - .../netsuite/Connect-To-NetSuite.md | 247 +- .../Configure-Quickbooks-Online.md | 6 +- ...e-Vendor-Matching-for-QuickBooks-Online.md | 2 +- ...Set-Up-and-Manage-the-Expensify-Card-US.md | 3 +- .../reports-and-expenses/Distance-Expenses.md | 3 +- .../Using-Reports-in-New-Expensify.md | 2 +- .../Configure-Per-Diem-in-a-workspace.md | 1 - .../workspaces/Managing-Workspace-Members.md | 2 - .../workspaces/Set-distance-rates.md | 2 - ios/pod-usage.json | 28 - knip.json | 6 + package-lock.json | 576 ++- package.json | 3 + patches/link/details.md | 14 + patches/link/link+2.1.1+001+hot-load.patch | 4513 +++++++++++++++++ scripts/checkUnusedGradleDependencies.ts | 179 - scripts/checkUnusedPods.ts | 175 - scripts/generateGradleDependencyUsage.ts | 108 - scripts/generatePodUsage.ts | 109 - scripts/gradleUsageShared.ts | 302 -- scripts/nativeSourceComments.ts | 224 - scripts/nativeUsageManifest.ts | 62 - scripts/podUsageShared.ts | 158 - src/CONST/index.ts | 49 +- src/Expensify.tsx | 2 - src/ONYXKEYS.ts | 8 - src/ROUTES.ts | 16 +- src/components/ApprovalWorkflowSection.tsx | 20 +- src/components/InitialURLContextProvider.tsx | 31 +- .../sections/CategoryField.tsx | 104 +- .../sections/CategoryFieldDropdown.tsx | 5 +- .../sections/ExpenseFieldDropdown.tsx | 28 +- .../hooks/useReceiptThumbnailSource.ts | 13 +- .../ReportActionItem/MoneyRequestView.tsx | 8 +- src/components/ReportOrNotFoundGuard.tsx | 140 - .../SignInButtons/AppleSignIn/index.tsx | 16 +- .../SignInButtons/GoogleSignIn/index.tsx | 12 +- src/components/Table/Table.tsx | 320 +- src/components/Table/TableContext.tsx | 13 +- src/components/Table/TableHeader.tsx | 23 +- src/components/Table/TableRow.tsx | 7 - .../Table/TableSemanticContainer.tsx | 47 +- src/components/Table/buildTableListData.ts | 10 +- .../Table/calculateDynamicColumnWidths.ts | 15 - .../ColumnResizeHandle/index.native.tsx | 9 - .../columnResize/ColumnResizeHandle/index.tsx | 47 - .../ColumnResizeScope/index.native.tsx | 8 - .../columnResize/ColumnResizeScope/index.tsx | 26 - .../columnResize/applyColumnWidthOverrides.ts | 70 - .../columnResize/columnWidthExpressions.ts | 54 - .../columnResize/getDraggedColumnWidth.ts | 9 - .../getMeasureWidthRef/index.native.ts | 7 - .../columnResize/getMeasureWidthRef/index.ts | 16 - .../columnResize/getResizableColumnLayout.ts | 119 - src/components/Table/columnResize/types.ts | 13 - .../useColumnResize/index.native.ts | 9 - .../columnResize/useColumnResize/index.ts | 166 - .../columnResize/useColumnResize/types.ts | 30 - .../useColumnResize/useLiveColumnWidths.ts | 71 - .../useColumnResize/useResizeIndicator.ts | 58 - .../useStoredColumnWidths/index.native.ts | 9 - .../useStoredColumnWidths/index.ts | 13 - src/components/Table/types.ts | 6 - .../Table/useDynamicColumnWidths.ts | 91 +- .../WorkspaceCompanyCardsTable/index.tsx | 1 - .../WorkspaceExpensifyCardsTable/index.tsx | 2 - .../WorkspaceRowBrickRoadIndicator.tsx | 4 +- .../Tables/WorkspaceMembersTable/index.tsx | 1 - .../Tables/WorkspaceTaxesTable/index.tsx | 3 - src/components/Text.tsx | 6 +- src/hooks/useExpenseActions.ts | 2 +- ...asApprovalWorkflowWithNonMemberApprover.ts | 34 - src/hooks/useLocalReceiptThumbnail.ts | 7 +- src/hooks/usePolicyIndicatorChecks.ts | 20 +- src/hooks/useReceiptUpgradeCount.ts | 16 - src/hooks/useReportActionsScroll.ts | 6 +- src/hooks/useSaveMarketingAttribution.ts | 32 - src/hooks/useSearchBulkActions.ts | 9 +- src/languages/de.ts | 29 +- src/languages/el.ts | 23 +- src/languages/en.ts | 21 +- src/languages/es.ts | 28 +- src/languages/fr.ts | 29 +- src/languages/it.ts | 28 +- src/languages/ja.ts | 27 +- src/languages/nl.ts | 28 +- src/languages/pl.ts | 27 +- src/languages/pt-BR.ts | 28 +- src/languages/zh-hans.ts | 25 +- src/libs/API/index.ts | 4 +- .../API/parameters/BeginAppleSignInParams.ts | 3 +- .../API/parameters/BeginGoogleSignInParams.ts | 3 +- .../SignInWithShortLivedAuthTokenParams.ts | 5 - src/libs/API/parameters/SignUpUserParams.ts | 3 +- src/libs/API/types.ts | 4 +- src/libs/CardUtils.ts | 17 +- src/libs/ExportOnyxState/masking.ts | 2 - src/libs/Middleware/SaveResponseInOnyx.ts | 2 +- .../AppNavigator/AuthScreensInitHandler.tsx | 6 +- src/libs/Navigation/types.ts | 1 - src/libs/OptionsListUtils/index.ts | 27 +- src/libs/PolicyUtils/index.ts | 9 - src/libs/PolicyUtils/permissions.ts | 21 +- src/libs/ReceiptStorage/index.native.ts | 287 +- src/libs/ReceiptStorage/index.ts | 6 - src/libs/ReceiptStorage/receiptUpgrades.ts | 83 - src/libs/ReceiptStorage/types.ts | 21 - src/libs/ReportActionsUtils.ts | 29 +- src/libs/ReportUtils.ts | 32 +- src/libs/SessionUtils.ts | 57 +- src/libs/TransactionUtils/index.ts | 24 +- src/libs/WorkflowUtils.ts | 178 +- src/libs/actions/IOU/PayMoneyRequest.ts | 9 +- src/libs/actions/IOU/Receipt.ts | 20 +- src/libs/actions/MarketingAttribution.ts | 73 - src/libs/actions/Policy/Member.ts | 6 +- src/libs/actions/Report/index.ts | 62 +- src/libs/actions/Session/index.ts | 69 +- src/libs/actions/TableColumnWidths.ts | 11 - src/libs/actions/Workflow.ts | 12 +- src/libs/actions/connections/merge/index.ts | 4 - .../validateReceiptFile/index.native.ts | 11 +- src/libs/getImageRecyclingKey.ts | 10 +- src/libs/merge/HRUtils.ts | 51 +- src/libs/merge/MergeUtils.ts | 6 +- .../prepareRequestPayload/index.native.ts | 24 +- src/libs/sanitizeLogParams.ts | 1 - .../ReportDetailsContent.tsx | 115 - .../ReportDetailsMenuItems.tsx | 193 +- .../ReportDetailsMenuTrackExpenseItems.tsx | 204 - src/pages/DynamicReportDetailsPage/index.tsx | 111 +- .../LogInWithShortLivedAuthTokenPage.tsx | 6 +- src/pages/LogOutPreviousUserPage.tsx | 142 +- .../BaseOnboardingPurpose.tsx | 5 +- src/pages/SetDefaultWorkspacePage.tsx | 9 +- .../ReportActionCompose/ComposerProvider.tsx | 6 +- .../ComposerWithSuggestions.tsx | 6 +- .../useEditComposerToggle.ts | 20 +- src/pages/inbox/report/ReportActionItem.tsx | 24 +- .../actionContents/ActionContentRouter.tsx | 4 +- .../actionContents/ChatMessageContent.tsx | 6 +- .../inbox/report/useReportDeepLinkOnOpen.ts | 49 - .../inbox/report/useShouldEditInComposer.ts | 14 - ...useShouldShowComposerForActiveEditDraft.ts | 7 +- .../inbox/report/withReportOrNotFound.tsx | 82 +- .../step/DynamicIOURequestStepUpgrade.tsx | 6 +- .../components/Camera/index.native.tsx | 36 +- .../components/Camera/types.ts | 3 - .../components/CameraViewport.tsx | 14 +- .../NavigationAwareCamera/Camera.tsx | 7 +- .../components/NavigationAwareCamera/types.ts | 1 - .../components/ScanEditReceipt.tsx | 1 - .../components/ScanSkipConfirmation.tsx | 1 - .../hooks/usePhotoUpgrade.ts | 316 -- .../utils/rotatePhotoToUpright.ts | 71 - .../signin/SAMLSignInPage/index.native.tsx | 7 +- src/pages/signin/SignUpWelcomeForm.tsx | 3 +- src/pages/workspace/WorkspaceInitialPage.tsx | 3 - src/pages/workspace/WorkspaceMembersPage.tsx | 16 +- .../companyCards/addNew/CardTypeStep.tsx | 63 +- .../addNew/DynamicAddNewCardPage.tsx | 8 +- .../WorkspaceDuplicateSelectFeaturesForm.tsx | 59 +- src/pages/workspace/getWorkspaceMenuItems.ts | 5 +- .../workspace/hr/merge/MergeHRGroupsPage.tsx | 101 +- src/pages/workspace/hr/utils.ts | 9 +- .../members/WorkspaceMemberDetailsPage.tsx | 11 +- .../workspace/merge/MergeProviderCard.tsx | 23 +- src/pages/workspace/merge/types.ts | 6 - .../approvals/ApprovalWorkflowEditor.tsx | 10 +- ...orkspaceWorkflowsApprovalsApproverPage.tsx | 36 +- src/selectors/Session.ts | 5 +- src/selectors/TableColumnWidths.ts | 9 - src/setup/index.ts | 3 - src/styles/index.ts | 4 - src/styles/utils/cursor/index.native.ts | 1 - src/styles/utils/cursor/index.ts | 4 - src/styles/utils/cursor/types.ts | 1 - src/styles/utils/index.ts | 16 +- src/types/onyx/ApprovalWorkflow.ts | 12 - src/types/onyx/MarketingAttribution.ts | 46 - src/types/onyx/Policy.ts | 6 - src/types/onyx/TableColumnWidths.ts | 8 - src/types/onyx/index.ts | 6 - tests/actions/IOUTest/PayMoneyRequestTest.ts | 58 - tests/actions/MarketingAttributionTest.ts | 266 - tests/actions/PolicyTest.ts | 55 - tests/actions/ReportTest.ts | 109 - tests/actions/SessionTest.ts | 140 +- tests/actions/WorkflowTest.ts | 152 - .../DynamicReportDetailsPage.perf-test.tsx | 6 + tests/ui/AuthScreensInitHandlerTest.tsx | 18 +- tests/ui/DynamicIOURequestStepUpgradeTest.tsx | 161 - tests/ui/DynamicReportDetailsPageTest.tsx | 25 + .../ui/IOURequestRedirectToStartPageTest.tsx | 1 - .../LogInWithShortLivedAuthTokenPageTest.tsx | 12 +- tests/ui/MoneyRequestViewTest.tsx | 29 - tests/ui/OnboardingPurpose.tsx | 25 - .../ui/ReportActionMessageEditLayoutTest.tsx | 32 - tests/ui/SAMLSignInPageTest.tsx | 6 +- tests/ui/SessionTest.tsx | 115 +- tests/ui/WorkspaceMemberDetailsPageTest.tsx | 38 +- tests/ui/WorkspaceMembersTest.tsx | 42 +- ...paceWorkflowsApprovalsApproverPageTest.tsx | 231 - ...orkspaceWorkflowsApprovalsEditPageTest.tsx | 28 +- tests/unit/ApprovalWorkflowSectionTest.tsx | 58 - tests/unit/CardUtilsTest.ts | 24 - tests/unit/ClearReportActionErrorsTest.ts | 37 - tests/unit/ContextMenuActionsDeleteTest.ts | 105 - tests/unit/HrUtilsTest.ts | 285 +- tests/unit/MergeActionTest.ts | 75 +- tests/unit/MiddlewareTest.ts | 4 +- tests/unit/OptionsListUtilsTest.tsx | 176 +- tests/unit/PolicyUtilsTest.ts | 40 - tests/unit/ReportActionsUtilsTest.ts | 151 - tests/unit/ReportUtilsTest.ts | 161 +- tests/unit/SearchEditMultipleTagPageTest.tsx | 3 - tests/unit/SessionUtilsTest.ts | 49 +- tests/unit/SignUpWelcomeFormTest.tsx | 26 +- tests/unit/TransactionUtilsTest.ts | 35 - tests/unit/WorkflowUtilsTest.ts | 476 +- ...rkspaceDuplicateSelectFeaturesFormTest.tsx | 209 - tests/unit/applyColumnWidthOverridesTest.ts | 66 - .../unit/calculateDynamicColumnWidthsTest.ts | 21 +- tests/unit/columnWidthExpressionsTest.ts | 17 - .../components/ExpenseFieldDropdown.test.tsx | 49 +- .../CategoryField.test.tsx | 283 +- .../components/NavigationAwareCameraTest.tsx | 61 - .../Table/TableSemanticContainerTest.tsx | 34 - .../Table/buildTableListDataTest.ts | 72 - .../components/Table/useColumnResizeTest.ts | 285 -- .../DynamicReportDetailsPageTest.tsx | 21 + .../components/withReportOrNotFound.test.tsx | 257 - tests/unit/getImageRecyclingKeyTest.ts | 20 - tests/unit/getResizableColumnLayoutTest.ts | 104 - tests/unit/getWorkspaceMenuItemsTest.ts | 19 - .../unit/hooks/useEditComposerToggle.test.ts | 29 - .../hooks/useLocalReceiptThumbnail.test.ts | 83 - tests/unit/hooks/usePhotoUpgrade.test.ts | 625 --- .../unit/hooks/useReceiptUpgradeCount.test.ts | 79 - tests/unit/hooks/useSearchBulkActionsTest.ts | 34 - .../libs/prepareRequestPayloadNativeTest.ts | 81 +- tests/unit/libs/receiptReadDuringSwapTest.ts | 275 - tests/unit/libs/receiptStorageTest.ts | 832 --- tests/unit/libs/receiptUpgradesTest.ts | 110 - tests/unit/rotatePhotoToUprightTest.ts | 50 - tests/unit/selectors/TableColumnWidthsTest.ts | 25 - tests/unit/usePolicyIndicatorChecksTest.ts | 35 - tests/unit/useReportActionsScrollTest.tsx | 1 - 259 files changed, 6254 insertions(+), 13115 deletions(-) delete mode 100644 .claude/skills/app-coding-standards/rules/consistency-20-function-names.md delete mode 100644 .github/workflows/nativeDependencies.yml delete mode 100644 android/app/dependency-usage.json delete mode 100644 docs/articles/new-expensify/connections/certinia/Manage-Vendor-Matching-for-Certinia.md delete mode 100644 ios/pod-usage.json create mode 100644 patches/link/details.md create mode 100644 patches/link/link+2.1.1+001+hot-load.patch delete mode 100644 scripts/checkUnusedGradleDependencies.ts delete mode 100644 scripts/checkUnusedPods.ts delete mode 100644 scripts/generateGradleDependencyUsage.ts delete mode 100644 scripts/generatePodUsage.ts delete mode 100644 scripts/gradleUsageShared.ts delete mode 100644 scripts/nativeSourceComments.ts delete mode 100644 scripts/nativeUsageManifest.ts delete mode 100644 scripts/podUsageShared.ts delete mode 100644 src/components/ReportOrNotFoundGuard.tsx delete mode 100644 src/components/Table/columnResize/ColumnResizeHandle/index.native.tsx delete mode 100644 src/components/Table/columnResize/ColumnResizeHandle/index.tsx delete mode 100644 src/components/Table/columnResize/ColumnResizeScope/index.native.tsx delete mode 100644 src/components/Table/columnResize/ColumnResizeScope/index.tsx delete mode 100644 src/components/Table/columnResize/applyColumnWidthOverrides.ts delete mode 100644 src/components/Table/columnResize/columnWidthExpressions.ts delete mode 100644 src/components/Table/columnResize/getDraggedColumnWidth.ts delete mode 100644 src/components/Table/columnResize/getMeasureWidthRef/index.native.ts delete mode 100644 src/components/Table/columnResize/getMeasureWidthRef/index.ts delete mode 100644 src/components/Table/columnResize/getResizableColumnLayout.ts delete mode 100644 src/components/Table/columnResize/types.ts delete mode 100644 src/components/Table/columnResize/useColumnResize/index.native.ts delete mode 100644 src/components/Table/columnResize/useColumnResize/index.ts delete mode 100644 src/components/Table/columnResize/useColumnResize/types.ts delete mode 100644 src/components/Table/columnResize/useColumnResize/useLiveColumnWidths.ts delete mode 100644 src/components/Table/columnResize/useColumnResize/useResizeIndicator.ts delete mode 100644 src/components/Table/columnResize/useStoredColumnWidths/index.native.ts delete mode 100644 src/components/Table/columnResize/useStoredColumnWidths/index.ts delete mode 100644 src/hooks/useHasApprovalWorkflowWithNonMemberApprover.ts delete mode 100644 src/hooks/useReceiptUpgradeCount.ts delete mode 100644 src/hooks/useSaveMarketingAttribution.ts delete mode 100644 src/libs/ReceiptStorage/receiptUpgrades.ts delete mode 100644 src/libs/actions/MarketingAttribution.ts delete mode 100644 src/libs/actions/TableColumnWidths.ts delete mode 100644 src/pages/DynamicReportDetailsPage/ReportDetailsContent.tsx delete mode 100644 src/pages/DynamicReportDetailsPage/ReportDetailsMenuTrackExpenseItems.tsx delete mode 100644 src/pages/inbox/report/useReportDeepLinkOnOpen.ts delete mode 100644 src/pages/inbox/report/useShouldEditInComposer.ts delete mode 100644 src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts delete mode 100644 src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts delete mode 100644 src/selectors/TableColumnWidths.ts delete mode 100644 src/types/onyx/MarketingAttribution.ts delete mode 100644 src/types/onyx/TableColumnWidths.ts delete mode 100644 tests/actions/MarketingAttributionTest.ts delete mode 100644 tests/ui/DynamicIOURequestStepUpgradeTest.tsx delete mode 100644 tests/ui/WorkspaceWorkflowsApprovalsApproverPageTest.tsx delete mode 100644 tests/unit/ContextMenuActionsDeleteTest.ts delete mode 100644 tests/unit/WorkspaceDuplicateSelectFeaturesFormTest.tsx delete mode 100644 tests/unit/applyColumnWidthOverridesTest.ts delete mode 100644 tests/unit/columnWidthExpressionsTest.ts delete mode 100644 tests/unit/components/NavigationAwareCameraTest.tsx delete mode 100644 tests/unit/components/Table/buildTableListDataTest.ts delete mode 100644 tests/unit/components/Table/useColumnResizeTest.ts delete mode 100644 tests/unit/components/withReportOrNotFound.test.tsx delete mode 100644 tests/unit/getResizableColumnLayoutTest.ts delete mode 100644 tests/unit/hooks/useLocalReceiptThumbnail.test.ts delete mode 100644 tests/unit/hooks/usePhotoUpgrade.test.ts delete mode 100644 tests/unit/hooks/useReceiptUpgradeCount.test.ts delete mode 100644 tests/unit/libs/receiptReadDuringSwapTest.ts delete mode 100644 tests/unit/libs/receiptUpgradesTest.ts delete mode 100644 tests/unit/rotatePhotoToUprightTest.ts delete mode 100644 tests/unit/selectors/TableColumnWidthsTest.ts diff --git a/.claude/skills/app-coding-standards/SKILL.md b/.claude/skills/app-coding-standards/SKILL.md index 17ab10ef3c30..7f1f76639aba 100644 --- a/.claude/skills/app-coding-standards/SKILL.md +++ b/.claude/skills/app-coding-standards/SKILL.md @@ -59,7 +59,6 @@ Coding standards for the Expensify App. Each standard is a standalone file in `r - [CONSISTENCY-17](rules/consistency-17-plural-form.md) — Pluralize with PluralForm, not ternaries on a count - [CONSISTENCY-18](rules/consistency-18-comment-non-obvious-code.md) — Comment code that is not self-explanatory - [CONSISTENCY-19](rules/consistency-19-copy-grammar-and-case.md) — User-facing copy is grammatical and sentence case -- [CONSISTENCY-20](rules/consistency-20-function-names.md) — Name functions with a verb that says what they do ### Clean React Patterns - [CLEAN-REACT-PATTERNS-0](rules/clean-react-0-compiler.md) — React Compiler compliance diff --git a/.claude/skills/app-coding-standards/rules/consistency-20-function-names.md b/.claude/skills/app-coding-standards/rules/consistency-20-function-names.md deleted file mode 100644 index 0abad36df62b..000000000000 --- a/.claude/skills/app-coding-standards/rules/consistency-20-function-names.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -ruleId: CONSISTENCY-20 -title: Name functions with a verb that says what they do ---- - -## [CONSISTENCY-20] Name functions with a verb that says what they do - -### Reasoning - -A function name that starts with a verb tells the caller what the function does without a trip to its body. A name made only of nouns or adjectives, such as `reportTotal`, reads as a value, so the reader cannot tell that the function computes something or what it returns. - -### Incorrect - -```ts -function reportTotal(transactions: Transaction[]): number { - // ... -} -``` - -### Correct - -```ts -function getReportTotal(transactions: Transaction[]): number { - // ... -} -``` - ---- - -### Review Metadata - -Flag ONLY when ALL of these are true: - -- The changed code **declares** a new or renamed function (a function declaration, or an arrow function or function expression assigned to a `const`/`let`) -- The name does not start with a verb, so it reads as a value rather than an action (e.g. `reportTotal`, `policyMembers`) - -**DO NOT flag if:** - -- The function is a React component, a higher-order component (`withX`), or a hook (`useX`) -- The function is an Onyx selector, which ends in `Selector` by convention (e.g. `isQBORefreshTokenExpiringSoonSelector`) -- The function is a conversion that follows the `toX()` / `fromX()` idiom (e.g. `toSafeReturnPath`) -- The name starts with `on` or `handle`, which [CONSISTENCY-12](consistency-12-callback-named-for-action.md) covers -- A parent class, an interface, a library, or another external contract sets the name -- The function already exists and the PR does not rename it -- The file is a test or story - -**Search Patterns** (hints for reviewers): -- `function [a-z]` / `const [a-z][A-Za-z]* = (` / `const [a-z][A-Za-z]* = async (` diff --git a/.github/workflows/nativeDependencies.yml b/.github/workflows/nativeDependencies.yml deleted file mode 100644 index 9f34711c3445..000000000000 --- a/.github/workflows/nativeDependencies.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Native dependency check - -on: - workflow_dispatch: - pull_request: - types: [opened, synchronize] - branches-ignore: [staging, production] - paths: - - '.github/workflows/nativeDependencies.yml' - - 'ios/Podfile' - - 'ios/pod-usage.json' - - 'ios/**.swift' - - 'ios/**.m' - - 'ios/**.mm' - - 'ios/**.h' - - 'ios/**.hpp' - - 'ios/**.pch' - - 'ios/**.c' - - 'ios/**.cpp' - - 'ios/**.cc' - - 'android/app/build.gradle' - - 'android/app/dependency-usage.json' - - 'android/app/**.java' - - 'android/app/**.kt' - - 'android/app/**.xml' - - 'scripts/checkUnusedPods.ts' - - 'scripts/checkUnusedGradleDependencies.ts' - - 'scripts/podUsageShared.ts' - - 'scripts/gradleUsageShared.ts' - - 'scripts/nativeSourceComments.ts' - - 'scripts/nativeUsageManifest.ts' - -concurrency: - group: ${{ github.ref == 'refs/heads/main' && format('{0}-{1}', github.ref, github.sha) || github.ref }}-nativeDependencies - cancel-in-progress: true - -jobs: - nativeDependencies: - name: Check for unused native dependencies - runs-on: blacksmith-2vcpu-ubuntu-2404 - steps: - - name: Checkout - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 - - - name: Setup Node - uses: ./.github/actions/composite/setupNode - - - name: Check for unused pods - run: npx bun scripts/checkUnusedPods.ts - - - name: Check for unused Gradle dependencies - run: npx bun scripts/checkUnusedGradleDependencies.ts diff --git a/Mobile-Expensify b/Mobile-Expensify index 49dee986556d..0bfa0070a60c 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 49dee986556dc4fcf428ff658d3b0d3f2df30d22 +Subproject commit 0bfa0070a60ccba87234c3285b7057465c5bd5a2 diff --git a/android/app/dependency-usage.json b/android/app/dependency-usage.json deleted file mode 100644 index ba00283e1a43..000000000000 --- a/android/app/dependency-usage.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "androidx.legacy:legacy-support-core-utils": { - "usage": "exempt", - "reason": "Umbrella artifact under the \"AndroidX support library\" comment; it ships no classes the sources import directly.", - "reviewed": "2026-08-28" - }, - "androidx.lifecycle:lifecycle-viewmodel-ktx": { - "usage": "exempt", - "reason": "Version pin for a transitive androidx.lifecycle; no Java or Kotlin source imports it. Consumer not identified \u2014 recheck when the androidx versions move.", - "reviewed": "2026-08-28" - }, - "androidx.multidex:multidex": { - "usage": "packages", - "packages": [ - "androidx.multidex" - ] - }, - "androidx.work:work-runtime-ktx": { - "usage": "exempt", - "reason": "Version pin only. android/app/build.gradle:240-242 records it as the fix for a conflict between airship and react-native-plaid-link-sdk.", - "reviewed": "2026-08-28" - }, - "com.facebook.fresco:animated-gif": { - "usage": "exempt", - "reason": "Sole supplier of the animated-GIF decoder for React Native. Removing it breaks the animations rather than the build.", - "reviewed": "2026-08-28" - }, - "com.facebook.fresco:fresco": { - "usage": "exempt", - "reason": "Image pipeline React Native draws through; the app reaches it via React Native rather than importing com.facebook.drawee itself.", - "reviewed": "2026-08-28" - }, - "com.facebook.react:hermes-android": { - "usage": "exempt", - "reason": "Supplies the Hermes JS engine as a native library, loaded at runtime rather than imported; ReactAndroid declares hermes-engine compileOnly, so nothing else brings it in.", - "reviewed": "2026-08-28" - }, - "com.facebook.react:react-android": { - "usage": "packages", - "packages": [ - "com.facebook.react" - ] - }, - "com.google.firebase:firebase-bom": { - "usage": "exempt", - "reason": "Platform BOM that aligns the Firebase versions React Native Firebase resolves; it declares no classes.", - "reviewed": "2026-08-28" - }, - "com.squareup.okhttp3:okhttp-urlconnection": { - "usage": "exempt", - "reason": "Version pin only. android/app/build.gradle:244 records it as preventing a crash with react-native-plaid-link-sdk.", - "reviewed": "2026-08-28" - }, - "io.github.react-native-community:jsc-android": { - "usage": "exempt", - "reason": "JavaScriptCore, declared through the jscFlavor variable in the else branch of the hermesEnabled check at android/app/build.gradle:218-222. android/gradle.properties sets hermesEnabled=true, so this branch is not taken today; the artifact is a native JS engine loaded by React Native rather than imported, so no source reference can prove or disprove it.", - "reviewed": "2026-09-17" - } -} diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 5fec781e7e52..0c37ef30f930 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -964,6 +964,7 @@ "../../src/pages/DynamicNewReportWorkspaceSelectionPage.tsx" "no-restricted-imports" 1 "../../src/pages/DynamicReportChangeApproverPage.tsx" "no-restricted-imports" 1 "../../src/pages/DynamicReportChangeWorkspacePage.tsx" "no-restricted-imports" 1 +"../../src/pages/DynamicReportDetailsPage/index.tsx" "no-restricted-imports" 1 "../../src/pages/DynamicReportParticipantDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/DynamicReportParticipantDetailsPage.tsx" "no-restricted-imports" 1 "../../src/pages/DynamicReportParticipantRoleSelectionPage.tsx" "no-restricted-imports" 1 @@ -1632,7 +1633,7 @@ "../../src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/workspace/members/WorkspaceMemberCustomFieldPage.tsx" "no-restricted-imports" 1 -"../../src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/members/WorkspaceOwnerChangeCheck.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/workspace/members/WorkspaceOwnerPaymentCardForm.tsx" "react-hooks/set-state-in-effect" 2 @@ -1709,6 +1710,7 @@ "../../src/pages/workspace/WorkspaceConfirmationOwnerSelectorPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/WorkspaceConfirmationSuccessPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/WorkspaceInitialPage.tsx" "no-restricted-imports" 1 +"../../src/pages/workspace/WorkspaceMembersPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/WorkspaceMembersPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/WorkspaceNamePage.tsx" "no-restricted-imports" 1 diff --git a/config/rsbuild/rsbuild.common.ts b/config/rsbuild/rsbuild.common.ts index caf4082088e8..f610e45b943e 100644 --- a/config/rsbuild/rsbuild.common.ts +++ b/config/rsbuild/rsbuild.common.ts @@ -442,6 +442,7 @@ const getCommonConfiguration = async ({file = '.env', platform = 'web', isDevSer // Rsbuild's default exclusion, plus the `.br` twins BrotliCompressionPlugin emits below: listing them would // double the report with a meaningless "gzipped size" of already-Brotli-compressed bytes. printFileSize: {exclude: (asset) => /\.(?:map|LICENSE\.txt|d\.(?:ts|mts|cts)|br)$/.test(asset.name)}, + // We have to load the whole lottie player to get the player to work in offline mode // heic-to library is used sparsely so we load it as a separate chunk to reduce initial bundle size // ExpensifyIcons/illustrations chunks are loaded eagerly for offline support // Vendor: extract all 3rd party deps (~75% of App) to a separate js file for better caching @@ -449,6 +450,11 @@ const getCommonConfiguration = async ({file = '.env', platform = 'web', isDevSer strategy: 'custom', splitChunks: { cacheGroups: { + lottiePlayer: { + test: /[\\/]node_modules[\\/](@dotlottie\/react-player)[\\/]/, + name: 'lottiePlayer', + chunks: 'all', + }, heicTo: { test: /[\\/]node_modules[\\/](heic-to)[\\/]/, name: 'heicTo', diff --git a/cspell.json b/cspell.json index ab05306535c1..f5f9e034144b 100644 --- a/cspell.json +++ b/cspell.json @@ -49,7 +49,6 @@ "BNDL", "BOFMCAM2", "BROWSABLE", - "bumptech", "bunfig", "BYOC", "BambooHr", @@ -126,7 +125,6 @@ "EUVAT", "elif", "evals", - "EXIF", "EXPENSIDEV", "EXPENSIFYAPI", "EXPENSIFYPDBUSINESS", @@ -140,7 +138,6 @@ "Ephermeral", "Erste", "Español", - "detekt", "Fabrikam", "fillyjonk", "hemulen", @@ -149,16 +146,11 @@ "IFSC", "imgbot", "issuecomment", - "kapt", - "modulemap", "mymble", "snorkmaiden", "ticky", "unparseable", - "urlconnection", - "viewmodel", "whomper", - "xcframework", "Ελληνικά", "Expatriot", "Expensable", @@ -184,7 +176,6 @@ "GDSO", "GEOLOCATION", "Gaber", - "Gbraid", "Gclid", "Geral", "gitlink", @@ -460,7 +451,6 @@ "WDYR", "Wallester", "Warchoł", - "Wbraid", "Webp", "Wintrust", "Woohoo", @@ -1135,7 +1125,6 @@ "utilise", "vcfanzfav", "vcfcitibank", - "vcfmock", "vcpu", "verticalanchor", "victoryaxis", diff --git a/docs/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite.md b/docs/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite.md index 9e4170dcd76b..d7f677d402a0 100644 --- a/docs/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite.md +++ b/docs/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite.md @@ -1,18 +1,16 @@ --- title: Connect to NetSuite description: Connect NetSuite to Expensify for streamlined expense reporting and accounting integration. -keywords: [NetSuite, integration, Expensify Classic, expense reporting, accounting, automated reporting, NetSuite OAuth 2.0] +keywords: [NetSuite, integration, Expensify Classic, expense reporting, accounting, automated reporting] order: 1 --- Expensify integrates directly with NetSuite to automate report exports, customize your coding preferences, and leverage NetSuite's advanced features. Follow the steps below to set up the integration. -**Note:** New NetSuite connections use OAuth 2.0, which you can only set up in New Expensify. To connect NetSuite, switch to New Expensify and follow the steps in [Connect to NetSuite in New Expensify](/articles/new-expensify/connections/netsuite/Connect-To-NetSuite). After you connect, the connection works in both Expensify Classic and New Expensify. - # Before You Connect NetSuite to Expensify -- **OAuth 2.0** is how new connections are set up. You set it up in New Expensify, and you need two-factor authentication enabled on your Expensify account. +- **Token-based authentication** ensures each request to NetSuite is signed and verified for authenticity. - You must have **administrator access** in NetSuite to initiate the connection. - **Control Plan** in Expensify is required for this integration. - Employees do not need NetSuite access or a license to submit expense reports; the integration is managed by the Workspace Admin. @@ -30,14 +28,10 @@ Expensify integrates directly with NetSuite to automate report exports, customiz 4. If you already have the Expensify Connect bundle installed, go to **Customization > SuiteBundler > Search & Install Bundles > List** and update to the latest version. 5. Ensure **Show on Existing Custom Forms** is selected for all available fields. -The latest bundle version includes the permissions Expensify needs for OAuth 2.0 and REST web services, so update it before you connect. - --- # Step 2: Enable Token-Based Authentication -**Note:** Steps 2–4 and Step 12 describe how existing token-based connections were set up. You can no longer create a new token-based connection. Existing token-based connections keep working until they migrate to OAuth 2.0, and we'll let you know well before token-based authentication is removed. To set up a new connection, follow the steps in [Connect to NetSuite in New Expensify](/articles/new-expensify/connections/netsuite/Connect-To-NetSuite). - 1. In NetSuite, navigate to **Setup > Company > Enable Features > SuiteCloud > Manage Authentication**. 2. Enable **Token-Based Authentication**. 3. Click **Save**. @@ -138,8 +132,6 @@ Ensure **Tax Groups** can be applied to expenses by setting the **Tax Code Lists # Step 12: Connect Expensify and NetSuite -**Note:** You can no longer create a new token-based connection. New connections use OAuth 2.0, and Expensify Classic directs you to New Expensify to complete the connection. The steps below show how existing token-based connections were set up. - 1. Log into Expensify as a **Workspace Admin** and go to **Settings > Workspaces > [Workspace Name] > Accounting > NetSuite**. 2. Click **Connect to NetSuite**. 3. Enter your **Account ID** (found in NetSuite under **Setup > Integration > Web Services Preferences**). @@ -171,25 +163,5 @@ To transfer the connection ownership: 4. Enter the new admin's email address and NetSuite account ID. 5. Follow the steps to reconfigure the connection. -Reconnecting creates a new connection, so you'll complete it in New Expensify using OAuth 2.0. - ---- - -## Why do I need New Expensify to connect NetSuite? - -The OAuth 2.0 connection and re-authorization flows are only available in New Expensify. After you connect, you can keep using Expensify Classic to manage your NetSuite settings and export reports. - ---- - -## Do I need to do anything about my existing NetSuite connection? - -No. Workspaces that already connect to NetSuite using token-based authentication keep working exactly as they do today. When it's time to re-authorize using OAuth 2.0, we'll let you know, and you'll complete it in New Expensify. - ---- - -## When is token-based authentication going away? - -We'll remove token-based authentication once every customer with an existing connection has migrated to OAuth 2.0. We'll give you notice and migration steps before that happens, so you don't need to do anything yet. - --- diff --git a/docs/articles/new-expensify/connections/certinia/Configure-Certinia.md b/docs/articles/new-expensify/connections/certinia/Configure-Certinia.md index 1f165a9df00d..53e2208557d9 100644 --- a/docs/articles/new-expensify/connections/certinia/Configure-Certinia.md +++ b/docs/articles/new-expensify/connections/certinia/Configure-Certinia.md @@ -84,9 +84,7 @@ If a report contains both reimbursable and non-reimbursable expenses, Expensify ## How to set a default vendor for Certinia (FFA) -Select **Default vendor**, then choose a vendor from your Certinia FFA account. This vendor is assigned to non-reimbursable expenses that don't already have a vendor assigned. - -If vendor matching is enabled for your Workspace, [learn how to manage vendor matching for Certinia](/articles/new-expensify/connections/certinia/Manage-Vendor-Matching-for-Certinia). +Select a vendor from your Certinia FFA account. This vendor is assigned to non-reimbursable payable invoices. --- @@ -137,7 +135,7 @@ No. Enabling Auto-sync does not affect previously approved or reimbursed reports ## How do reports map to records in Certinia? -- **FFA (Payable Invoices):** Account Name = the account linked to the submitter's email (for non-reimbursable expenses, the assigned vendor account is used when one is available), Reference 1 = the report URL, Invoice Description = the report title. +- **FFA (Payable Invoices):** Account Name = the account linked to the submitter's email, Reference 1 = the report URL, Invoice Description = the report title. - **PSA/SRP (Expense Reports):** Expense Report Name = the report title, Resource = the submitter's email, Description = the report URL, Approver = the Expensify report approver. ## How do I export tax? diff --git a/docs/articles/new-expensify/connections/certinia/Manage-Vendor-Matching-for-Certinia.md b/docs/articles/new-expensify/connections/certinia/Manage-Vendor-Matching-for-Certinia.md deleted file mode 100644 index 169b42c742e4..000000000000 --- a/docs/articles/new-expensify/connections/certinia/Manage-Vendor-Matching-for-Certinia.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -title: Manage Vendor Matching for Certinia -description: Learn how Certinia FFA vendor matching assigns vendors to non-reimbursable expenses before they export as Payable Invoices, including automatic matching, manual selection, and default vendor behavior. -keywords: [Certinia, FinancialForce, FFA, vendor matching, vendor, company card expenses, Payable Invoices, default vendor] -internalScope: Audience is workspace admins using the Certinia FFA connection. Covers imported Certinia vendors, automatic and manual vendor assignment, default vendor behavior, how vendors affect Payable Invoice export, and vendors that are no longer valid. Does not cover Certinia connection setup, other Certinia configuration settings, Certinia PSA/SRP, or vendor matching for other accounting connections. -noindex: true -sitemap: false ---- - -# Manage Vendor Matching for Certinia - -Certinia vendor matching lets workspace admins review and update the vendor assigned to non-reimbursable expenses before they export to Certinia FFA as Payable Invoices. Expensify imports your Certinia vendor accounts, automatically matches vendors where possible, and lets admins set or update the **Vendor** field before export. This helps ensure expenses export to the correct vendor account instead of requiring manual corrections in Certinia. - -## Who can use Certinia vendor matching - -This feature is available to workspace admins whose workspace: - - - Is connected to Certinia using the FFA module. - - Has finished configuring the Certinia connection. - -Certinia vendor matching isn't available for Certinia PSA or SRP connections. - -Certinia vendor matching is rolling out gradually. If the **Vendors** feature and the **Vendor** field don't appear on a configured Certinia FFA workspace, they aren't enabled for your workspace yet. - -If your workspace isn't connected to Certinia yet, learn how to [connect to Certinia](/articles/new-expensify/connections/certinia/Connect-To-Certinia). - -## How vendors are matched to expenses - -Expensify assigns vendors automatically in the following order: - - - If a workspace merchant rule specifies a vendor, that vendor is assigned. - - Otherwise, Expensify automatically matches the merchant name against your imported Certinia vendor list. For example, **STARBUCKS #456 DOWNTOWN** matches **Starbucks**. - - If no match is found, the **Vendor** field remains empty until a workspace admin selects one. - -Whenever a vendor is assigned automatically, Concierge posts a system message on the expense indicating whether the vendor was set by a merchant rule or by vendor matching. - -## How to select a Certinia vendor on an expense - -1. Open the non-reimbursable expense. -2. Select **Vendor**. -3. Search for the vendor by name. -4. Select the vendor you want to assign. - -Once a vendor is selected manually, Expensify preserves that selection and won't overwrite it with automatic matching. - -![The expense details view for a non-reimbursable company card expense on a Certinia-connected workspace, with the Vendor row visible directly below Category and a matched vendor name shown]({{site.url}}/assets/images/Expense_select-vendor.png){:width="100%"} - -## Where to find your imported Certinia vendors - -1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [workspace name]**. -2. Select **Vendors**. -3. Use **Find vendor** to search the list by name. - -Vendors are managed in Certinia, so the list is read-only in Expensify and refreshes when the connection syncs. Expensify imports Salesforce accounts of type Vendor or Supplier from your Certinia FFA account. - -## How to set a default vendor for Certinia - -1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Workspaces > [workspace name]**. -2. Select **Accounting**. -3. On the Certinia connection, select **Export**. -4. Select **Default vendor**. -5. Select a vendor from your imported Certinia vendor list. - -The default vendor is used only when an expense doesn't already have a vendor assigned. - -## How vendors export to Certinia - -When non-reimbursable expenses export to Certinia FFA, Expensify assigns each expense to a vendor account in the following order: - -1. The vendor selected on the expense, if it's still in your imported Certinia vendor list. -2. The vendor account set for the company card, if one is set. -3. The **Default vendor** configured in the Certinia export settings. -4. The Certinia account linked to the report submitter's email. - -Expensify creates one Payable Invoice for each combination of company card and vendor account on the report. For example, a report with expenses from two vendors on the same card exports as two Payable Invoices. - -Because a matched vendor or default vendor can be used, non-reimbursable expenses can export even if the report submitter isn't set up as a contact in Certinia. - -Reimbursable expenses aren't affected by vendor matching. They continue to export as one Payable Invoice made out to the employee. - -## How vendors that are no longer valid affect expenses - -If a vendor assigned to an expense is removed or archived in Certinia, the expense displays a **Vendor no longer valid** error after the connection syncs, similar to category, tag, and tax violations. - -Select an active vendor on the expense to clear the error. - -# FAQ - -## Do I have to set a vendor? - -No. The **Vendor** field is optional. - -Expensify automatically attempts to match a vendor using your imported Certinia vendor list. If no match is found, the field can remain blank, and the expense exports using the next available vendor account described in the export order above. - -## Does manually assigning a vendor stop automatic matching? - -Yes. Once a workspace admin manually assigns a vendor to an expense, Expensify preserves that selection and won't replace it with automatic matching. - -## Why don't I see any vendors to choose from? - -The vendor selector shows **No vendors found** when your Certinia vendor list is empty. Add the vendors in Certinia, then sync the connection again. - -## Why is vendor matching missing on my Certinia PSA workspace? - -Vendor matching is only available for Certinia FFA connections. Certinia PSA and SRP connections don't support vendor matching. diff --git a/docs/articles/new-expensify/connections/netsuite/Connect-To-NetSuite.md b/docs/articles/new-expensify/connections/netsuite/Connect-To-NetSuite.md index 36afe0f0a109..6b8647d7a60c 100644 --- a/docs/articles/new-expensify/connections/netsuite/Connect-To-NetSuite.md +++ b/docs/articles/new-expensify/connections/netsuite/Connect-To-NetSuite.md @@ -1,143 +1,137 @@ --- title: Connect to NetSuite -description: Connect NetSuite to New Expensify for streamlined expense reporting and accounting sync. -keywords: [New Expensify, NetSuite integration, connect NetSuite, NetSuite OAuth 2.0, NetSuite REST web services, NetSuite token-based authentication, NetSuite bundle, accounting sync] -internalScope: Audience is Workspace admins on the Control plan who are also NetSuite administrators, covers setting up a new NetSuite connection using OAuth 2.0 and REST web services plus the legacy token-based authentication steps that are still supported, does not cover configuring import/export settings after connecting or troubleshooting a broken connection +description: Connect NetSuite to New Expensify for streamlined expense reporting and accounting integration. +keywords: [New Expensify, NetSuite integration, connect NetSuite, NetSuite bundle, accounting sync] order: 1 --- -# Connect to NetSuite -Connect your Expensify Workspace to NetSuite to streamline expense syncing, reporting, and accounting. New connections use OAuth 2.0 and NetSuite's REST web services, so you approve access on a NetSuite consent screen instead of creating and copying access tokens. +Connect your Expensify Workspace to NetSuite to streamline expense syncing, reporting, and accounting. This guide walks you through installing the bundle, enabling features, and finalizing the connection. -This guide walks you through preparing NetSuite, connecting Expensify, and confirming the connection. Token-based authentication is still supported and documented below, but OAuth 2.0 is the recommended way to set up a new connection. +**Note:** You must have a NetSuite account and be on the **Control** plan in Expensify. ---- - -## What you need before you connect to NetSuite - -- A NetSuite account you can log into as an administrator -- The **Control** plan in Expensify -- Two-factor authentication enabled on your Expensify account, which NetSuite requires to authorize an OAuth 2.0 connection -- One Expensify **Workspace** per NetSuite **subsidiary** -- A Workspace currency that matches the NetSuite subsidiary's default currency - -Employees submitting reports don't need NetSuite access or licenses. - ---- - -## How to connect to NetSuite - -Complete these steps in order. Each step has its own section below with detailed instructions. +**Before you begin, make sure:** -1. Install the Expensify bundle in NetSuite -2. Enable OAuth 2.0 in NetSuite -3. Enable REST web services in NetSuite -4. Enable expense reports in NetSuite -5. Set up expense categories in NetSuite -6. Check your journal entry transaction forms -7. Check your expense report transaction forms -8. Check your vendor bill transaction forms -9. Check your vendor credit transaction forms -10. Set up tax groups in NetSuite -11. Connect Expensify to NetSuite using OAuth 2.0 - -If you're connecting with token-based authentication instead, follow the steps in **How to connect to NetSuite using token-based authentication** below. +- You can log into NetSuite as an administrator +- Each NetSuite **subsidiary** maps to a separate Expensify **Workspace** +- Your Workspace currency matches the NetSuite subsidiary's default currency +- Employees submitting reports don’t need NetSuite access or licenses --- -## How to install the Expensify bundle in NetSuite +# Step 1: Install the Expensify bundle in NetSuite 1. In NetSuite, go to **Customization > SuiteBundler > Search & Install Bundles** 2. Search for **Expensify** and select the **Expensify Connect** bundle (Bundle ID `283395`) 3. Click **Install** -4. If the bundle is already installed, go to **SuiteBundler > List** and update to the latest version +4. If already installed, go to **SuiteBundler > List** and update to the latest version 5. Select **Show on Existing Custom Forms** for all available fields -The latest bundle version grants the permissions Expensify needs to log in using OAuth 2.0 access tokens and to use REST web services, so update it before you connect. - --- -## How to enable OAuth 2.0 in NetSuite +# Step 2: Enable Token-Based Authentication -1. In NetSuite, go to **Setup > Company > Enable Features > SuiteCloud** -2. Under **Manage Authentication**, enable **OAuth 2.0** +1. Go to **Setup > Company > Enable Features > SuiteCloud > Manage Authentication** +2. Enable **Token Based Authentication** 3. Click **Save** -Skip this section if you're connecting with token-based authentication instead. - --- -## How to enable REST web services in NetSuite +# Step 3: Assign the Expensify Integration Role -1. In NetSuite, go to **Setup > Company > Enable Features > SuiteCloud** -2. Under **SuiteTalk (Web Services)**, enable **REST Web Services** -3. Click **Save** +1. Go to **Lists > Employees** +2. Find and edit the employee who will connect the integration +3. Under the **Access** tab, add the **Expensify Integration** role +4. Click **Save** -Skip this section if you're connecting with token-based authentication instead. +**Reminder:** Tokens link to a **user-role combination**, not just a user. You must use the same user and role for all syncs after setup. --- -## How to enable expense reports in NetSuite +# Step 4: Create access tokens + +1. In NetSuite, use the Global Search to search for `page: tokens` +2. Click **New Access Token** +3. Select **Expensify** as the application and the **Expensify Integration** role +4. Click **Save** +5. Copy and securely save the **Token ID** and **Token Secret** (you’ll only see them once) + +--- + +# Step 5: Enable Expense Reports in NetSuite 1. Go to **Setup > Company > Enable Features > Employees** -2. Make sure **Expense Reports** is enabled -3. If it isn't, check the box and click **Save** +2. Ensure **Expense Reports** is enabled +3. If not, check the box and click **Save** --- -## How to set up expense categories in NetSuite +# Step 6: Set up Expense Categories in NetSuite 1. Go to **Setup > Accounting > Expense Categories** 2. If none are listed, click **New** to create them -Expense categories map to Expensify categories and are linked to GL accounts so employees can select them easily. +**Note:** Expense Categories map to Expensify Categories and are linked to GL accounts for easy employee selection. --- -## How to check your journal entry transaction forms +# Step 7: Check Journal Entry transaction forms 1. Go to **Customization > Forms > Transaction Forms** 2. Edit the **Standard Journal Entry** form: - - Under the **Main** tab, make sure **Created From** is shown and its display type is **Normal** - - Under **Lines**, make sure **Receipt URL** is shown + - Under **Main** tab, make sure: + - **Created From** is shown + - Display type is **Normal** + - Under **Lines**, ensure **Receipt URL** is shown 3. Repeat for all journal-type forms --- -## How to check your expense report transaction forms +# Step 8: Check Expense Report transaction forms 1. Go to **Customization > Forms > Transaction Forms** 2. Edit the **Standard Expense Report** form: - - Under the **Main** tab, make sure **Created From** is shown - - Under the **Expenses** subtab, make sure **Receipt URL** is shown + - Under **Main** tab: + - **Created From** should be shown + - Under **Expenses** subtab: + - **Receipt URL** should be shown 3. Repeat for all expense report-type forms --- -## How to check your vendor bill transaction forms +# Step 9: Check Vendor Bill transaction forms 1. Go to **Customization > Forms > Transaction Forms** 2. Edit your Vendor Bill form: - - Under the **Main** tab, show **Created From** and hide **Department**, **Class**, and **Location** - - Under the **Expenses** subtab, show **Receipt URL**, **Department**, **Location**, and **Class** + - Under **Main** tab: + - Show “Created From” + - Hide **Department**, **Class**, and **Location** + - Under **Expenses** subtab: + - Show **Receipt URL**, **Department**, **Location**, **Class** 3. Repeat for all vendor bill-type forms --- -## How to check your vendor credit transaction forms +# Step 10: Check Vendor Credit transaction forms 1. Go to **Customization > Forms > Transaction Forms** 2. Edit your Vendor Credit form: - - Under the **Main** tab, show **Created From** and hide **Department**, **Class**, and **Location** - - Under the **Expenses** subtab, show **Receipt URL**, **Department**, **Location**, and **Class** + - Under **Main** tab: + - Show “Created From” + - Hide **Department**, **Class**, and **Location** + - Under **Expenses** subtab: + - Show **Receipt URL**, **Department**, **Location**, **Class** 3. Repeat for all vendor credit-type forms --- -## How to set up tax groups in NetSuite +# Step 11: Set up Tax Groups (if applicable) + +**Things to note about tax:** -Expensify supports Tax Groups, not individual Tax Codes, except when you use SuiteTax. Under **Setup > Accounting > Set Up Taxes**, set **Tax Code Lists Include** to **Tax Groups And Tax Codes** or **Tax Groups Only**. In the UK and Ireland, make sure each Tax Group contains only one Tax Code. +- Expensify supports Tax Groups, not individual Tax Codes (except when using SuiteTax) +- Set **Tax Code Lists Include** to “Tax Groups And Tax Codes” or “Tax Groups Only” under **Setup > Accounting > Set Up Taxes** +- UK and Ireland: ensure each Tax Group contains only one Tax Code To create a Tax Group: @@ -147,27 +141,21 @@ To create a Tax Group: 4. Choose a Tax Code and click **Add** 5. Click **Save** 6. Repeat for each tax rate you want to show in Expensify - --- -## How to connect Expensify to NetSuite using OAuth 2.0 +# Step 12: Connect Expensify to NetSuite -1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Workspaces > [Workspace Name] > Accounting** +1. From the navigation tabs (on the left on web, and at the bottom on mobile), go to **Workspaces > [Workspace Name] > Accounting** 2. Click **Connect** next to **NetSuite** -3. Click **Next** through the NetSuite setup steps -4. Enter your **NetSuite Account ID** -5. Click **Connect** -6. On the NetSuite consent screen, sign in if prompted and approve access for Expensify - -If you're connecting to a Sandbox environment, capitalize the "s" and "b" in the Account ID and replace the hyphen with an underscore. For example, `123456-sb1` becomes `123456_SB1`. - -You can find your Account ID in NetSuite under **Setup > Integration > Web Services Preferences**. +3. Click **Next** through setup steps 1–4 +4. On **Step 5**, enter: + - **NetSuite Account ID** + - Note: If connecting to a Sandbox environment, in the Account ID, ensure the "s" and "b" are always capitalized and the hyphen "-" is replaced by an underscore "_". So "123456-sb1" would be "123456_SB1". + - **Token ID** + - **Token Secret** +5. Click **Confirm** - +You can find your **Account ID** in NetSuite under **Setup > Integration > Web Services Preferences.** ![The New Expensify workspace setting is open and the More Features tab is selected and visible. The toggle to enable Accounting is highlighted with an orange call out and is currently in the grey disabled position.]({{site.url}}/assets/images/ExpensifyHelp-Xero-1.png) @@ -175,97 +163,20 @@ Purpose: Members expect to stay in Expensify and may abandon the flow when NetSu --- -## What happens after you connect to NetSuite +# After Setup - The NetSuite connection syncs within 1–2 minutes -- Newly approved and paid reports export to NetSuite -- Next, choose your import, export, and advanced settings. [Learn how to configure NetSuite in Expensify](/articles/new-expensify/connections/netsuite/Configure-Netsuite). - ---- - -## How to connect to NetSuite using token-based authentication - -**Note:** Token-based authentication still works, but we're deprecating it. We'll remove it as soon as every customer with an existing connection has migrated to the new OAuth 2.0 connection, and we'll let you know well before that happens. If you're setting up a new connection, use OAuth 2.0 instead. - -Complete these steps in order. Steps 1 and 5–11 are the same as for an OAuth 2.0 connection. Steps 2–4 and 12 have their own sections below. - -1. Install the Expensify bundle in NetSuite -2. Enable token-based authentication in NetSuite -3. Assign the Expensify Integration role in NetSuite -4. Create access tokens in NetSuite -5. Enable expense reports in NetSuite -6. Set up expense categories in NetSuite -7. Check your journal entry transaction forms -8. Check your expense report transaction forms -9. Check your vendor bill transaction forms -10. Check your vendor credit transaction forms -11. Set up tax groups in NetSuite -12. Connect Expensify to NetSuite using access tokens - ---- - -## How to enable token-based authentication in NetSuite - -1. Go to **Setup > Company > Enable Features > SuiteCloud > Manage Authentication** -2. Enable **Token Based Authentication** -3. Click **Save** - ---- - -## How to assign the Expensify Integration role in NetSuite - -1. Go to **Lists > Employees** -2. Find and edit the employee who will connect the integration -3. Under the **Access** tab, add the **Expensify Integration** role -4. Click **Save** - -Tokens link to a user-role combination, not just a user. Use the same user and role for all syncs after setup. - ---- - -## How to create access tokens in NetSuite - -1. In NetSuite, use the Global Search to search for `page: tokens` -2. Click **New Access Token** -3. Select **Expensify** as the application and the **Expensify Integration** role -4. Click **Save** -5. Copy and securely save the **Token ID** and **Token Secret**, which you'll only see once - ---- - -## How to connect Expensify to NetSuite using access tokens - -1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Workspaces > [Workspace Name] > Accounting** -2. Click **Connect** next to **NetSuite** -3. Click **Next** through the NetSuite setup steps -4. Enter your **NetSuite Account ID**, **Token ID**, and **Token Secret** -5. Click **Confirm** - -If you're connecting to a Sandbox environment, capitalize the "s" and "b" in the Account ID and replace the hyphen with an underscore. For example, `123456-sb1` becomes `123456_SB1`. +- Newly approved and paid reports will export to NetSuite via SOAP Web Services +- Continue with configuring settings by visiting: + [Configure NetSuite in Expensify](https://help.expensify.com/articles/new-expensify/connections/netsuite/Configure-Netsuite) --- # FAQ -## Why do I need two-factor authentication to connect NetSuite? - -NetSuite requires two-factor authentication to authorize an OAuth 2.0 connection. If two-factor authentication isn't enabled on your Expensify account, Expensify prompts you to enable it before you can continue. Token-based connections don't require it. - -## Do I still need to create access tokens in NetSuite? - -Not if you connect with OAuth 2.0. You approve access on a NetSuite consent screen instead, so there are no token IDs or token secrets to create, copy, or store. You only create access tokens if you connect with token-based authentication, which we're deprecating. - -## How are existing NetSuite connections affected by the new OAuth connection option? - -Workspaces that already connect to NetSuite using token-based authentication keep working exactly as they do today. You don't need to reinstall the bundle, create new tokens, or reconnect. Expensify will let you know before anything changes for existing connections. - -## When is token-based authentication going away? - -We'll remove token-based authentication once every customer with an existing connection has migrated to OAuth 2.0. We'll give you notice and migration steps before that happens, so you don't need to do anything yet. - ## How do I ensure smooth import of customer and vendor data from NetSuite? -Set the **Page Size** to 1000 in NetSuite: - +Set the **Page Size** to 1000 in NetSuite. To do this: 1. Go to **Setup > Integration > Web Services Preferences** 2. Search for and update the **Page Size** setting + diff --git a/docs/articles/new-expensify/connections/quickbooks-online/Configure-Quickbooks-Online.md b/docs/articles/new-expensify/connections/quickbooks-online/Configure-Quickbooks-Online.md index 95c5d2fc0cce..95703706d2d1 100644 --- a/docs/articles/new-expensify/connections/quickbooks-online/Configure-Quickbooks-Online.md +++ b/docs/articles/new-expensify/connections/quickbooks-online/Configure-Quickbooks-Online.md @@ -56,9 +56,9 @@ To choose how data is exported from Expensify to QuickBooks Online: - **Export company card expenses as**: - Credit card (default) - Debit card - - For both Credit card and Debit card, optionally select a **Default vendor** to apply to expenses that don't auto-match a vendor. If you leave it blank, those expenses export as **Credit Card Misc** or **Debit Card Misc**. Learn how vendors are imported, matched, and exported in [QuickBooks Online Vendor Matching](/articles/new-expensify/connections/quickbooks-online/Manage-Vendor-Matching-for-QuickBooks-Online). + - Optionally set a **Default vendor** as a fallback for expenses that don't have a vendor set. Learn how vendors are imported, matched, and exported in [QuickBooks Online Vendor Matching](/articles/new-expensify/connections/quickbooks-online/Manage-Vendor-Matching-for-QuickBooks-Online.md). - Vendor bill - - If exporting as vendor bills, select the AP account and optionally select a **Default vendor**. QuickBooks Online doesn't substitute a **Credit Card Misc** or **Debit Card Misc** vendor on vendor bill exports. + - If exporting as vendor bills, select the AP account and optionally a default vendor - Vendor bill is unavailable when locations are imported as tags. To keep it available, import locations as report fields (only available on the **Control** plan) - **Export Consolidated Travel Billing expenses as**: This option appears only when [Consolidated Travel Billing](/travel/hubs/consolidated-travel-billing/) is enabled. Travel expenses export as credit card charges. Click it, then select the **Credit card account** where travel expenses will post in QuickBooks Online. @@ -74,7 +74,7 @@ To manage automation and other connection preferences: - **Auto-sync**: Automatically updates data between QuickBooks Online and Expensify - **Invite employees**: Imports and invites employees from QuickBooks Online -- **Auto-create entities**: Expensify automatically creates vendors in QuickBooks Online if they don’t exist already, and auto-creates customers when exporting invoices. Turning this setting on or off doesn’t change the **Default vendor** you selected under **Export** +- **Automatically create entities**: If a vendor/customer doesn’t exist, Expensify will create one - **Sync reimbursed reports**: - If marked as paid in QuickBooks Online, the report will show as reimbursed in Expensify - If reimbursed via ACH in Expensify, the status will sync to paid in QuickBooks diff --git a/docs/articles/new-expensify/connections/quickbooks-online/Manage-Vendor-Matching-for-QuickBooks-Online.md b/docs/articles/new-expensify/connections/quickbooks-online/Manage-Vendor-Matching-for-QuickBooks-Online.md index d7383b266189..5faca8c6b61e 100644 --- a/docs/articles/new-expensify/connections/quickbooks-online/Manage-Vendor-Matching-for-QuickBooks-Online.md +++ b/docs/articles/new-expensify/connections/quickbooks-online/Manage-Vendor-Matching-for-QuickBooks-Online.md @@ -47,7 +47,7 @@ When company card expenses are exported, Expensify assigns vendors in the follow 1. The vendor selected on the expense. 2. The **Default vendor** configured in workspace settings. -3. **Credit Card Misc** or **Debit Card Misc**, if neither of the above is available. +3. **Credit Card Misc** or **Debit Card Misc**., if neither of the above is available. ## How inactive QuickBooks Online vendors affect expenses diff --git a/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-US.md b/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-US.md index 9a473a4ee10f..103f7634afb3 100644 --- a/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-US.md +++ b/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-US.md @@ -92,8 +92,7 @@ When issuing a card, you can also apply an existing Spend Rule or create a new o - Limit type - Card name - Deactivation -4. On the web, you can also hover over a card's **Name**, **Limit type**, or **Limit** in the list and click the pencil icon that appears to edit it inline. -5. To change the linked bank account, update settlement frequency, or manage cash back settings, click **Settings**. +4. To change the linked bank account, update settlement frequency, or manage cash back settings, click **Settings**. Your workspace also has built-in default protections that automatically block certain transaction categories on every Expensify Card, and you can set Spend Rules to approve or decline transactions in real time. To learn more, [learn how to set up Expensify Card Spend Rules](/articles/new-expensify/expensify-card/Expensify-Card-Spend-Rules). diff --git a/docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md index 72bd6e6349cb..ec6190b6f080 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md @@ -1,7 +1,7 @@ --- title: Distance Expenses description: Learn how to create a Distance expense using GPS tracking, map-based routes, manual entry, or odometer readings, and how the reimbursement rate is determined in New Expensify. -keywords: [New Expensify, distance expense, mileage, mileage reimbursement, create expense, distance rate, workspace rate, map route, reuse route, reuse prior route, reimbursement rate, manual mileage, manual distance, global create, track distance, GPS, GPS tracking, start GPS, track route, track mileage, mileage tracking, calculate mileage reimbursement, mileage rate, odometer, odometer reading, odometer image, odometer mileage, odometer distance, start reading, end reading, save for later, in-progress odometer, incomplete odometer, edit distance, edit waypoints, manual edit distance, edit stop, trim trip, edit GPS trip, shorten GPS route, GPS slider, total distance, stop GPS trip, stop without moving, resume GPS trip, discard GPS trip, zero distance trip, same start and stop location, no edit icon, can't create expense, change distance rate, edit distance rate, move distance expense, move expense to another workspace, rate not valid for this workspace, distance rate violation] +keywords: [New Expensify, distance expense, mileage, mileage reimbursement, create expense, distance rate, workspace rate, map route, reimbursement rate, manual mileage, manual distance, global create, track distance, GPS, GPS tracking, start GPS, track route, track mileage, mileage tracking, calculate mileage reimbursement, mileage rate, odometer, odometer reading, odometer image, odometer mileage, odometer distance, start reading, end reading, save for later, in-progress odometer, incomplete odometer, edit distance, edit waypoints, manual edit distance, edit stop, trim trip, edit GPS trip, shorten GPS route, GPS slider, total distance, stop GPS trip, stop without moving, resume GPS trip, discard GPS trip, zero distance trip, same start and stop location, no edit icon, can't create expense, change distance rate, edit distance rate, move distance expense, move expense to another workspace, rate not valid for this workspace, distance rate violation] internalScope: Audience is all members. Covers creating Distance expenses using GPS tracking, map-based routes, manual entry, and odometer readings, plus how reimbursement rates are applied. Does not cover configuring Workspace distance rates in detail or broader report submission workflows. --- @@ -39,7 +39,6 @@ To create an expense using distance between the starting and ending locations of 2. Select **Map** from the top row. 3. Enter the **Start** and **Stop** locations. - To include additional stops, select **Add stop**. - - To use a route from a map-based Distance expense you created in the last 90 days, select **Reuse route** and choose the route. The stops and distance fill in automatically. 4. If more than one driving route is available, the map draws both routes. The selected route is highlighted in darker green with its distance shown in a dark label, and the alternate route appears in lighter green with a lighter distance label. To choose the alternate route, tap it on the map — it becomes the selected route, and the distance and amount update to match. 5. Select **Next**. 6. On the confirmation screen, review and confirm: diff --git a/docs/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify.md b/docs/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify.md index 7e54625df02b..b08dfee40b07 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify.md +++ b/docs/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify.md @@ -145,7 +145,7 @@ To view saved searches: Saved searches are listed in alphabetical order by name. -To update a saved search, change its filters while viewing it, click **Save**, then select **Update existing**. To keep the original and save your changes as a new search, select **Create new** instead. You can rename, share, or delete a saved search by selecting the three dots **(⋮)** next to it. Selecting **Share** copies the search URL to your clipboard. +To rename a saved search, click **Save** while viewing it and enter a new name. You can also rename, share, or delete a saved search by selecting the three dots **(⋮)** next to it. Selecting **Share** copies the search URL to your clipboard. ## Why you have a My expenses saved search diff --git a/docs/articles/new-expensify/workspaces/Configure-Per-Diem-in-a-workspace.md b/docs/articles/new-expensify/workspaces/Configure-Per-Diem-in-a-workspace.md index 7c7fc9610458..3f0105690157 100644 --- a/docs/articles/new-expensify/workspaces/Configure-Per-Diem-in-a-workspace.md +++ b/docs/articles/new-expensify/workspaces/Configure-Per-Diem-in-a-workspace.md @@ -54,7 +54,6 @@ Each rate is listed as a line item. You can: - Select one or more rates to edit or delete. - Click a rate to adjust its details. -- On the web, hover over a rate's **Destination**, **Subrate**, or **Amount** and click the pencil icon that appears to edit it inline. - Use the **X selected** dropdown to apply bulk actions like delete. ![Edit rates using the right-hand panel]({{site.url}}/assets/images/perdiem_03.png){:width="100%"} diff --git a/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md b/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md index aa9a38957be4..5a1eaa4943dc 100644 --- a/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md +++ b/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md @@ -92,8 +92,6 @@ If someone no longer needs access to your workspace, you can remove them individ 3. In the right-hand panel, click **Roles**. 4. Select the new role and confirm. -On the web, you can also change a member’s role directly from the members list. Hover over the member’s **Role** and click the pencil icon that appears to select a new role. - **Note:** People admins can change a member’s role between **Member** and **Auditor** only. Granting or removing the admin role or any scoped admin role (such as Card admin, People admin, or Payments admin) requires a Workspace admin. **Note:** You can’t change the role of the member set as the workspace’s authorized payer. Their **Role** can’t be edited and role-change options are hidden until you assign a different payer. To change their role, first go to **Workspaces > Workflows**, open **Payments**, and set a different admin as the **Payer**. diff --git a/docs/articles/new-expensify/workspaces/Set-distance-rates.md b/docs/articles/new-expensify/workspaces/Set-distance-rates.md index c58d7b8a7ede..bcead478f396 100644 --- a/docs/articles/new-expensify/workspaces/Set-distance-rates.md +++ b/docs/articles/new-expensify/workspaces/Set-distance-rates.md @@ -71,8 +71,6 @@ Once **Track tax** is enabled, you can assign a tax rate and tax reclaimable amo - **Tax reclaimable** (if enabled) 5. Select **Save**. -On the web, you can also edit a rate's **Name** or **Rate** directly from the table. Hover over the value and click the pencil icon that appears to edit it inline. - --- ## How to disable or delete distance rates diff --git a/ios/pod-usage.json b/ios/pod-usage.json deleted file mode 100644 index e49c2d884c52..000000000000 --- a/ios/pod-usage.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "AirshipServiceExtension": { - "usage": "module", - "module": "AirshipServiceExtension" - }, - "AppLogs": { - "usage": "module", - "module": "AppLogs" - }, - "FullStory": { - "usage": "exempt", - "reason": "Pinned here to the 1.70.1 xcframework tarball; fullstory_react-native depends on FullStory (~> 1.14) and would otherwise resolve it itself.", - "reviewed": "2026-08-28" - }, - "GzipSwift": { - "usage": "module", - "module": "Gzip" - }, - "NWWebSocket": { - "usage": "exempt", - "reason": "Never imported. PusherSwift depends on NWWebSocket (~> 0.5.7); this declaration only holds the floor at ~> 0.5.10.", - "reviewed": "2026-08-28" - }, - "TrustKit": { - "usage": "module", - "module": "TrustKit" - } -} diff --git a/knip.json b/knip.json index 8fba9bd02c97..32c6af6ddd9d 100644 --- a/knip.json +++ b/knip.json @@ -68,9 +68,14 @@ } }, "ignoreDependencies": [ + "@expensify/react-native-hybrid-app", "focus-trap", + "group-ib-fp", + "react-native-image-size", + "react-native-picker-select", "react-native-quick-crypto", "react-native-quick-base64", + "lodash", "@babel/plugin-proposal-private-methods", "@babel/plugin-proposal-private-property-in-object", "@babel/plugin-transform-export-namespace-from", @@ -80,6 +85,7 @@ "html-entities", "@fullstory/babel-plugin-react-native", "@fullstory/babel-plugin-annotate-react", + "eslint-config-airbnb-typescript", "eslint-config-prettier", "eslint-plugin-storybook", "@dword-design/eslint-plugin-import-alias", diff --git a/package-lock.json b/package-lock.json index f656c125faa8..73782a1c2f57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,12 +17,14 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", + "@dotlottie/react-player": "^1.6.3", "@expensify/nitro-utils": "file:./modules/ExpensifyNitroUtils", "@expensify/react-native-background-task": "file:./modules/background-task", "@expensify/react-native-hybrid-app": "file:./modules/hybrid-app", "@expensify/react-native-live-markdown": "0.1.342", "@expensify/react-native-wallet": "0.1.22", "@expo/metro-config": "57.0.7", + "@expo/metro-runtime": "57.0.7", "@formatjs/intl-listformat": "^7.5.7", "@formatjs/intl-locale": "^4.0.0", "@formatjs/intl-numberformat": "^8.10.3", @@ -266,6 +268,7 @@ "jest-transformer-svg": "^2.0.1", "knip": "^6.34.0", "lefthook": "2.1.9", + "link": "^2.1.1", "memfs": "^4.6.0", "nitrogen": "0.37.1", "onchange": "^7.1.0", @@ -5109,6 +5112,44 @@ "react": ">=16.8.0" } }, + "node_modules/@dotlottie/common": { + "version": "0.7.11", + "license": "MIT", + "dependencies": { + "@dotlottie/dotlottie-js": "^0.7.0", + "@preact/signals-core": "^1.2.3", + "howler": "^2.2.3", + "lottie-web": "^5.12.2", + "xstate": "^4.38.1" + }, + "engines": { + "node": ">18.0.0" + } + }, + "node_modules/@dotlottie/dotlottie-js": { + "version": "0.7.2", + "license": "MIT", + "dependencies": { + "browser-image-hash": "^0.0.5", + "fflate": "^0.8.1", + "sharp": "^0.33.2", + "sharp-phash": "^2.1.0", + "valibot": "^0.13.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@dotlottie/react-player": { + "version": "1.6.19", + "license": "MIT", + "dependencies": { + "@dotlottie/common": "0.7.11" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/@dword-design/dedent": { "version": "0.7.0", "dev": true, @@ -5189,7 +5230,6 @@ "version": "1.11.2", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -7720,6 +7760,31 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/@expo/metro-runtime": { + "version": "57.0.7", + "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-57.0.7.tgz", + "integrity": "sha512-95UeoN/YsLellvskKsFGN9vKBwNc5k70ysO3skqfL3VusWlYIiYmPY+MpEWNz8A8W2CjLg9AKwZKAGrFj6znQg==", + "license": "MIT", + "dependencies": { + "@expo/log-box": "^57.0.1", + "anser": "^1.4.9", + "pretty-format": "^29.7.0", + "stacktrace-parser": "^0.1.10", + "whatwg-fetch": "^3.0.0" + }, + "peerDependencies": { + "@expo/log-box": "^57.0.1", + "expo": "*", + "react": "*", + "react-dom": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/@expo/osascript": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.7.1.tgz", @@ -10205,6 +10270,363 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@invertase/react-native-apple-authentication": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/@invertase/react-native-apple-authentication/-/react-native-apple-authentication-2.5.0.tgz", @@ -13925,6 +14347,14 @@ "dev": true, "license": "MIT" }, + "node_modules/@preact/signals-core": { + "version": "1.8.0", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "license": "BSD-3-Clause" @@ -14875,6 +15305,33 @@ "@types/react": "^18.0.0" } }, + "node_modules/@rgba-image/common": { + "version": "0.1.13", + "license": "MIT" + }, + "node_modules/@rgba-image/copy": { + "version": "0.1.3", + "license": "MIT", + "dependencies": { + "@rgba-image/common": "^0.1.13" + } + }, + "node_modules/@rgba-image/create-image": { + "version": "0.1.1", + "license": "MIT", + "dependencies": { + "@rgba-image/common": "^0.1.0" + } + }, + "node_modules/@rgba-image/lanczos": { + "version": "0.1.1", + "license": "MIT", + "dependencies": { + "@rgba-image/common": "^0.1.13", + "@rgba-image/copy": "^0.1.2", + "@rgba-image/create-image": "^0.1.1" + } + }, "node_modules/@rnmapbox/maps": { "version": "10.3.2", "resolved": "https://registry.npmjs.org/@rnmapbox/maps/-/maps-10.3.2.tgz", @@ -22472,6 +22929,15 @@ "integrity": "sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ==", "dev": true }, + "node_modules/browser-image-hash": { + "version": "0.0.5", + "license": "MIT", + "dependencies": { + "@rgba-image/lanczos": "^0.1.0", + "decimal.js": "^10.2.0", + "wasm-imagemagick": "^1.2.3" + } + }, "node_modules/browserslist": { "version": "4.28.7", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", @@ -24504,7 +24970,6 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, "license": "MIT" }, "node_modules/decode-uri-component": { @@ -27442,6 +27907,10 @@ "integrity": "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==", "license": "MIT" }, + "node_modules/fflate": { + "version": "0.8.2", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -33351,6 +33820,17 @@ "devOptional": true, "license": "MIT" }, + "node_modules/link": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "bin": { + "link": "dist/cli.js" + }, + "funding": { + "url": "https://github.com/privatenumber/link?sponsor=1" + } + }, "node_modules/loader-runner": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", @@ -33648,6 +34128,10 @@ } } }, + "node_modules/lottie-web": { + "version": "5.12.2", + "license": "MIT" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -39141,6 +39625,63 @@ "dev": true, "license": "MIT" }, + "node_modules/sharp": { + "version": "0.33.5", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/sharp-phash": { + "version": "2.2.0", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "sharp": ">= 0.32.0" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.7.1", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "license": "MIT", @@ -39692,7 +40233,6 @@ }, "node_modules/stack-generator": { "version": "2.0.10", - "dev": true, "license": "MIT", "dependencies": { "stackframe": "^1.3.4" @@ -39723,7 +40263,6 @@ }, "node_modules/stacktrace-gps": { "version": "3.1.2", - "dev": true, "license": "MIT", "dependencies": { "source-map": "0.5.6", @@ -39732,7 +40271,6 @@ }, "node_modules/stacktrace-gps/node_modules/source-map": { "version": "0.5.6", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -39740,7 +40278,6 @@ }, "node_modules/stacktrace-js": { "version": "2.0.2", - "dev": true, "license": "MIT", "dependencies": { "error-stack-parser": "^2.0.6", @@ -42153,6 +42690,10 @@ "node": ">=10.12.0" } }, + "node_modules/valibot": { + "version": "0.13.1", + "license": "MIT" + }, "node_modules/validate-npm-package-name": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", @@ -42260,6 +42801,21 @@ "loose-envify": "^1.0.0" } }, + "node_modules/wasm-imagemagick": { + "version": "1.2.8", + "license": "Apache-2.0", + "dependencies": { + "p-map": "^2.0.0", + "stacktrace-js": "^2.0.0" + } + }, + "node_modules/wasm-imagemagick/node_modules/p-map": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/watchpack": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", @@ -43202,6 +43758,14 @@ "dev": true, "license": "MIT" }, + "node_modules/xstate": { + "version": "4.38.3", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/xstate" + } + }, "node_modules/xtend": { "version": "4.0.2", "license": "MIT", diff --git a/package.json b/package.json index 9a8893604467..43fcb7cf541a 100644 --- a/package.json +++ b/package.json @@ -93,12 +93,14 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", + "@dotlottie/react-player": "^1.6.3", "@expensify/nitro-utils": "file:./modules/ExpensifyNitroUtils", "@expensify/react-native-background-task": "file:./modules/background-task", "@expensify/react-native-hybrid-app": "file:./modules/hybrid-app", "@expensify/react-native-live-markdown": "0.1.342", "@expensify/react-native-wallet": "0.1.22", "@expo/metro-config": "57.0.7", + "@expo/metro-runtime": "57.0.7", "@formatjs/intl-listformat": "^7.5.7", "@formatjs/intl-locale": "^4.0.0", "@formatjs/intl-numberformat": "^8.10.3", @@ -342,6 +344,7 @@ "jest-transformer-svg": "^2.0.1", "knip": "^6.34.0", "lefthook": "2.1.9", + "link": "^2.1.1", "memfs": "^4.6.0", "nitrogen": "0.37.1", "onchange": "^7.1.0", diff --git a/patches/link/details.md b/patches/link/details.md new file mode 100644 index 000000000000..158c430737ed --- /dev/null +++ b/patches/link/details.md @@ -0,0 +1,14 @@ +# `link` patches + +### [link+2.1.1+001+hot-load.patch](link+2.1.1+001+hot-load.patch) + +- Reason: + + ``` + This patch enables us to develop react-native-live-markdown from the TS source and have it hot-reload in E/App. + + ``` + +- Upstream PR/issue: 🛑, this is related to Expensify/react-native-live-markdown and it seems like a customization, so it makes sense there's no upstream issue. +- E/App issue: Same as the PR. +- PR introducing patch: https://github.com/Expensify/App/pull/47794 \ No newline at end of file diff --git a/patches/link/link+2.1.1+001+hot-load.patch b/patches/link/link+2.1.1+001+hot-load.patch new file mode 100644 index 000000000000..ae5abc87400a --- /dev/null +++ b/patches/link/link+2.1.1+001+hot-load.patch @@ -0,0 +1,4513 @@ +diff --git a/node_modules/link/README.md b/node_modules/link/README.md +index d7ff505..bc868c4 100644 +--- a/node_modules/link/README.md ++++ b/node_modules/link/README.md +@@ -1,4 +1,11 @@ +-# npx link ++

++ ++

++

++ npx link ++
++ ++

+ + A safer and enhanced version of [`npm link`](https://docs.npmjs.com/cli/v8/commands/npm-link). + +@@ -13,14 +20,10 @@ Why is `npm link` unsafe? Read the [blog post](https://hirok.io/posts/avoid-npm- +
+ +

+- +- +- +- +- Premium sponsor banner +- +- ++ ++ +

++

Already a sponsor? Join the discussion in the Development repo!

+ + ## Terminology + +@@ -120,6 +123,37 @@ _Publish mode_ helps replicate the production environment in your development se + + > **Note:** If the _Dependency package_ emits new files, you'll need to re-run `npx link publish ` to create new hard links. + ++#### Watch mode ++ ++To automatically re-link a package in publish mode whenever a change is made, use the `--watch` flag: ++ ++```sh ++npx link publish --watch ++``` ++ ++Hard links do not support directories, so files must be individually linked. _Watch mode_ addresses this limitation by automatically linking new files added to the _Dependency package_ so they appear in the _Consuming package_. ++ ++##### Watch mode caveats ++ ++A fundamental limitation of _watch mode_ is that it doesn't have a reliable way to know when the rebuild of your local _Dependency package_ is complete. ++ ++The **recommended** way to address this limitation is using the `--litmus` flag. This flag specifies a filepath relative to the _Dependency package_ that `link` can check to see if a build is completed. ++For example, you might create an empty file in your local dependency called `.build_complete`, and augment it's build system to delete that file at the start of builds, and re-create it at the end of builds. Then pass `npx link --watch --litmus '.build_complete'` ++ ++``` ++"build:watch": "nodemon --watch src --ext .ts,.tsx,.css --exec \"rm -f .build_complete && yarn prepare && yarn pack && touch .build_complete\"", ++``` ++ ++If for some reason you can't do that, you'll have to fall back on some heuristics: ++ ++| Flag | Description | Default | ++|------|------------------------------------------------------------------------------------------------------------------------------------------|---------| ++| `--delay` or `-d` | The amount of time (in ms) after a change is observed to refresh the packlist for your _Dependency package_ | 2000 | ++| `--interval` or `-i` | The amount of time (in ms) to poll the _Dependency package_ to see if all expected files are present (i.e: build is complete) | 500 | ++| `--maxBuildTime` or `-m` | The maximum amount of time (in ms) to poll the _Dependency package_ to see if all expected files are present (i.e: build is complete) | 30000 | ++ ++Note that there is an edge case with _watch mode_ that may not work as expected if you don't use the `--litmus` flag; if you intentionally delete a file in the _Dependency package_, you may have to wait `--maxBuildTime` before the links are refreshed. ++ + ### Configuration file + + Create a `link.config.json` (or `link.config.js`) configuration file at the root of the _Consuming package_ to automatically setup links to multiple _Dependency packages_. +@@ -166,18 +200,6 @@ Enable with the `--deep` flag or `deepLink` property in `link.config.json`. + npx link --deep + ``` + +-
+- +-

+- +- +- +- +- Premium sponsor banner +- +- +-

+- + ## FAQ + + ### Why should I use `npx link` over `npm link`? +@@ -213,23 +235,6 @@ $ npx link + + ## Sponsors + +-

+- +- +- +- +- Premium sponsor banner +- +- +- +- +- +- +- Premium sponsor banner +- +- +-

+- +

+ + +diff --git a/node_modules/link/dist/cli.js b/node_modules/link/dist/cli.js +index ae69549..5687188 100755 +--- a/node_modules/link/dist/cli.js ++++ b/node_modules/link/dist/cli.js +@@ -1,111 +1,4284 @@ + #!/usr/bin/env node +-"use strict";var O=require("fs/promises"),Ot=require("tty"),A=require("path"),fi=require("module"),ru=require("fs"),kt=require("events"),Fi=typeof document<"u"?document.currentScript:null;const jt="known-flag",xt="unknown-flag",Tt="argument",{stringify:ne}=JSON,Rt=/\B([A-Z])/g,Pt=u=>u.replace(Rt,"-$1").toLowerCase(),{hasOwnProperty:Lt}=Object.prototype,ie=(u,e)=>Lt.call(u,e),Mt=u=>Array.isArray(u),ou=u=>typeof u=="function"?[u,!1]:Mt(u)?[u[0],!0]:ou(u.type),Nt=(u,e)=>u===Boolean?e!=="false":e,It=(u,e)=>typeof e=="boolean"?e:u===Number&&e===""?Number.NaN:u(e),_t=/[\s.:=]/,Wt=u=>{const e=`Flag name ${ne(u)}`;if(u.length===0)throw new Error(`${e} cannot be empty`);if(u.length===1)throw new Error(`${e} must be longer than a character`);const t=u.match(_t);if(t)throw new Error(`${e} cannot contain ${ne(t?.[0])}`)},Gt=u=>{const e={},t=(D,n)=>{if(ie(e,D))throw new Error(`Duplicate flags named ${ne(D)}`);e[D]=n};for(const D in u){if(!ie(u,D))continue;Wt(D);const n=u[D],i=[[],...ou(n),n];t(D,i);const s=Pt(D);if(D!==s&&t(s,i),"alias"in n&&typeof n.alias=="string"){const{alias:o}=n,a=`Flag alias ${ne(o)} for flag ${ne(D)}`;if(o.length===0)throw new Error(`${a} cannot be empty`);if(o.length>1)throw new Error(`${a} must be a single character`);t(o,i)}}return e},qt=(u,e)=>{const t={};for(const D in u){if(!ie(u,D))continue;const[n,,i,s]=e[D];if(n.length===0&&"default"in s){let{default:o}=s;typeof o=="function"&&(o=o()),t[D]=o}else t[D]=i?n:n.pop()}return t},ge="--",zt=/[.:=]/,Jt=/^-{1,2}\w/,Vt=u=>{if(!Jt.test(u))return;const e=!u.startsWith(ge);let t=u.slice(e?1:2),D;const n=t.match(zt);if(n){const{index:i}=n;D=t.slice(i+1),t=t.slice(0,i)}return[t,D,e]},Ut=(u,{onFlag:e,onArgument:t})=>{let D;const n=(i,s)=>{if(typeof D!="function")return!0;D(i,s),D=void 0};for(let i=0;i{for(const[t,D,n]of e.reverse()){if(D){const i=u[t];let s=i.slice(0,D);if(n||(s+=i.slice(D+1)),s!=="-"){u[t]=s;continue}}u.splice(t,1)}},Ht=(u,e=process.argv.slice(2),{ignore:t}={})=>{const D=[],n=Gt(u),i={},s=[];return s[ge]=[],Ut(e,{onFlag(o,a,h){const f=ie(n,o);if(!t?.(f?jt:xt,o,a)){if(f){const[g,F]=n[o],d=Nt(F,a),m=(w,S)=>{D.push(h),S&&D.push(S),g.push(It(F,w||""))};return d===void 0?m:m(d)}ie(i,o)||(i[o]=[]),i[o].push(a===void 0?!0:a),D.push(h)}},onArgument(o,a,h){t?.(Tt,e[a[0]])||(s.push(...o),h?(s[ge]=o,e.splice(a[0])):D.push(a))}}),Zt(e,D),{flags:qt(u,n),unknownFlags:i,_:s}};var Xt=Object.create,me=Object.defineProperty,Yt=Object.defineProperties,Kt=Object.getOwnPropertyDescriptor,Qt=Object.getOwnPropertyDescriptors,eD=Object.getOwnPropertyNames,au=Object.getOwnPropertySymbols,uD=Object.getPrototypeOf,lu=Object.prototype.hasOwnProperty,tD=Object.prototype.propertyIsEnumerable,cu=(u,e,t)=>e in u?me(u,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):u[e]=t,Be=(u,e)=>{for(var t in e||(e={}))lu.call(e,t)&&cu(u,t,e[t]);if(au)for(var t of au(e))tD.call(e,t)&&cu(u,t,e[t]);return u},Re=(u,e)=>Yt(u,Qt(e)),DD=u=>me(u,"__esModule",{value:!0}),nD=(u,e)=>()=>(u&&(e=u(u=0)),e),iD=(u,e)=>()=>(e||u((e={exports:{}}).exports,e),e.exports),sD=(u,e,t,D)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of eD(e))!lu.call(u,n)&&n!=="default"&&me(u,n,{get:()=>e[n],enumerable:!(D=Kt(e,n))||D.enumerable});return u},rD=(u,e)=>sD(DD(me(u!=null?Xt(uD(u)):{},"default",{value:u,enumerable:!0})),u),x=nD(()=>{}),oD=iD((u,e)=>{x(),e.exports=function(){return/\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62(?:\uDB40\uDC77\uDB40\uDC6C\uDB40\uDC73|\uDB40\uDC73\uDB40\uDC63\uDB40\uDC74|\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67)\uDB40\uDC7F|(?:\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDD1D\u200D(?:\uD83D[\uDC68\uDC69]))(?:\uD83C[\uDFFB-\uDFFE])|(?:\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDD1D\u200D(?:\uD83D[\uDC68\uDC69]))(?:\uD83C[\uDFFB-\uDFFD\uDFFF])|(?:\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDD1D\u200D(?:\uD83D[\uDC68\uDC69]))(?:\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])|(?:\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDD1D\u200D(?:\uD83D[\uDC68\uDC69]))(?:\uD83C[\uDFFB\uDFFD-\uDFFF])|(?:\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDD1D\u200D(?:\uD83D[\uDC68\uDC69]))(?:\uD83C[\uDFFC-\uDFFF])|\uD83D\uDC68(?:\uD83C\uDFFB(?:\u200D(?:\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFF])|\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFF]))|\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFC-\uDFFF])|[\u2695\u2696\u2708]\uFE0F|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD]))?|(?:\uD83C[\uDFFC-\uDFFF])\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFF])|\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFF]))|\u200D(?:\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83D\uDC68|(?:\uD83D[\uDC68\uDC69])\u200D(?:\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67]))|\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67])|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFF\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFE])|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFE\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFD\uDFFF])|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFD\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFC\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB\uDFFD-\uDFFF])|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|(?:\uD83C\uDFFF\u200D[\u2695\u2696\u2708]|\uD83C\uDFFE\u200D[\u2695\u2696\u2708]|\uD83C\uDFFD\u200D[\u2695\u2696\u2708]|\uD83C\uDFFC\u200D[\u2695\u2696\u2708]|\u200D[\u2695\u2696\u2708])\uFE0F|\u200D(?:(?:\uD83D[\uDC68\uDC69])\u200D(?:\uD83D[\uDC66\uDC67])|\uD83D[\uDC66\uDC67])|\uD83C\uDFFF|\uD83C\uDFFE|\uD83C\uDFFD|\uD83C\uDFFC)?|(?:\uD83D\uDC69(?:\uD83C\uDFFB\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D(?:\uD83D[\uDC68\uDC69])|\uD83D[\uDC68\uDC69])|(?:\uD83C[\uDFFC-\uDFFF])\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D(?:\uD83D[\uDC68\uDC69])|\uD83D[\uDC68\uDC69]))|\uD83E\uDDD1(?:\uD83C[\uDFFB-\uDFFF])\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1)(?:\uD83C[\uDFFB-\uDFFF])|\uD83D\uDC69\u200D\uD83D\uDC69\u200D(?:\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67]))|\uD83D\uDC69(?:\u200D(?:\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D(?:\uD83D[\uDC68\uDC69])|\uD83D[\uDC68\uDC69])|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFF\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFE\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFD\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFC\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFB\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD]))|\uD83E\uDDD1(?:\u200D(?:\uD83E\uDD1D\u200D\uD83E\uDDD1|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFF\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFE\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFD\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFC\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFB\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD]))|\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC69\u200D\uD83D\uDC69\u200D(?:\uD83D[\uDC66\uDC67])|\uD83D\uDC69\u200D\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67])|(?:\uD83D\uDC41\uFE0F\u200D\uD83D\uDDE8|\uD83E\uDDD1(?:\uD83C\uDFFF\u200D[\u2695\u2696\u2708]|\uD83C\uDFFE\u200D[\u2695\u2696\u2708]|\uD83C\uDFFD\u200D[\u2695\u2696\u2708]|\uD83C\uDFFC\u200D[\u2695\u2696\u2708]|\uD83C\uDFFB\u200D[\u2695\u2696\u2708]|\u200D[\u2695\u2696\u2708])|\uD83D\uDC69(?:\uD83C\uDFFF\u200D[\u2695\u2696\u2708]|\uD83C\uDFFE\u200D[\u2695\u2696\u2708]|\uD83C\uDFFD\u200D[\u2695\u2696\u2708]|\uD83C\uDFFC\u200D[\u2695\u2696\u2708]|\uD83C\uDFFB\u200D[\u2695\u2696\u2708]|\u200D[\u2695\u2696\u2708])|\uD83D\uDE36\u200D\uD83C\uDF2B|\uD83C\uDFF3\uFE0F\u200D\u26A7|\uD83D\uDC3B\u200D\u2744|(?:(?:\uD83C[\uDFC3\uDFC4\uDFCA]|\uD83D[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6]|\uD83E[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD])(?:\uD83C[\uDFFB-\uDFFF])|\uD83D\uDC6F|\uD83E[\uDD3C\uDDDE\uDDDF])\u200D[\u2640\u2642]|(?:\u26F9|\uD83C[\uDFCB\uDFCC]|\uD83D\uDD75)(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])\u200D[\u2640\u2642]|\uD83C\uDFF4\u200D\u2620|(?:\uD83C[\uDFC3\uDFC4\uDFCA]|\uD83D[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6]|\uD83E[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD])\u200D[\u2640\u2642]|[\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u2328\u23CF\u23ED-\u23EF\u23F1\u23F2\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB\u25FC\u2600-\u2604\u260E\u2611\u2618\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u2692\u2694-\u2697\u2699\u269B\u269C\u26A0\u26A7\u26B0\u26B1\u26C8\u26CF\u26D1\u26D3\u26E9\u26F0\u26F1\u26F4\u26F7\u26F8\u2702\u2708\u2709\u270F\u2712\u2714\u2716\u271D\u2721\u2733\u2734\u2744\u2747\u2763\u27A1\u2934\u2935\u2B05-\u2B07\u3030\u303D\u3297\u3299]|\uD83C[\uDD70\uDD71\uDD7E\uDD7F\uDE02\uDE37\uDF21\uDF24-\uDF2C\uDF36\uDF7D\uDF96\uDF97\uDF99-\uDF9B\uDF9E\uDF9F\uDFCD\uDFCE\uDFD4-\uDFDF\uDFF5\uDFF7]|\uD83D[\uDC3F\uDCFD\uDD49\uDD4A\uDD6F\uDD70\uDD73\uDD76-\uDD79\uDD87\uDD8A-\uDD8D\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA\uDECB\uDECD-\uDECF\uDEE0-\uDEE5\uDEE9\uDEF0\uDEF3])\uFE0F|\uD83C\uDFF3\uFE0F\u200D\uD83C\uDF08|\uD83D\uDC69\u200D\uD83D\uDC67|\uD83D\uDC69\u200D\uD83D\uDC66|\uD83D\uDE35\u200D\uD83D\uDCAB|\uD83D\uDE2E\u200D\uD83D\uDCA8|\uD83D\uDC15\u200D\uD83E\uDDBA|\uD83E\uDDD1(?:\uD83C\uDFFF|\uD83C\uDFFE|\uD83C\uDFFD|\uD83C\uDFFC|\uD83C\uDFFB)?|\uD83D\uDC69(?:\uD83C\uDFFF|\uD83C\uDFFE|\uD83C\uDFFD|\uD83C\uDFFC|\uD83C\uDFFB)?|\uD83C\uDDFD\uD83C\uDDF0|\uD83C\uDDF6\uD83C\uDDE6|\uD83C\uDDF4\uD83C\uDDF2|\uD83D\uDC08\u200D\u2B1B|\u2764\uFE0F\u200D(?:\uD83D\uDD25|\uD83E\uDE79)|\uD83D\uDC41\uFE0F|\uD83C\uDFF3\uFE0F|\uD83C\uDDFF(?:\uD83C[\uDDE6\uDDF2\uDDFC])|\uD83C\uDDFE(?:\uD83C[\uDDEA\uDDF9])|\uD83C\uDDFC(?:\uD83C[\uDDEB\uDDF8])|\uD83C\uDDFB(?:\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDEE\uDDF3\uDDFA])|\uD83C\uDDFA(?:\uD83C[\uDDE6\uDDEC\uDDF2\uDDF3\uDDF8\uDDFE\uDDFF])|\uD83C\uDDF9(?:\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDED\uDDEF-\uDDF4\uDDF7\uDDF9\uDDFB\uDDFC\uDDFF])|\uD83C\uDDF8(?:\uD83C[\uDDE6-\uDDEA\uDDEC-\uDDF4\uDDF7-\uDDF9\uDDFB\uDDFD-\uDDFF])|\uD83C\uDDF7(?:\uD83C[\uDDEA\uDDF4\uDDF8\uDDFA\uDDFC])|\uD83C\uDDF5(?:\uD83C[\uDDE6\uDDEA-\uDDED\uDDF0-\uDDF3\uDDF7-\uDDF9\uDDFC\uDDFE])|\uD83C\uDDF3(?:\uD83C[\uDDE6\uDDE8\uDDEA-\uDDEC\uDDEE\uDDF1\uDDF4\uDDF5\uDDF7\uDDFA\uDDFF])|\uD83C\uDDF2(?:\uD83C[\uDDE6\uDDE8-\uDDED\uDDF0-\uDDFF])|\uD83C\uDDF1(?:\uD83C[\uDDE6-\uDDE8\uDDEE\uDDF0\uDDF7-\uDDFB\uDDFE])|\uD83C\uDDF0(?:\uD83C[\uDDEA\uDDEC-\uDDEE\uDDF2\uDDF3\uDDF5\uDDF7\uDDFC\uDDFE\uDDFF])|\uD83C\uDDEF(?:\uD83C[\uDDEA\uDDF2\uDDF4\uDDF5])|\uD83C\uDDEE(?:\uD83C[\uDDE8-\uDDEA\uDDF1-\uDDF4\uDDF6-\uDDF9])|\uD83C\uDDED(?:\uD83C[\uDDF0\uDDF2\uDDF3\uDDF7\uDDF9\uDDFA])|\uD83C\uDDEC(?:\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEE\uDDF1-\uDDF3\uDDF5-\uDDFA\uDDFC\uDDFE])|\uD83C\uDDEB(?:\uD83C[\uDDEE-\uDDF0\uDDF2\uDDF4\uDDF7])|\uD83C\uDDEA(?:\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDED\uDDF7-\uDDFA])|\uD83C\uDDE9(?:\uD83C[\uDDEA\uDDEC\uDDEF\uDDF0\uDDF2\uDDF4\uDDFF])|\uD83C\uDDE8(?:\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDEE\uDDF0-\uDDF5\uDDF7\uDDFA-\uDDFF])|\uD83C\uDDE7(?:\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEF\uDDF1-\uDDF4\uDDF6-\uDDF9\uDDFB\uDDFC\uDDFE\uDDFF])|\uD83C\uDDE6(?:\uD83C[\uDDE8-\uDDEC\uDDEE\uDDF1\uDDF2\uDDF4\uDDF6-\uDDFA\uDDFC\uDDFD\uDDFF])|[#\*0-9]\uFE0F\u20E3|\u2764\uFE0F|(?:\uD83C[\uDFC3\uDFC4\uDFCA]|\uD83D[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6]|\uD83E[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD])(?:\uD83C[\uDFFB-\uDFFF])|(?:\u26F9|\uD83C[\uDFCB\uDFCC]|\uD83D\uDD75)(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])|\uD83C\uDFF4|(?:[\u270A\u270B]|\uD83C[\uDF85\uDFC2\uDFC7]|\uD83D[\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDC8F\uDC91\uDCAA\uDD7A\uDD95\uDD96\uDE4C\uDE4F\uDEC0\uDECC]|\uD83E[\uDD0C\uDD0F\uDD18-\uDD1C\uDD1E\uDD1F\uDD30-\uDD34\uDD36\uDD77\uDDB5\uDDB6\uDDBB\uDDD2\uDDD3\uDDD5])(?:\uD83C[\uDFFB-\uDFFF])|(?:[\u261D\u270C\u270D]|\uD83D[\uDD74\uDD90])(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])|[\u270A\u270B]|\uD83C[\uDF85\uDFC2\uDFC7]|\uD83D[\uDC08\uDC15\uDC3B\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDC8F\uDC91\uDCAA\uDD7A\uDD95\uDD96\uDE2E\uDE35\uDE36\uDE4C\uDE4F\uDEC0\uDECC]|\uD83E[\uDD0C\uDD0F\uDD18-\uDD1C\uDD1E\uDD1F\uDD30-\uDD34\uDD36\uDD77\uDDB5\uDDB6\uDDBB\uDDD2\uDDD3\uDDD5]|\uD83C[\uDFC3\uDFC4\uDFCA]|\uD83D[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6]|\uD83E[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD]|\uD83D\uDC6F|\uD83E[\uDD3C\uDDDE\uDDDF]|[\u231A\u231B\u23E9-\u23EC\u23F0\u23F3\u25FD\u25FE\u2614\u2615\u2648-\u2653\u267F\u2693\u26A1\u26AA\u26AB\u26BD\u26BE\u26C4\u26C5\u26CE\u26D4\u26EA\u26F2\u26F3\u26F5\u26FA\u26FD\u2705\u2728\u274C\u274E\u2753-\u2755\u2757\u2795-\u2797\u27B0\u27BF\u2B1B\u2B1C\u2B50\u2B55]|\uD83C[\uDC04\uDCCF\uDD8E\uDD91-\uDD9A\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF84\uDF86-\uDF93\uDFA0-\uDFC1\uDFC5\uDFC6\uDFC8\uDFC9\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF8-\uDFFF]|\uD83D[\uDC00-\uDC07\uDC09-\uDC14\uDC16-\uDC3A\uDC3C-\uDC3E\uDC40\uDC44\uDC45\uDC51-\uDC65\uDC6A\uDC79-\uDC7B\uDC7D-\uDC80\uDC84\uDC88-\uDC8E\uDC90\uDC92-\uDCA9\uDCAB-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDDA4\uDDFB-\uDE2D\uDE2F-\uDE34\uDE37-\uDE44\uDE48-\uDE4A\uDE80-\uDEA2\uDEA4-\uDEB3\uDEB7-\uDEBF\uDEC1-\uDEC5\uDED0-\uDED2\uDED5-\uDED7\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB]|\uD83E[\uDD0D\uDD0E\uDD10-\uDD17\uDD1D\uDD20-\uDD25\uDD27-\uDD2F\uDD3A\uDD3F-\uDD45\uDD47-\uDD76\uDD78\uDD7A-\uDDB4\uDDB7\uDDBA\uDDBC-\uDDCB\uDDD0\uDDE0-\uDDFF\uDE70-\uDE74\uDE78-\uDE7A\uDE80-\uDE86\uDE90-\uDEA8\uDEB0-\uDEB6\uDEC0-\uDEC2\uDED0-\uDED6]|(?:[\u231A\u231B\u23E9-\u23EC\u23F0\u23F3\u25FD\u25FE\u2614\u2615\u2648-\u2653\u267F\u2693\u26A1\u26AA\u26AB\u26BD\u26BE\u26C4\u26C5\u26CE\u26D4\u26EA\u26F2\u26F3\u26F5\u26FA\u26FD\u2705\u270A\u270B\u2728\u274C\u274E\u2753-\u2755\u2757\u2795-\u2797\u27B0\u27BF\u2B1B\u2B1C\u2B50\u2B55]|\uD83C[\uDC04\uDCCF\uDD8E\uDD91-\uDD9A\uDDE6-\uDDFF\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF93\uDFA0-\uDFCA\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF4\uDFF8-\uDFFF]|\uD83D[\uDC00-\uDC3E\uDC40\uDC42-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDD7A\uDD95\uDD96\uDDA4\uDDFB-\uDE4F\uDE80-\uDEC5\uDECC\uDED0-\uDED2\uDED5-\uDED7\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB]|\uD83E[\uDD0C-\uDD3A\uDD3C-\uDD45\uDD47-\uDD78\uDD7A-\uDDCB\uDDCD-\uDDFF\uDE70-\uDE74\uDE78-\uDE7A\uDE80-\uDE86\uDE90-\uDEA8\uDEB0-\uDEB6\uDEC0-\uDEC2\uDED0-\uDED6])|(?:[#\*0-9\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u231A\u231B\u2328\u23CF\u23E9-\u23F3\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB-\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u261D\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692-\u2697\u2699\u269B\u269C\u26A0\u26A1\u26A7\u26AA\u26AB\u26B0\u26B1\u26BD\u26BE\u26C4\u26C5\u26C8\u26CE\u26CF\u26D1\u26D3\u26D4\u26E9\u26EA\u26F0-\u26F5\u26F7-\u26FA\u26FD\u2702\u2705\u2708-\u270D\u270F\u2712\u2714\u2716\u271D\u2721\u2728\u2733\u2734\u2744\u2747\u274C\u274E\u2753-\u2755\u2757\u2763\u2764\u2795-\u2797\u27A1\u27B0\u27BF\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B50\u2B55\u3030\u303D\u3297\u3299]|\uD83C[\uDC04\uDCCF\uDD70\uDD71\uDD7E\uDD7F\uDD8E\uDD91-\uDD9A\uDDE6-\uDDFF\uDE01\uDE02\uDE1A\uDE2F\uDE32-\uDE3A\uDE50\uDE51\uDF00-\uDF21\uDF24-\uDF93\uDF96\uDF97\uDF99-\uDF9B\uDF9E-\uDFF0\uDFF3-\uDFF5\uDFF7-\uDFFF]|\uD83D[\uDC00-\uDCFD\uDCFF-\uDD3D\uDD49-\uDD4E\uDD50-\uDD67\uDD6F\uDD70\uDD73-\uDD7A\uDD87\uDD8A-\uDD8D\uDD90\uDD95\uDD96\uDDA4\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA-\uDE4F\uDE80-\uDEC5\uDECB-\uDED2\uDED5-\uDED7\uDEE0-\uDEE5\uDEE9\uDEEB\uDEEC\uDEF0\uDEF3-\uDEFC\uDFE0-\uDFEB]|\uD83E[\uDD0C-\uDD3A\uDD3C-\uDD45\uDD47-\uDD78\uDD7A-\uDDCB\uDDCD-\uDDFF\uDE70-\uDE74\uDE78-\uDE7A\uDE80-\uDE86\uDE90-\uDEA8\uDEB0-\uDEB6\uDEC0-\uDEC2\uDED0-\uDED6])\uFE0F|(?:[\u261D\u26F9\u270A-\u270D]|\uD83C[\uDF85\uDFC2-\uDFC4\uDFC7\uDFCA-\uDFCC]|\uD83D[\uDC42\uDC43\uDC46-\uDC50\uDC66-\uDC78\uDC7C\uDC81-\uDC83\uDC85-\uDC87\uDC8F\uDC91\uDCAA\uDD74\uDD75\uDD7A\uDD90\uDD95\uDD96\uDE45-\uDE47\uDE4B-\uDE4F\uDEA3\uDEB4-\uDEB6\uDEC0\uDECC]|\uD83E[\uDD0C\uDD0F\uDD18-\uDD1F\uDD26\uDD30-\uDD39\uDD3C-\uDD3E\uDD77\uDDB5\uDDB6\uDDB8\uDDB9\uDDBB\uDDCD-\uDDCF\uDDD1-\uDDDD])/g}});x(),x(),x();var aD=u=>{var e,t,D;let n=(e=process.stdout.columns)!=null?e:Number.POSITIVE_INFINITY;return typeof u=="function"&&(u=u(n)),u||(u={}),Array.isArray(u)?{columns:u,stdoutColumns:n}:{columns:(t=u.columns)!=null?t:[],stdoutColumns:(D=u.stdoutColumns)!=null?D:n}};x(),x(),x(),x(),x();function lD({onlyFirst:u=!1}={}){let e=["[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)","(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))"].join("|");return new RegExp(e,u?void 0:"g")}function hu(u){if(typeof u!="string")throw new TypeError(`Expected a \`string\`, got \`${typeof u}\``);return u.replace(lD(),"")}x();function cD(u){return Number.isInteger(u)?u>=4352&&(u<=4447||u===9001||u===9002||11904<=u&&u<=12871&&u!==12351||12880<=u&&u<=19903||19968<=u&&u<=42182||43360<=u&&u<=43388||44032<=u&&u<=55203||63744<=u&&u<=64255||65040<=u&&u<=65049||65072<=u&&u<=65131||65281<=u&&u<=65376||65504<=u&&u<=65510||110592<=u&&u<=110593||127488<=u&&u<=127569||131072<=u&&u<=262141):!1}var hD=rD(oD());function V(u){if(typeof u!="string"||u.length===0||(u=hu(u),u.length===0))return 0;u=u.replace((0,hD.default)()," ");let e=0;for(let t=0;t=127&&D<=159||D>=768&&D<=879||(D>65535&&t++,e+=cD(D)?2:1)}return e}var pu=u=>Math.max(...u.split(` +-`).map(V)),pD=u=>{let e=[];for(let t of u){let{length:D}=t,n=D-e.length;for(let i=0;ie[i]&&(e[i]=s)}}return e};x();var fu=/^\d+%$/,Fu={width:"auto",align:"left",contentWidth:0,paddingLeft:0,paddingRight:0,paddingTop:0,paddingBottom:0,horizontalPadding:0,paddingLeftString:"",paddingRightString:""},fD=(u,e)=>{var t;let D=[];for(let n=0;n=e){let a=s-e,h=Math.ceil(t.paddingLeft/n*a),f=a-h;t.paddingLeft-=h,t.paddingRight-=f,t.horizontalPadding=t.paddingLeft+t.paddingRight}t.paddingLeftString=t.paddingLeft?" ".repeat(t.paddingLeft):"",t.paddingRightString=t.paddingRight?" ".repeat(t.paddingRight):"";let o=e-t.horizontalPadding;t.width=Math.max(Math.min(t.width,o),i)}}var du=()=>Object.assign([],{columns:0});function dD(u,e){let t=[du()],[D]=t;for(let n of u){let i=n.width+n.horizontalPadding;D.columns+i>e&&(D=du(),t.push(D)),D.push(n),D.columns+=i}for(let n of t){let i=n.reduce((F,d)=>F+d.width+d.horizontalPadding,0),s=e-i;if(s===0)continue;let o=n.filter(F=>"autoOverflow"in F),a=o.filter(F=>F.autoOverflow>0),h=a.reduce((F,d)=>F+d.autoOverflow,0),f=Math.min(h,s);for(let F of a){let d=Math.floor(F.autoOverflow/h*f);F.width+=d,s-=d}let g=Math.floor(s/o.length);for(let F=0;Fe=>`\x1B[${e+u}m`,Eu=(u=0)=>e=>`\x1B[${38+u};5;${e}m`,gu=(u=0)=>(e,t,D)=>`\x1B[${38+u};2;${e};${t};${D}m`;function ED(){let u=new Map,e={modifier:{reset:[0,0],bold:[1,22],dim:[2,22],italic:[3,23],underline:[4,24],overline:[53,55],inverse:[7,27],hidden:[8,28],strikethrough:[9,29]},color:{black:[30,39],red:[31,39],green:[32,39],yellow:[33,39],blue:[34,39],magenta:[35,39],cyan:[36,39],white:[37,39],blackBright:[90,39],redBright:[91,39],greenBright:[92,39],yellowBright:[93,39],blueBright:[94,39],magentaBright:[95,39],cyanBright:[96,39],whiteBright:[97,39]},bgColor:{bgBlack:[40,49],bgRed:[41,49],bgGreen:[42,49],bgYellow:[43,49],bgBlue:[44,49],bgMagenta:[45,49],bgCyan:[46,49],bgWhite:[47,49],bgBlackBright:[100,49],bgRedBright:[101,49],bgGreenBright:[102,49],bgYellowBright:[103,49],bgBlueBright:[104,49],bgMagentaBright:[105,49],bgCyanBright:[106,49],bgWhiteBright:[107,49]}};e.color.gray=e.color.blackBright,e.bgColor.bgGray=e.bgColor.bgBlackBright,e.color.grey=e.color.blackBright,e.bgColor.bgGrey=e.bgColor.bgBlackBright;for(let[t,D]of Object.entries(e)){for(let[n,i]of Object.entries(D))e[n]={open:`\x1B[${i[0]}m`,close:`\x1B[${i[1]}m`},D[n]=e[n],u.set(i[0],i[1]);Object.defineProperty(e,t,{value:D,enumerable:!1})}return Object.defineProperty(e,"codes",{value:u,enumerable:!1}),e.color.close="\x1B[39m",e.bgColor.close="\x1B[49m",e.color.ansi=Cu(),e.color.ansi256=Eu(),e.color.ansi16m=gu(),e.bgColor.ansi=Cu(Pe),e.bgColor.ansi256=Eu(Pe),e.bgColor.ansi16m=gu(Pe),Object.defineProperties(e,{rgbToAnsi256:{value:(t,D,n)=>t===D&&D===n?t<8?16:t>248?231:Math.round((t-8)/247*24)+232:16+36*Math.round(t/255*5)+6*Math.round(D/255*5)+Math.round(n/255*5),enumerable:!1},hexToRgb:{value:t=>{let D=/(?[a-f\d]{6}|[a-f\d]{3})/i.exec(t.toString(16));if(!D)return[0,0,0];let{colorString:n}=D.groups;n.length===3&&(n=n.split("").map(s=>s+s).join(""));let i=Number.parseInt(n,16);return[i>>16&255,i>>8&255,i&255]},enumerable:!1},hexToAnsi256:{value:t=>e.rgbToAnsi256(...e.hexToRgb(t)),enumerable:!1},ansi256ToAnsi:{value:t=>{if(t<8)return 30+t;if(t<16)return 90+(t-8);let D,n,i;if(t>=232)D=((t-232)*10+8)/255,n=D,i=D;else{t-=16;let a=t%36;D=Math.floor(t/36)/5,n=Math.floor(a/6)/5,i=a%6/5}let s=Math.max(D,n,i)*2;if(s===0)return 30;let o=30+(Math.round(i)<<2|Math.round(n)<<1|Math.round(D));return s===2&&(o+=60),o},enumerable:!1},rgbToAnsi:{value:(t,D,n)=>e.ansi256ToAnsi(e.rgbToAnsi256(t,D,n)),enumerable:!1},hexToAnsi:{value:t=>e.ansi256ToAnsi(e.hexToAnsi256(t)),enumerable:!1}}),e}var gD=ED(),mD=gD,we=new Set(["\x1B","\x9B"]),BD=39,Le="\x07",mu="[",wD="]",Bu="m",Me=`${wD}8;;`,wu=u=>`${we.values().next().value}${mu}${u}${Bu}`,yu=u=>`${we.values().next().value}${Me}${u}${Le}`,yD=u=>u.split(" ").map(e=>V(e)),Ne=(u,e,t)=>{let D=[...e],n=!1,i=!1,s=V(hu(u[u.length-1]));for(let[o,a]of D.entries()){let h=V(a);if(s+h<=t?u[u.length-1]+=a:(u.push(a),s=0),we.has(a)&&(n=!0,i=D.slice(o+1).join("").startsWith(Me)),n){i?a===Le&&(n=!1,i=!1):a===Bu&&(n=!1);continue}s+=h,s===t&&o0&&u.length>1&&(u[u.length-2]+=u.pop())},$D=u=>{let e=u.split(" "),t=e.length;for(;t>0&&!(V(e[t-1])>0);)t--;return t===e.length?u:e.slice(0,t).join(" ")+e.slice(t).join("")},AD=(u,e,t={})=>{if(t.trim!==!1&&u.trim()==="")return"";let D="",n,i,s=yD(u),o=[""];for(let[h,f]of u.split(" ").entries()){t.trim!==!1&&(o[o.length-1]=o[o.length-1].trimStart());let g=V(o[o.length-1]);if(h!==0&&(g>=e&&(t.wordWrap===!1||t.trim===!1)&&(o.push(""),g=0),(g>0||t.trim===!1)&&(o[o.length-1]+=" ",g++)),t.hard&&s[h]>e){let F=e-g,d=1+Math.floor((s[h]-F-1)/e);Math.floor((s[h]-1)/e)e&&g>0&&s[h]>0){if(t.wordWrap===!1&&ge&&t.wordWrap===!1){Ne(o,f,e);continue}o[o.length-1]+=f}t.trim!==!1&&(o=o.map(h=>$D(h)));let a=[...o.join(` +-`)];for(let[h,f]of a.entries()){if(D+=f,we.has(f)){let{groups:F}=new RegExp(`(?:\\${mu}(?\\d+)m|\\${Me}(?.*)${Le})`).exec(a.slice(h).join(""))||{groups:{}};if(F.code!==void 0){let d=Number.parseFloat(F.code);n=d===BD?void 0:d}else F.uri!==void 0&&(i=F.uri.length===0?void 0:F.uri)}let g=mD.codes.get(Number(n));a[h+1]===` +-`?(i&&(D+=yu("")),n&&g&&(D+=wu(g))):f===` +-`&&(n&&g&&(D+=wu(n)),i&&(D+=yu(i)))}return D};function bD(u,e,t){return String(u).normalize().replace(/\r\n/g,` ++'use strict'; ++ ++var fs$1 = require('fs/promises'); ++var N$1 = require('tty'); ++var path$1 = require('path'); ++var module$1 = require('module'); ++var path$2 = require('node:path'); ++var fs$2 = require('node:fs/promises'); ++var require$$0 = require('fs'); ++var require$$2 = require('events'); ++ ++var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null; ++const V$2="known-flag",k$2="unknown-flag",C$1="argument",{stringify:h}=JSON,O$1=/\B([A-Z])/g,v$1=t=>t.replace(O$1,"-$1").toLowerCase(),{hasOwnProperty:D$1}=Object.prototype,w$2=(t,n)=>D$1.call(t,n),L$2=t=>Array.isArray(t),b$2=t=>typeof t=="function"?[t,!1]:L$2(t)?[t[0],!0]:b$2(t.type),d$2=(t,n)=>t===Boolean?n!=="false":n,m$1=(t,n)=>typeof n=="boolean"?n:t===Number&&n===""?Number.NaN:t(n),R$2=/[\s.:=]/,B=t=>{const n=`Flag name ${h(t)}`;if(t.length===0)throw new Error(`${n} cannot be empty`);if(t.length===1)throw new Error(`${n} must be longer than a character`);const r=t.match(R$2);if(r)throw new Error(`${n} cannot contain ${h(r?.[0])}`)},K$1=t=>{const n={},r=(e,o)=>{if(w$2(n,e))throw new Error(`Duplicate flags named ${h(e)}`);n[e]=o;};for(const e in t){if(!w$2(t,e))continue;B(e);const o=t[e],s=[[],...b$2(o),o];r(e,s);const i=v$1(e);if(e!==i&&r(i,s),"alias"in o&&typeof o.alias=="string"){const{alias:a}=o,l=`Flag alias ${h(a)} for flag ${h(e)}`;if(a.length===0)throw new Error(`${l} cannot be empty`);if(a.length>1)throw new Error(`${l} must be a single character`);r(a,s);}}return n},_$2=(t,n)=>{const r={};for(const e in t){if(!w$2(t,e))continue;const[o,,s,i]=n[e];if(o.length===0&&"default"in i){let{default:a}=i;typeof a=="function"&&(a=a()),r[e]=a;}else r[e]=s?o:o.pop();}return r},F$1="--",G$2=/[.:=]/,T$2=/^-{1,2}\w/,N=t=>{if(!T$2.test(t))return;const n=!t.startsWith(F$1);let r=t.slice(n?1:2),e;const o=r.match(G$2);if(o){const{index:s}=o;e=r.slice(s+1),r=r.slice(0,s);}return [r,e,n]},$$1=(t,{onFlag:n,onArgument:r})=>{let e;const o=(s,i)=>{if(typeof e!="function")return !0;e(s,i),e=void 0;};for(let s=0;s{for(const[r,e,o]of n.reverse()){if(e){const s=t[r];let i=s.slice(0,e);if(o||(i+=s.slice(e+1)),i!=="-"){t[r]=i;continue}}t.splice(r,1);}},U$2=(t,n=process.argv.slice(2),{ignore:r}={})=>{const e=[],o=K$1(t),s={},i=[];return i[F$1]=[],$$1(n,{onFlag(a,l,f){const g=w$2(o,a);if(!r?.(g?V$2:k$2,a,l)){if(g){const[c,u]=o[a],y=d$2(u,l),p=(P,A)=>{e.push(f),A&&e.push(A),c.push(m$1(u,P||""));};return y===void 0?p:p(y)}w$2(s,a)||(s[a]=[]),s[a].push(l===void 0?!0:l),e.push(f);}},onArgument(a,l,f){r?.(C$1,n[l[0]])||(i.push(...a),f?(i[F$1]=a,n.splice(l[0])):e.push(l));}}),E(n,e),{flags:_$2(t,o),unknownFlags:s,_:i}}; ++ ++var DD=Object.create;var m=Object.defineProperty,uD=Object.defineProperties,FD=Object.getOwnPropertyDescriptor,CD=Object.getOwnPropertyDescriptors,tD=Object.getOwnPropertyNames,I$1=Object.getOwnPropertySymbols,ED=Object.getPrototypeOf,L$1=Object.prototype.hasOwnProperty,eD=Object.prototype.propertyIsEnumerable;var W$1=(D,F,u)=>F in D?m(D,F,{enumerable:!0,configurable:!0,writable:!0,value:u}):D[F]=u,p=(D,F)=>{for(var u in F||(F={}))L$1.call(F,u)&&W$1(D,u,F[u]);if(I$1)for(var u of I$1(F))eD.call(F,u)&&W$1(D,u,F[u]);return D},c=(D,F)=>uD(D,CD(F)),nD=D=>m(D,"__esModule",{value:!0});var rD=(D,F)=>()=>(D&&(F=D(D=0)),F);var iD=(D,F)=>()=>(F||D((F={exports:{}}).exports,F),F.exports);var oD=(D,F,u,C)=>{if(F&&typeof F=="object"||typeof F=="function")for(let t of tD(F))!L$1.call(D,t)&&(t!=="default")&&m(D,t,{get:()=>F[t],enumerable:!(C=FD(F,t))||C.enumerable});return D},BD=(D,F)=>oD(nD(m(D!=null?DD(ED(D)):{},"default",{value:D,enumerable:!0})),D);var i=rD(()=>{});var $=iD((LD,N)=>{i();N.exports=function(){return /\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62(?:\uDB40\uDC77\uDB40\uDC6C\uDB40\uDC73|\uDB40\uDC73\uDB40\uDC63\uDB40\uDC74|\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67)\uDB40\uDC7F|(?:\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDD1D\u200D(?:\uD83D[\uDC68\uDC69]))(?:\uD83C[\uDFFB-\uDFFE])|(?:\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDD1D\u200D(?:\uD83D[\uDC68\uDC69]))(?:\uD83C[\uDFFB-\uDFFD\uDFFF])|(?:\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDD1D\u200D(?:\uD83D[\uDC68\uDC69]))(?:\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])|(?:\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDD1D\u200D(?:\uD83D[\uDC68\uDC69]))(?:\uD83C[\uDFFB\uDFFD-\uDFFF])|(?:\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDD1D\u200D(?:\uD83D[\uDC68\uDC69]))(?:\uD83C[\uDFFC-\uDFFF])|\uD83D\uDC68(?:\uD83C\uDFFB(?:\u200D(?:\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFF])|\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFF]))|\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFC-\uDFFF])|[\u2695\u2696\u2708]\uFE0F|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD]))?|(?:\uD83C[\uDFFC-\uDFFF])\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFF])|\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFF]))|\u200D(?:\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83D\uDC68|(?:\uD83D[\uDC68\uDC69])\u200D(?:\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67]))|\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67])|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFF\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFE])|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFE\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFD\uDFFF])|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFD\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFC\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB\uDFFD-\uDFFF])|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|(?:\uD83C\uDFFF\u200D[\u2695\u2696\u2708]|\uD83C\uDFFE\u200D[\u2695\u2696\u2708]|\uD83C\uDFFD\u200D[\u2695\u2696\u2708]|\uD83C\uDFFC\u200D[\u2695\u2696\u2708]|\u200D[\u2695\u2696\u2708])\uFE0F|\u200D(?:(?:\uD83D[\uDC68\uDC69])\u200D(?:\uD83D[\uDC66\uDC67])|\uD83D[\uDC66\uDC67])|\uD83C\uDFFF|\uD83C\uDFFE|\uD83C\uDFFD|\uD83C\uDFFC)?|(?:\uD83D\uDC69(?:\uD83C\uDFFB\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D(?:\uD83D[\uDC68\uDC69])|\uD83D[\uDC68\uDC69])|(?:\uD83C[\uDFFC-\uDFFF])\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D(?:\uD83D[\uDC68\uDC69])|\uD83D[\uDC68\uDC69]))|\uD83E\uDDD1(?:\uD83C[\uDFFB-\uDFFF])\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1)(?:\uD83C[\uDFFB-\uDFFF])|\uD83D\uDC69\u200D\uD83D\uDC69\u200D(?:\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67]))|\uD83D\uDC69(?:\u200D(?:\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D(?:\uD83D[\uDC68\uDC69])|\uD83D[\uDC68\uDC69])|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFF\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFE\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFD\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFC\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFB\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD]))|\uD83E\uDDD1(?:\u200D(?:\uD83E\uDD1D\u200D\uD83E\uDDD1|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFF\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFE\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFD\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFC\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFB\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD]))|\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC69\u200D\uD83D\uDC69\u200D(?:\uD83D[\uDC66\uDC67])|\uD83D\uDC69\u200D\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67])|(?:\uD83D\uDC41\uFE0F\u200D\uD83D\uDDE8|\uD83E\uDDD1(?:\uD83C\uDFFF\u200D[\u2695\u2696\u2708]|\uD83C\uDFFE\u200D[\u2695\u2696\u2708]|\uD83C\uDFFD\u200D[\u2695\u2696\u2708]|\uD83C\uDFFC\u200D[\u2695\u2696\u2708]|\uD83C\uDFFB\u200D[\u2695\u2696\u2708]|\u200D[\u2695\u2696\u2708])|\uD83D\uDC69(?:\uD83C\uDFFF\u200D[\u2695\u2696\u2708]|\uD83C\uDFFE\u200D[\u2695\u2696\u2708]|\uD83C\uDFFD\u200D[\u2695\u2696\u2708]|\uD83C\uDFFC\u200D[\u2695\u2696\u2708]|\uD83C\uDFFB\u200D[\u2695\u2696\u2708]|\u200D[\u2695\u2696\u2708])|\uD83D\uDE36\u200D\uD83C\uDF2B|\uD83C\uDFF3\uFE0F\u200D\u26A7|\uD83D\uDC3B\u200D\u2744|(?:(?:\uD83C[\uDFC3\uDFC4\uDFCA]|\uD83D[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6]|\uD83E[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD])(?:\uD83C[\uDFFB-\uDFFF])|\uD83D\uDC6F|\uD83E[\uDD3C\uDDDE\uDDDF])\u200D[\u2640\u2642]|(?:\u26F9|\uD83C[\uDFCB\uDFCC]|\uD83D\uDD75)(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])\u200D[\u2640\u2642]|\uD83C\uDFF4\u200D\u2620|(?:\uD83C[\uDFC3\uDFC4\uDFCA]|\uD83D[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6]|\uD83E[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD])\u200D[\u2640\u2642]|[\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u2328\u23CF\u23ED-\u23EF\u23F1\u23F2\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB\u25FC\u2600-\u2604\u260E\u2611\u2618\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u2692\u2694-\u2697\u2699\u269B\u269C\u26A0\u26A7\u26B0\u26B1\u26C8\u26CF\u26D1\u26D3\u26E9\u26F0\u26F1\u26F4\u26F7\u26F8\u2702\u2708\u2709\u270F\u2712\u2714\u2716\u271D\u2721\u2733\u2734\u2744\u2747\u2763\u27A1\u2934\u2935\u2B05-\u2B07\u3030\u303D\u3297\u3299]|\uD83C[\uDD70\uDD71\uDD7E\uDD7F\uDE02\uDE37\uDF21\uDF24-\uDF2C\uDF36\uDF7D\uDF96\uDF97\uDF99-\uDF9B\uDF9E\uDF9F\uDFCD\uDFCE\uDFD4-\uDFDF\uDFF5\uDFF7]|\uD83D[\uDC3F\uDCFD\uDD49\uDD4A\uDD6F\uDD70\uDD73\uDD76-\uDD79\uDD87\uDD8A-\uDD8D\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA\uDECB\uDECD-\uDECF\uDEE0-\uDEE5\uDEE9\uDEF0\uDEF3])\uFE0F|\uD83C\uDFF3\uFE0F\u200D\uD83C\uDF08|\uD83D\uDC69\u200D\uD83D\uDC67|\uD83D\uDC69\u200D\uD83D\uDC66|\uD83D\uDE35\u200D\uD83D\uDCAB|\uD83D\uDE2E\u200D\uD83D\uDCA8|\uD83D\uDC15\u200D\uD83E\uDDBA|\uD83E\uDDD1(?:\uD83C\uDFFF|\uD83C\uDFFE|\uD83C\uDFFD|\uD83C\uDFFC|\uD83C\uDFFB)?|\uD83D\uDC69(?:\uD83C\uDFFF|\uD83C\uDFFE|\uD83C\uDFFD|\uD83C\uDFFC|\uD83C\uDFFB)?|\uD83C\uDDFD\uD83C\uDDF0|\uD83C\uDDF6\uD83C\uDDE6|\uD83C\uDDF4\uD83C\uDDF2|\uD83D\uDC08\u200D\u2B1B|\u2764\uFE0F\u200D(?:\uD83D\uDD25|\uD83E\uDE79)|\uD83D\uDC41\uFE0F|\uD83C\uDFF3\uFE0F|\uD83C\uDDFF(?:\uD83C[\uDDE6\uDDF2\uDDFC])|\uD83C\uDDFE(?:\uD83C[\uDDEA\uDDF9])|\uD83C\uDDFC(?:\uD83C[\uDDEB\uDDF8])|\uD83C\uDDFB(?:\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDEE\uDDF3\uDDFA])|\uD83C\uDDFA(?:\uD83C[\uDDE6\uDDEC\uDDF2\uDDF3\uDDF8\uDDFE\uDDFF])|\uD83C\uDDF9(?:\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDED\uDDEF-\uDDF4\uDDF7\uDDF9\uDDFB\uDDFC\uDDFF])|\uD83C\uDDF8(?:\uD83C[\uDDE6-\uDDEA\uDDEC-\uDDF4\uDDF7-\uDDF9\uDDFB\uDDFD-\uDDFF])|\uD83C\uDDF7(?:\uD83C[\uDDEA\uDDF4\uDDF8\uDDFA\uDDFC])|\uD83C\uDDF5(?:\uD83C[\uDDE6\uDDEA-\uDDED\uDDF0-\uDDF3\uDDF7-\uDDF9\uDDFC\uDDFE])|\uD83C\uDDF3(?:\uD83C[\uDDE6\uDDE8\uDDEA-\uDDEC\uDDEE\uDDF1\uDDF4\uDDF5\uDDF7\uDDFA\uDDFF])|\uD83C\uDDF2(?:\uD83C[\uDDE6\uDDE8-\uDDED\uDDF0-\uDDFF])|\uD83C\uDDF1(?:\uD83C[\uDDE6-\uDDE8\uDDEE\uDDF0\uDDF7-\uDDFB\uDDFE])|\uD83C\uDDF0(?:\uD83C[\uDDEA\uDDEC-\uDDEE\uDDF2\uDDF3\uDDF5\uDDF7\uDDFC\uDDFE\uDDFF])|\uD83C\uDDEF(?:\uD83C[\uDDEA\uDDF2\uDDF4\uDDF5])|\uD83C\uDDEE(?:\uD83C[\uDDE8-\uDDEA\uDDF1-\uDDF4\uDDF6-\uDDF9])|\uD83C\uDDED(?:\uD83C[\uDDF0\uDDF2\uDDF3\uDDF7\uDDF9\uDDFA])|\uD83C\uDDEC(?:\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEE\uDDF1-\uDDF3\uDDF5-\uDDFA\uDDFC\uDDFE])|\uD83C\uDDEB(?:\uD83C[\uDDEE-\uDDF0\uDDF2\uDDF4\uDDF7])|\uD83C\uDDEA(?:\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDED\uDDF7-\uDDFA])|\uD83C\uDDE9(?:\uD83C[\uDDEA\uDDEC\uDDEF\uDDF0\uDDF2\uDDF4\uDDFF])|\uD83C\uDDE8(?:\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDEE\uDDF0-\uDDF5\uDDF7\uDDFA-\uDDFF])|\uD83C\uDDE7(?:\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEF\uDDF1-\uDDF4\uDDF6-\uDDF9\uDDFB\uDDFC\uDDFE\uDDFF])|\uD83C\uDDE6(?:\uD83C[\uDDE8-\uDDEC\uDDEE\uDDF1\uDDF2\uDDF4\uDDF6-\uDDFA\uDDFC\uDDFD\uDDFF])|[#\*0-9]\uFE0F\u20E3|\u2764\uFE0F|(?:\uD83C[\uDFC3\uDFC4\uDFCA]|\uD83D[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6]|\uD83E[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD])(?:\uD83C[\uDFFB-\uDFFF])|(?:\u26F9|\uD83C[\uDFCB\uDFCC]|\uD83D\uDD75)(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])|\uD83C\uDFF4|(?:[\u270A\u270B]|\uD83C[\uDF85\uDFC2\uDFC7]|\uD83D[\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDC8F\uDC91\uDCAA\uDD7A\uDD95\uDD96\uDE4C\uDE4F\uDEC0\uDECC]|\uD83E[\uDD0C\uDD0F\uDD18-\uDD1C\uDD1E\uDD1F\uDD30-\uDD34\uDD36\uDD77\uDDB5\uDDB6\uDDBB\uDDD2\uDDD3\uDDD5])(?:\uD83C[\uDFFB-\uDFFF])|(?:[\u261D\u270C\u270D]|\uD83D[\uDD74\uDD90])(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])|[\u270A\u270B]|\uD83C[\uDF85\uDFC2\uDFC7]|\uD83D[\uDC08\uDC15\uDC3B\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDC8F\uDC91\uDCAA\uDD7A\uDD95\uDD96\uDE2E\uDE35\uDE36\uDE4C\uDE4F\uDEC0\uDECC]|\uD83E[\uDD0C\uDD0F\uDD18-\uDD1C\uDD1E\uDD1F\uDD30-\uDD34\uDD36\uDD77\uDDB5\uDDB6\uDDBB\uDDD2\uDDD3\uDDD5]|\uD83C[\uDFC3\uDFC4\uDFCA]|\uD83D[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6]|\uD83E[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD]|\uD83D\uDC6F|\uD83E[\uDD3C\uDDDE\uDDDF]|[\u231A\u231B\u23E9-\u23EC\u23F0\u23F3\u25FD\u25FE\u2614\u2615\u2648-\u2653\u267F\u2693\u26A1\u26AA\u26AB\u26BD\u26BE\u26C4\u26C5\u26CE\u26D4\u26EA\u26F2\u26F3\u26F5\u26FA\u26FD\u2705\u2728\u274C\u274E\u2753-\u2755\u2757\u2795-\u2797\u27B0\u27BF\u2B1B\u2B1C\u2B50\u2B55]|\uD83C[\uDC04\uDCCF\uDD8E\uDD91-\uDD9A\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF84\uDF86-\uDF93\uDFA0-\uDFC1\uDFC5\uDFC6\uDFC8\uDFC9\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF8-\uDFFF]|\uD83D[\uDC00-\uDC07\uDC09-\uDC14\uDC16-\uDC3A\uDC3C-\uDC3E\uDC40\uDC44\uDC45\uDC51-\uDC65\uDC6A\uDC79-\uDC7B\uDC7D-\uDC80\uDC84\uDC88-\uDC8E\uDC90\uDC92-\uDCA9\uDCAB-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDDA4\uDDFB-\uDE2D\uDE2F-\uDE34\uDE37-\uDE44\uDE48-\uDE4A\uDE80-\uDEA2\uDEA4-\uDEB3\uDEB7-\uDEBF\uDEC1-\uDEC5\uDED0-\uDED2\uDED5-\uDED7\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB]|\uD83E[\uDD0D\uDD0E\uDD10-\uDD17\uDD1D\uDD20-\uDD25\uDD27-\uDD2F\uDD3A\uDD3F-\uDD45\uDD47-\uDD76\uDD78\uDD7A-\uDDB4\uDDB7\uDDBA\uDDBC-\uDDCB\uDDD0\uDDE0-\uDDFF\uDE70-\uDE74\uDE78-\uDE7A\uDE80-\uDE86\uDE90-\uDEA8\uDEB0-\uDEB6\uDEC0-\uDEC2\uDED0-\uDED6]|(?:[\u231A\u231B\u23E9-\u23EC\u23F0\u23F3\u25FD\u25FE\u2614\u2615\u2648-\u2653\u267F\u2693\u26A1\u26AA\u26AB\u26BD\u26BE\u26C4\u26C5\u26CE\u26D4\u26EA\u26F2\u26F3\u26F5\u26FA\u26FD\u2705\u270A\u270B\u2728\u274C\u274E\u2753-\u2755\u2757\u2795-\u2797\u27B0\u27BF\u2B1B\u2B1C\u2B50\u2B55]|\uD83C[\uDC04\uDCCF\uDD8E\uDD91-\uDD9A\uDDE6-\uDDFF\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF93\uDFA0-\uDFCA\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF4\uDFF8-\uDFFF]|\uD83D[\uDC00-\uDC3E\uDC40\uDC42-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDD7A\uDD95\uDD96\uDDA4\uDDFB-\uDE4F\uDE80-\uDEC5\uDECC\uDED0-\uDED2\uDED5-\uDED7\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB]|\uD83E[\uDD0C-\uDD3A\uDD3C-\uDD45\uDD47-\uDD78\uDD7A-\uDDCB\uDDCD-\uDDFF\uDE70-\uDE74\uDE78-\uDE7A\uDE80-\uDE86\uDE90-\uDEA8\uDEB0-\uDEB6\uDEC0-\uDEC2\uDED0-\uDED6])|(?:[#\*0-9\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u231A\u231B\u2328\u23CF\u23E9-\u23F3\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB-\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u261D\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692-\u2697\u2699\u269B\u269C\u26A0\u26A1\u26A7\u26AA\u26AB\u26B0\u26B1\u26BD\u26BE\u26C4\u26C5\u26C8\u26CE\u26CF\u26D1\u26D3\u26D4\u26E9\u26EA\u26F0-\u26F5\u26F7-\u26FA\u26FD\u2702\u2705\u2708-\u270D\u270F\u2712\u2714\u2716\u271D\u2721\u2728\u2733\u2734\u2744\u2747\u274C\u274E\u2753-\u2755\u2757\u2763\u2764\u2795-\u2797\u27A1\u27B0\u27BF\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B50\u2B55\u3030\u303D\u3297\u3299]|\uD83C[\uDC04\uDCCF\uDD70\uDD71\uDD7E\uDD7F\uDD8E\uDD91-\uDD9A\uDDE6-\uDDFF\uDE01\uDE02\uDE1A\uDE2F\uDE32-\uDE3A\uDE50\uDE51\uDF00-\uDF21\uDF24-\uDF93\uDF96\uDF97\uDF99-\uDF9B\uDF9E-\uDFF0\uDFF3-\uDFF5\uDFF7-\uDFFF]|\uD83D[\uDC00-\uDCFD\uDCFF-\uDD3D\uDD49-\uDD4E\uDD50-\uDD67\uDD6F\uDD70\uDD73-\uDD7A\uDD87\uDD8A-\uDD8D\uDD90\uDD95\uDD96\uDDA4\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA-\uDE4F\uDE80-\uDEC5\uDECB-\uDED2\uDED5-\uDED7\uDEE0-\uDEE5\uDEE9\uDEEB\uDEEC\uDEF0\uDEF3-\uDEFC\uDFE0-\uDFEB]|\uD83E[\uDD0C-\uDD3A\uDD3C-\uDD45\uDD47-\uDD78\uDD7A-\uDDCB\uDDCD-\uDDFF\uDE70-\uDE74\uDE78-\uDE7A\uDE80-\uDE86\uDE90-\uDEA8\uDEB0-\uDEB6\uDEC0-\uDEC2\uDED0-\uDED6])\uFE0F|(?:[\u261D\u26F9\u270A-\u270D]|\uD83C[\uDF85\uDFC2-\uDFC4\uDFC7\uDFCA-\uDFCC]|\uD83D[\uDC42\uDC43\uDC46-\uDC50\uDC66-\uDC78\uDC7C\uDC81-\uDC83\uDC85-\uDC87\uDC8F\uDC91\uDCAA\uDD74\uDD75\uDD7A\uDD90\uDD95\uDD96\uDE45-\uDE47\uDE4B-\uDE4F\uDEA3\uDEB4-\uDEB6\uDEC0\uDECC]|\uD83E[\uDD0C\uDD0F\uDD18-\uDD1F\uDD26\uDD30-\uDD39\uDD3C-\uDD3E\uDD77\uDDB5\uDDB6\uDDB8\uDDB9\uDDBB\uDDCD-\uDDCF\uDDD1-\uDDDD])/g};});i();i();i();var v=D=>{var u,C,t;let F=(u=process.stdout.columns)!=null?u:Number.POSITIVE_INFINITY;return typeof D=="function"&&(D=D(F)),D||(D={}),Array.isArray(D)?{columns:D,stdoutColumns:F}:{columns:(C=D.columns)!=null?C:[],stdoutColumns:(t=D.stdoutColumns)!=null?t:F}};i();i();i();i();i();function w$1({onlyFirst:D=!1}={}){let F=["[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)","(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))"].join("|");return new RegExp(F,D?void 0:"g")}function d$1(D){if(typeof D!="string")throw new TypeError(`Expected a \`string\`, got \`${typeof D}\``);return D.replace(w$1(),"")}i();function y$1(D){return Number.isInteger(D)?D>=4352&&(D<=4447||D===9001||D===9002||11904<=D&&D<=12871&&D!==12351||12880<=D&&D<=19903||19968<=D&&D<=42182||43360<=D&&D<=43388||44032<=D&&D<=55203||63744<=D&&D<=64255||65040<=D&&D<=65049||65072<=D&&D<=65131||65281<=D&&D<=65376||65504<=D&&D<=65510||110592<=D&&D<=110593||127488<=D&&D<=127569||131072<=D&&D<=262141):!1}var j=BD($());function g(D){if(typeof D!="string"||D.length===0||(D=d$1(D),D.length===0))return 0;D=D.replace((0, j.default)()," ");let F=0;for(let u=0;u=127&&C<=159||C>=768&&C<=879||(C>65535&&u++,F+=y$1(C)?2:1);}return F}var b$1=D=>Math.max(...D.split(` ++`).map(g));var k$1=D=>{let F=[];for(let u of D){let{length:C}=u,t=C-F.length;for(let E=0;EF[E]&&(F[E]=e);}}return F};i();var _$1=/^\d+%$/,z$1={width:"auto",align:"left",contentWidth:0,paddingLeft:0,paddingRight:0,paddingTop:0,paddingBottom:0,horizontalPadding:0,paddingLeftString:"",paddingRightString:""},sD=(D,F)=>{var C;let u=[];for(let t=0;t=F){let n=e-F,o=Math.ceil(u.paddingLeft/t*n),B=n-o;u.paddingLeft-=o,u.paddingRight-=B,u.horizontalPadding=u.paddingLeft+u.paddingRight;}u.paddingLeftString=u.paddingLeft?" ".repeat(u.paddingLeft):"",u.paddingRightString=u.paddingRight?" ".repeat(u.paddingRight):"";let r=F-u.horizontalPadding;u.width=Math.max(Math.min(u.width,r),E);}}var G$1=()=>Object.assign([],{columns:0});function lD(D,F){let u=[G$1()],[C]=u;for(let t of D){let E=t.width+t.horizontalPadding;C.columns+E>F&&(C=G$1(),u.push(C)),C.push(t),C.columns+=E;}for(let t of u){let E=t.reduce((s,l)=>s+l.width+l.horizontalPadding,0),e=F-E;if(e===0)continue;let r=t.filter(s=>"autoOverflow"in s),n=r.filter(s=>s.autoOverflow>0),o=n.reduce((s,l)=>s+l.autoOverflow,0),B=Math.min(o,e);for(let s of n){let l=Math.floor(s.autoOverflow/o*B);s.width+=l,e-=l;}let a=Math.floor(e/r.length);for(let s=0;sF=>`[${F+D}m`,V$1=(D=0)=>F=>`[${38+D};5;${F}m`,Y=(D=0)=>(F,u,C)=>`[${38+D};2;${F};${u};${C}m`;function AD(){let D=new Map,F={modifier:{reset:[0,0],bold:[1,22],dim:[2,22],italic:[3,23],underline:[4,24],overline:[53,55],inverse:[7,27],hidden:[8,28],strikethrough:[9,29]},color:{black:[30,39],red:[31,39],green:[32,39],yellow:[33,39],blue:[34,39],magenta:[35,39],cyan:[36,39],white:[37,39],blackBright:[90,39],redBright:[91,39],greenBright:[92,39],yellowBright:[93,39],blueBright:[94,39],magentaBright:[95,39],cyanBright:[96,39],whiteBright:[97,39]},bgColor:{bgBlack:[40,49],bgRed:[41,49],bgGreen:[42,49],bgYellow:[43,49],bgBlue:[44,49],bgMagenta:[45,49],bgCyan:[46,49],bgWhite:[47,49],bgBlackBright:[100,49],bgRedBright:[101,49],bgGreenBright:[102,49],bgYellowBright:[103,49],bgBlueBright:[104,49],bgMagentaBright:[105,49],bgCyanBright:[106,49],bgWhiteBright:[107,49]}};F.color.gray=F.color.blackBright,F.bgColor.bgGray=F.bgColor.bgBlackBright,F.color.grey=F.color.blackBright,F.bgColor.bgGrey=F.bgColor.bgBlackBright;for(let[u,C]of Object.entries(F)){for(let[t,E]of Object.entries(C))F[t]={open:`[${E[0]}m`,close:`[${E[1]}m`},C[t]=F[t],D.set(E[0],E[1]);Object.defineProperty(F,u,{value:C,enumerable:!1});}return Object.defineProperty(F,"codes",{value:D,enumerable:!1}),F.color.close="",F.bgColor.close="",F.color.ansi=U$1(),F.color.ansi256=V$1(),F.color.ansi16m=Y(),F.bgColor.ansi=U$1(O),F.bgColor.ansi256=V$1(O),F.bgColor.ansi16m=Y(O),Object.defineProperties(F,{rgbToAnsi256:{value:(u,C,t)=>u===C&&C===t?u<8?16:u>248?231:Math.round((u-8)/247*24)+232:16+36*Math.round(u/255*5)+6*Math.round(C/255*5)+Math.round(t/255*5),enumerable:!1},hexToRgb:{value:u=>{let C=/(?[a-f\d]{6}|[a-f\d]{3})/i.exec(u.toString(16));if(!C)return [0,0,0];let{colorString:t}=C.groups;t.length===3&&(t=t.split("").map(e=>e+e).join(""));let E=Number.parseInt(t,16);return [E>>16&255,E>>8&255,E&255]},enumerable:!1},hexToAnsi256:{value:u=>F.rgbToAnsi256(...F.hexToRgb(u)),enumerable:!1},ansi256ToAnsi:{value:u=>{if(u<8)return 30+u;if(u<16)return 90+(u-8);let C,t,E;if(u>=232)C=((u-232)*10+8)/255,t=C,E=C;else {u-=16;let n=u%36;C=Math.floor(u/36)/5,t=Math.floor(n/6)/5,E=n%6/5;}let e=Math.max(C,t,E)*2;if(e===0)return 30;let r=30+(Math.round(E)<<2|Math.round(t)<<1|Math.round(C));return e===2&&(r+=60),r},enumerable:!1},rgbToAnsi:{value:(u,C,t)=>F.ansi256ToAnsi(F.rgbToAnsi256(u,C,t)),enumerable:!1},hexToAnsi:{value:u=>F.ansi256ToAnsi(F.hexToAnsi256(u)),enumerable:!1}}),F}var fD=AD(),K=fD;var x$1=new Set(["","\x9B"]),gD=39,R$1="\x07",q$1="[",pD="]",H$1="m",M$1=`${pD}8;;`,J$1=D=>`${x$1.values().next().value}${q$1}${D}${H$1}`,Q=D=>`${x$1.values().next().value}${M$1}${D}${R$1}`,hD=D=>D.split(" ").map(F=>g(F)),S$1=(D,F,u)=>{let C=[...F],t=!1,E=!1,e=g(d$1(D[D.length-1]));for(let[r,n]of C.entries()){let o=g(n);if(e+o<=u?D[D.length-1]+=n:(D.push(n),e=0),x$1.has(n)&&(t=!0,E=C.slice(r+1).join("").startsWith(M$1)),t){E?n===R$1&&(t=!1,E=!1):n===H$1&&(t=!1);continue}e+=o,e===u&&r0&&D.length>1&&(D[D.length-2]+=D.pop());},cD=D=>{let F=D.split(" "),u=F.length;for(;u>0&&!(g(F[u-1])>0);)u--;return u===F.length?D:F.slice(0,u).join(" ")+F.slice(u).join("")},dD=(D,F,u={})=>{if(u.trim!==!1&&D.trim()==="")return "";let C="",t,E,e=hD(D),r=[""];for(let[o,B]of D.split(" ").entries()){u.trim!==!1&&(r[r.length-1]=r[r.length-1].trimStart());let a=g(r[r.length-1]);if(o!==0&&(a>=F&&(u.wordWrap===!1||u.trim===!1)&&(r.push(""),a=0),(a>0||u.trim===!1)&&(r[r.length-1]+=" ",a++)),u.hard&&e[o]>F){let s=F-a,l=1+Math.floor((e[o]-s-1)/F);Math.floor((e[o]-1)/F)F&&a>0&&e[o]>0){if(u.wordWrap===!1&&aF&&u.wordWrap===!1){S$1(r,B,F);continue}r[r.length-1]+=B;}u.trim!==!1&&(r=r.map(o=>cD(o)));let n=[...r.join(` ++`)];for(let[o,B]of n.entries()){if(C+=B,x$1.has(B)){let{groups:s}=new RegExp(`(?:\\${q$1}(?\\d+)m|\\${M$1}(?.*)${R$1})`).exec(n.slice(o).join(""))||{groups:{}};if(s.code!==void 0){let l=Number.parseFloat(s.code);t=l===gD?void 0:l;}else s.uri!==void 0&&(E=s.uri.length===0?void 0:s.uri);}let a=K.codes.get(Number(t));n[o+1]===` ++`?(E&&(C+=Q("")),t&&a&&(C+=J$1(a))):B===` ++`&&(t&&a&&(C+=J$1(t)),E&&(C+=Q(E)));}return C};function T$1(D,F,u){return String(D).normalize().replace(/\r\n/g,` + `).split(` +-`).map(D=>AD(D,e,t)).join(` +-`)}var $u=u=>Array.from({length:u}).fill("");function vD(u,e){let t=[],D=0;for(let n of u){let i=0,s=n.map(a=>{var h;let f=(h=e[D])!=null?h:"";D+=1,a.preprocess&&(f=a.preprocess(f)),pu(f)>a.width&&(f=bD(f,a.width,{hard:!0}));let g=f.split(` +-`);if(a.postprocess){let{postprocess:F}=a;g=g.map((d,m)=>F.call(a,d,m))}return a.paddingTop&&g.unshift(...$u(a.paddingTop)),a.paddingBottom&&g.push(...$u(a.paddingBottom)),g.length>i&&(i=g.length),Re(Be({},a),{lines:g})}),o=[];for(let a=0;a{var g;let F=(g=f.lines[a])!=null?g:"",d=Number.isFinite(f.width)?" ".repeat(f.width-V(F)):"",m=f.paddingLeftString;return f.align==="right"&&(m+=d),m+=F,f.align==="left"&&(m+=d),m+f.paddingRightString}).join("");o.push(h)}t.push(o.join(` +-`))}return t.join(` +-`)}function SD(u,e){if(!u||u.length===0)return"";let t=pD(u),D=t.length;if(D===0)return"";let{stdoutColumns:n,columns:i}=aD(e);if(i.length>D)throw new Error(`${i.length} columns defined, but only ${D} columns found`);let s=CD(n,i,t);return u.map(o=>vD(s,o)).join(` +-`)}x();var OD=["<",">","=",">=","<="];function kD(u){if(!OD.includes(u))throw new TypeError(`Invalid breakpoint operator: ${u}`)}function jD(u){let e=Object.keys(u).map(t=>{let[D,n]=t.split(" ");kD(D);let i=Number.parseInt(n,10);if(Number.isNaN(i))throw new TypeError(`Invalid breakpoint value: ${n}`);let s=u[t];return{operator:D,breakpoint:i,value:s}}).sort((t,D)=>D.breakpoint-t.breakpoint);return t=>{var D;return(D=e.find(({operator:n,breakpoint:i})=>n==="="&&t===i||n===">"&&t>i||n==="<"&&t="&&t>=i||n==="<="&&t<=i))==null?void 0:D.value}}const xD=u=>u.replace(/[\W_]([a-z\d])?/gi,(e,t)=>t?t.toUpperCase():""),TD=u=>u.replace(/\B([A-Z])/g,"-$1").toLowerCase(),RD={"> 80":[{width:"content-width",paddingLeft:2,paddingRight:8},{width:"auto"}],"> 40":[{width:"auto",paddingLeft:2,paddingRight:8,preprocess:u=>u.trim()},{width:"100%",paddingLeft:2,paddingBottom:1}],"> 0":{stdoutColumns:1e3,columns:[{width:"content-width",paddingLeft:2,paddingRight:8},{width:"content-width"}]}};function PD(u){let e=!1;return{type:"table",data:{tableData:Object.keys(u).sort((t,D)=>t.localeCompare(D)).map(t=>{const D=u[t],n="alias"in D;return n&&(e=!0),{name:t,flag:D,flagFormatted:`--${TD(t)}`,aliasesEnabled:e,aliasFormatted:n?`-${D.alias}`:void 0}}).map(t=>(t.aliasesEnabled=e,[{type:"flagName",data:t},{type:"flagDescription",data:t}])),tableBreakpoints:RD}}}const Au=u=>!u||(u.version??(u.help?u.help.version:void 0)),bu=u=>{const e="parent"in u&&u.parent?.name;return(e?`${e} `:"")+u.name};function LD(u){const e=[];u.name&&e.push(bu(u));const t=Au(u)??("parent"in u&&Au(u.parent));if(t&&e.push(`v${t}`),e.length!==0)return{id:"name",type:"text",data:`${e.join(" ")} +-`}}function MD(u){const{help:e}=u;if(!(!e||!e.description))return{id:"description",type:"text",data:`${e.description} +-`}}function ND(u){const e=u.help||{};if("usage"in e)return e.usage?{id:"usage",type:"section",data:{title:"Usage:",body:Array.isArray(e.usage)?e.usage.join(` +-`):e.usage}}:void 0;if(u.name){const t=[],D=[bu(u)];if(u.flags&&Object.keys(u.flags).length>0&&D.push("[flags...]"),u.parameters&&u.parameters.length>0){const{parameters:n}=u,i=n.indexOf("--"),s=i>-1&&n.slice(i+1).some(o=>o.startsWith("<"));D.push(n.map(o=>o!=="--"?o:s?"--":"[--]").join(" "))}if(D.length>1&&t.push(D.join(" ")),"commands"in u&&u.commands?.length&&t.push(`${u.name} `),t.length>0)return{id:"usage",type:"section",data:{title:"Usage:",body:t.join(` +-`)}}}}function ID(u){return!("commands"in u)||!u.commands?.length?void 0:{id:"commands",type:"section",data:{title:"Commands:",body:{type:"table",data:{tableData:u.commands.map(e=>[e.options.name,e.options.help?e.options.help.description:""]),tableOptions:[{width:"content-width",paddingLeft:2,paddingRight:8}]}},indentBody:0}}}function _D(u){if(!(!u.flags||Object.keys(u.flags).length===0))return{id:"flags",type:"section",data:{title:"Flags:",body:PD(u.flags),indentBody:0}}}function WD(u){const{help:e}=u;if(!e||!e.examples||e.examples.length===0)return;let{examples:t}=e;if(Array.isArray(t)&&(t=t.join(` +-`)),t)return{id:"examples",type:"section",data:{title:"Examples:",body:t}}}function GD(u){if(!("alias"in u)||!u.alias)return;const{alias:e}=u;return{id:"aliases",type:"section",data:{title:"Aliases:",body:Array.isArray(e)?e.join(", "):e}}}const qD=u=>[LD,MD,ND,ID,_D,WD,GD].map(e=>e(u)).filter(Boolean),zD=Ot.WriteStream.prototype.hasColors();class JD{text(e){return e}bold(e){return zD?`\x1B[1m${e}\x1B[22m`:e.toLocaleUpperCase()}indentText({text:e,spaces:t}){return e.replace(/^/gm," ".repeat(t))}heading(e){return this.bold(e)}section({title:e,body:t,indentBody:D=2}){return`${(e?`${this.heading(e)} +-`:"")+(t?this.indentText({text:this.render(t),spaces:D}):"")} +-`}table({tableData:e,tableOptions:t,tableBreakpoints:D}){return SD(e.map(n=>n.map(i=>this.render(i))),D?jD(D):t)}flagParameter(e){return e===Boolean?"":e===String?"":e===Number?"":Array.isArray(e)?this.flagParameter(e[0]):""}flagOperator(e){return" "}flagName(e){const{flag:t,flagFormatted:D,aliasesEnabled:n,aliasFormatted:i}=e;let s="";if(i?s+=`${i}, `:n&&(s+=" "),s+=D,"placeholder"in t&&typeof t.placeholder=="string")s+=`${this.flagOperator(e)}${t.placeholder}`;else{const o=this.flagParameter("type"in t?t.type:t);o&&(s+=`${this.flagOperator(e)}${o}`)}return s}flagDefault(e){return JSON.stringify(e)}flagDescription({flag:e}){let t="description"in e?e.description??"":"";if("default"in e){let{default:D}=e;typeof D=="function"&&(D=D()),D&&(t+=` (default: ${this.flagDefault(D)})`)}return t}render(e){if(typeof e=="string")return e;if(Array.isArray(e))return e.map(t=>this.render(t)).join(` +-`);if("type"in e&&this[e.type]){const t=this[e.type];if(typeof t=="function")return t.call(this,e.data)}throw new Error(`Invalid node type: ${JSON.stringify(e)}`)}}const Ie=/^[\w.-]+$/,{stringify:I}=JSON,VD=/[|\\{}()[\]^$+*?.]/;function _e(u){const e=[];let t,D;for(const n of u){if(D)throw new Error(`Invalid parameter: Spread parameter ${I(D)} must be last`);const i=n[0],s=n[n.length-1];let o;if(i==="<"&&s===">"&&(o=!0,t))throw new Error(`Invalid parameter: Required parameter ${I(n)} cannot come after optional parameter ${I(t)}`);if(i==="["&&s==="]"&&(o=!1,t=n),o===void 0)throw new Error(`Invalid parameter: ${I(n)}. Must be wrapped in <> (required parameter) or [] (optional parameter)`);let a=n.slice(1,-1);const h=a.slice(-3)==="...";h&&(D=n,a=a.slice(0,-3));const f=a.match(VD);if(f)throw new Error(`Invalid parameter: ${I(n)}. Invalid character found ${I(f[0])}`);e.push({name:a,required:o,spread:h})}return e}function We(u,e,t,D){for(let n=0;n{console.log(e.version)};if(i&&a.flags.version===!0)return h(),process.exit(0);const f=new JD,g=o&&s?.render?s.render:m=>f.render(m),F=m=>{const w=qD({...e,...m?{help:m}:{},flags:n});console.log(g(w,f))};if(o&&a.flags.help===!0)return F(),process.exit(0);if(e.parameters){let{parameters:m}=e,w=a._;const S=m.indexOf("--"),R=m.slice(S+1),M=Object.create(null);if(S>-1&&R.length>0){m=m.slice(0,S);const G=a._["--"];w=w.slice(0,-G.length||void 0),We(M,_e(m),w,F),We(M,_e(R),G,F)}else We(M,_e(m),w,F);Object.assign(a._,M)}const d={...a,showVersion:h,showHelp:F};return typeof t=="function"&&t(d),{command:u,...d}}function ZD(u,e){const t=new Map;for(const D of e){const n=[D.options.name],{alias:i}=D.options;i&&(Array.isArray(i)?n.push(...i):n.push(i));for(const s of n){if(t.has(s))throw new Error(`Duplicate command name found: ${I(s)}`);t.set(s,D)}}return t.get(u)}function HD(u,e,t=process.argv.slice(2)){if(!u)throw new Error("Options is required");if("name"in u&&(!u.name||!Ie.test(u.name)))throw new Error(`Invalid script name: ${I(u.name)}`);const D=t[0];if(u.commands&&Ie.test(D)){const n=ZD(D,u.commands);if(n)return vu(n.options.name,{...n.options,parent:u},n.callback,t.slice(1))}return vu(void 0,u,e,t)}function XD(u,e){if(!u)throw new Error("Command options are required");const{name:t}=u;if(u.name===void 0)throw new Error("Command name is required");if(!Ie.test(t))throw new Error(`Invalid command name ${JSON.stringify(t)}. Command names must be one word.`);return{options:u,callback:e}}function ye(){}function Su(){return typeof WeakMap<"u"?new WeakMap:YD()}function YD(){return{add:ye,delete:ye,get:ye,set:ye,has:function(u){return!1}}}var KD=Object.prototype.hasOwnProperty,Ge=function(u,e){return KD.call(u,e)};function qe(u,e){for(var t in e)Ge(e,t)&&(u[t]=e[t]);return u}var QD=/^[ \t]*(?:\r\n|\r|\n)/,en=/(?:\r\n|\r|\n)[ \t]*$/,un=/^(?:[\r\n]|$)/,tn=/(?:\r\n|\r|\n)([ \t]*)(?:[^ \t\r\n]|$)/,Dn=/^[ \t]*[\r\n][ \t\r\n]*$/;function Ou(u,e,t){var D=0,n=u[0].match(tn);n&&(D=n[1].length);var i="(\\r\\n|\\r|\\n).{0,"+D+"}",s=new RegExp(i,"g");e&&(u=u.slice(1));var o=t.newline,a=t.trimLeadingNewline,h=t.trimTrailingNewline,f=typeof o=="string",g=u.length,F=u.map(function(d,m){return d=d.replace(s,"$1"),m===0&&a&&(d=d.replace(QD,"")),m===g-1&&h&&(d=d.replace(en,"")),f&&(d=d.replace(/\r\n|\n|\r/g,function(w){return o})),d});return F}function nn(u,e){for(var t="",D=0,n=u.length;Di in H.process.env)?U=!0:U=process.stdout.isTTY,U&&(process.platform==="win32"||n&&(n==="truecolor"||n==="24bit")?se=3:D&&(D.endsWith("-256color")||D.endsWith("256"))?se=2:se=1)}let ju={enabled:U,supportLevel:se};function X(u,e,t=1){const D=`\x1B[${u}m`,n=`\x1B[${e}m`,i=new RegExp(`\\x1b\\[${e}m`,"g");return s=>ju.enabled&&ju.supportLevel>=t?D+(""+s).replace(i,D)+n:""+s}const rn=X(1,22),ze=X(2,22),xu=X(31,39),Tu=X(32,39),Je=X(35,39),re=X(36,39),oe=u=>O.access(u).then(()=>!0,()=>!1);var on=require;const Ru=async u=>{const e=await O.readFile(u,"utf8");return JSON.parse(e)},an="link.config.json",Pu="link.config.js",Lu=async u=>{const e=A.join(u,an);if(await oe(e))try{return Ru(e)}catch(D){throw new Error(`Failed to parse config JSON ${e}: ${D.message}`)}const t=A.join(u,Pu);if(await oe(t))try{return on(t)}catch(D){throw new Error(`Failed to load config file ${Pu}: ${D.message}`)}};var Mu=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function Nu(u){return u&&u.__esModule&&Object.prototype.hasOwnProperty.call(u,"default")?u.default:u}var $e={};$e.replaceDollarWithPercentPair=_u,$e.convertToSetCommand=Iu,$e.convertToSetCommands=cn;function Iu(u,e){var t="";return u=u||"",u=u.trim(),e=e||"",e=e.trim(),u&&e&&e.length>0&&(t="@SET "+u+"="+_u(e)+`\r +-`),t}function ln(u){var e={};return u.map(function(t){var D=t.split("=");e[D[0]]=D[1]}),e}function cn(u){var e=ln(u.split(" ")),t="";return Object.keys(e).forEach(function(D){t+=Iu(D,e[D])}),t}function _u(u){var e=/\$\{?([^$@#?\- \t{}:]+)\}?/g,t="",D=0;do{var n=e.exec(u);if(n){var i=u.substring(D,n.index)||"";t+=i+"%"+n[1]+"%",D=e.lastIndex}}while(e.lastIndex>0);return t+=u.slice(D),t}const{chmod:Ve,mkdir:hn,readFile:pn,stat:Wu,unlink:fn,writeFile:Ue}=O,{dirname:Gu,relative:Fn}=A,dn=$e,Cn=/^#!\s*(?:\/usr\/bin\/env\s+(?:-S\s+)?((?:[^ \t=]+=[^ \t=]+\s+)*))?([^ \t]+)(.*)$/,En=(u,e)=>Wu(u).then(()=>He(u,e),()=>{}),Ze=u=>fn(u).catch(()=>{}),He=(u,e)=>Wu(u).then(()=>gn(u,e)),gn=(u,e)=>Promise.all([Ze(e),Ze(e+".cmd"),Ze(e+".ps1")]).then(()=>mn(u,e)),mn=(u,e)=>hn(Gu(e),{recursive:!0}).then(()=>pn(u,"utf8")).then(t=>{const n=t.trim().split(/\r*\n/)[0].match(Cn);if(!n)return Xe(u,e);const i=n[1]||"",s=n[2],o=n[3]||"";return Xe(u,e,s,o,i)},t=>Xe(u,e)),Xe=(u,e,t,D,n)=>{let i=Fn(Gu(e),u),s=i.split("/").join("\\"),o,a=t&&t.split("\\").join("/"),h,f=a&&`"${a}$exe"`,g;i=i.split("\\").join("/"),D=D||"",n=n||"",t?(o=`"%dp0%\\${t}.exe"`,h=`"$basedir/${t}"`,g=`"$basedir/${t}$exe"`,s=`"%dp0%\\${s}"`,i=`"$basedir/${i}"`):(t=`"%dp0%\\${s}"`,a=`"$basedir/${i}"`,f=a,D="",s="",i="");const F=`@ECHO off\r +-GOTO start\r +-:find_dp0\r +-SET dp0=%~dp0\r +-EXIT /b\r +-:start\r +-SETLOCAL\r +-CALL :find_dp0\r +-`;let d;if(o){h=h.trim(),D=D.trim();const S=dn.convertToSetCommands(n);d=F+S+`\r +-IF EXIST ${o} (\r +- SET "_prog=${o.replace(/(^")|("$)/g,"")}"\r +-) ELSE (\r +- SET "_prog=${t.replace(/(^")|("$)/g,"")}"\r +- SET PATHEXT=%PATHEXT:;.JS;=;%\r +-)\r +-\r +-endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" ${D} ${s} %*\r +-`}else d=`${F}${t} ${D} ${s} %*\r +-`;let m=`#!/bin/sh +-`;m=m+`basedir=$(dirname "$(echo "$0" | sed -e 's,\\\\,/,g')") +- +-case \`uname\` in +- *CYGWIN*|*MINGW*|*MSYS*) basedir=\`cygpath -w "$basedir"\`;; +-esac +- +-`,h?m=m+`if [ -x ${h} ]; then +- exec ${n}${h} ${D} ${i} "$@" +-else +- exec ${n}${a} ${D} ${i} "$@" +-fi +-`:m=m+`exec ${a} ${D} ${i} "$@" +-`;let w=`#!/usr/bin/env pwsh +-$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent +- +-$exe="" +-if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { +- # Fix case when both the Windows and Linux builds of Node +- # are installed in the same directory +- $exe=".exe" +-} +-`;return h?w=w+`$ret=0 +-if (Test-Path ${g}) { +- # Support pipeline input +- if ($MyInvocation.ExpectingInput) { +- $input | & ${g} ${D} ${i} $args ++`).map(C=>dD(C,F,u)).join(` ++`)}var X=D=>Array.from({length:D}).fill("");function P(D,F){let u=[],C=0;for(let t of D){let E=0,e=t.map(n=>{var a;let o=(a=F[C])!=null?a:"";C+=1,n.preprocess&&(o=n.preprocess(o)),b$1(o)>n.width&&(o=T$1(o,n.width,{hard:!0}));let B=o.split(` ++`);if(n.postprocess){let{postprocess:s}=n;B=B.map((l,h)=>s.call(n,l,h));}return n.paddingTop&&B.unshift(...X(n.paddingTop)),n.paddingBottom&&B.push(...X(n.paddingBottom)),B.length>E&&(E=B.length),c(p({},n),{lines:B})}),r=[];for(let n=0;n{var h;let a=(h=B.lines[n])!=null?h:"",s=Number.isFinite(B.width)?" ".repeat(B.width-g(a)):"",l=B.paddingLeftString;return B.align==="right"&&(l+=s),l+=a,B.align==="left"&&(l+=s),l+B.paddingRightString}).join("");r.push(o);}u.push(r.join(` ++`));}return u.join(` ++`)}function mD(D,F){if(!D||D.length===0)return "";let u=k$1(D),C=u.length;if(C===0)return "";let{stdoutColumns:t,columns:E}=v(F);if(E.length>C)throw new Error(`${E.length} columns defined, but only ${C} columns found`);let e=Z$1(t,E,u);return D.map(r=>P(e,r)).join(` ++`)}i();var bD=["<",">","=",">=","<="];function xD(D){if(!bD.includes(D))throw new TypeError(`Invalid breakpoint operator: ${D}`)}function wD(D){let F=Object.keys(D).map(u=>{let[C,t]=u.split(" ");xD(C);let E=Number.parseInt(t,10);if(Number.isNaN(E))throw new TypeError(`Invalid breakpoint value: ${t}`);let e=D[u];return {operator:C,breakpoint:E,value:e}}).sort((u,C)=>C.breakpoint-u.breakpoint);return u=>{var C;return (C=F.find(({operator:t,breakpoint:E})=>t==="="&&u===E||t===">"&&u>E||t==="<"&&u="&&u>=E||t==="<="&&u<=E))==null?void 0:C.value}} ++ ++const S=t=>t.replace(/[\W_]([a-z\d])?/gi,(e,r)=>r?r.toUpperCase():""),q=t=>t.replace(/\B([A-Z])/g,"-$1").toLowerCase(),I={"> 80":[{width:"content-width",paddingLeft:2,paddingRight:8},{width:"auto"}],"> 40":[{width:"auto",paddingLeft:2,paddingRight:8,preprocess:t=>t.trim()},{width:"100%",paddingLeft:2,paddingBottom:1}],"> 0":{stdoutColumns:1e3,columns:[{width:"content-width",paddingLeft:2,paddingRight:8},{width:"content-width"}]}};function D(t){let e=!1;return {type:"table",data:{tableData:Object.keys(t).sort((a,i)=>a.localeCompare(i)).map(a=>{const i=t[a],s="alias"in i;return s&&(e=!0),{name:a,flag:i,flagFormatted:`--${q(a)}`,aliasesEnabled:e,aliasFormatted:s?`-${i.alias}`:void 0}}).map(a=>(a.aliasesEnabled=e,[{type:"flagName",data:a},{type:"flagDescription",data:a}])),tableBreakpoints:I}}}const A=t=>!t||(t.version??(t.help?t.help.version:void 0)),C=t=>{const e="parent"in t&&t.parent?.name;return (e?`${e} `:"")+t.name};function R(t){const e=[];t.name&&e.push(C(t));const r=A(t)??("parent"in t&&A(t.parent));if(r&&e.push(`v${r}`),e.length!==0)return {id:"name",type:"text",data:`${e.join(" ")} ++`}}function L(t){const{help:e}=t;if(!(!e||!e.description))return {id:"description",type:"text",data:`${e.description} ++`}}function T(t){const e=t.help||{};if("usage"in e)return e.usage?{id:"usage",type:"section",data:{title:"Usage:",body:Array.isArray(e.usage)?e.usage.join(` ++`):e.usage}}:void 0;if(t.name){const r=[],n=[C(t)];if(t.flags&&Object.keys(t.flags).length>0&&n.push("[flags...]"),t.parameters&&t.parameters.length>0){const{parameters:a}=t,i=a.indexOf("--"),s=i>-1&&a.slice(i+1).some(o=>o.startsWith("<"));n.push(a.map(o=>o!=="--"?o:s?"--":"[--]").join(" "));}if(n.length>1&&r.push(n.join(" ")),"commands"in t&&t.commands?.length&&r.push(`${t.name} `),r.length>0)return {id:"usage",type:"section",data:{title:"Usage:",body:r.join(` ++`)}}}}function _(t){return !("commands"in t)||!t.commands?.length?void 0:{id:"commands",type:"section",data:{title:"Commands:",body:{type:"table",data:{tableData:t.commands.map(n=>[n.options.name,n.options.help?n.options.help.description:""]),tableOptions:[{width:"content-width",paddingLeft:2,paddingRight:8}]}},indentBody:0}}}function k(t){if(!(!t.flags||Object.keys(t.flags).length===0))return {id:"flags",type:"section",data:{title:"Flags:",body:D(t.flags),indentBody:0}}}function F(t){const{help:e}=t;if(!e||!e.examples||e.examples.length===0)return;let{examples:r}=e;if(Array.isArray(r)&&(r=r.join(` ++`)),r)return {id:"examples",type:"section",data:{title:"Examples:",body:r}}}function H(t){if(!("alias"in t)||!t.alias)return;const{alias:e}=t;return {id:"aliases",type:"section",data:{title:"Aliases:",body:Array.isArray(e)?e.join(", "):e}}}const U=t=>[R,L,T,_,k,F,H].map(e=>e(t)).filter(Boolean),J=N$1.WriteStream.prototype.hasColors();class M{text(e){return e}bold(e){return J?`\x1B[1m${e}\x1B[22m`:e.toLocaleUpperCase()}indentText({text:e,spaces:r}){return e.replace(/^/gm," ".repeat(r))}heading(e){return this.bold(e)}section({title:e,body:r,indentBody:n=2}){return `${(e?`${this.heading(e)} ++`:"")+(r?this.indentText({text:this.render(r),spaces:n}):"")} ++`}table({tableData:e,tableOptions:r,tableBreakpoints:n}){return mD(e.map(a=>a.map(i=>this.render(i))),n?wD(n):r)}flagParameter(e){return e===Boolean?"":e===String?"":e===Number?"":Array.isArray(e)?this.flagParameter(e[0]):""}flagOperator(e){return " "}flagName(e){const{flag:r,flagFormatted:n,aliasesEnabled:a,aliasFormatted:i}=e;let s="";if(i?s+=`${i}, `:a&&(s+=" "),s+=n,"placeholder"in r&&typeof r.placeholder=="string")s+=`${this.flagOperator(e)}${r.placeholder}`;else {const o=this.flagParameter("type"in r?r.type:r);o&&(s+=`${this.flagOperator(e)}${o}`);}return s}flagDefault(e){return JSON.stringify(e)}flagDescription({flag:e}){let r="description"in e?e.description??"":"";if("default"in e){let{default:n}=e;typeof n=="function"&&(n=n()),n&&(r+=` (default: ${this.flagDefault(n)})`);}return r}render(e){if(typeof e=="string")return e;if(Array.isArray(e))return e.map(r=>this.render(r)).join(` ++`);if("type"in e&&this[e.type]){const r=this[e.type];if(typeof r=="function")return r.call(this,e.data)}throw new Error(`Invalid node type: ${JSON.stringify(e)}`)}}const y=/^[\w.-]+$/,{stringify:d}=JSON,V=/[|\\{}()[\]^$+*?.]/;function w(t){const e=[];let r,n;for(const a of t){if(n)throw new Error(`Invalid parameter: Spread parameter ${d(n)} must be last`);const i=a[0],s=a[a.length-1];let o;if(i==="<"&&s===">"&&(o=!0,r))throw new Error(`Invalid parameter: Required parameter ${d(a)} cannot come after optional parameter ${d(r)}`);if(i==="["&&s==="]"&&(o=!1,r=a),o===void 0)throw new Error(`Invalid parameter: ${d(a)}. Must be wrapped in <> (required parameter) or [] (optional parameter)`);let l=a.slice(1,-1);const f=l.slice(-3)==="...";f&&(n=a,l=l.slice(0,-3));const p=l.match(V);if(p)throw new Error(`Invalid parameter: ${d(a)}. Invalid character found ${d(p[0])}`);e.push({name:l,required:o,spread:f});}return e}function b(t,e,r,n){for(let a=0;a{console.log(e.version);};if(i&&l.flags.version===!0)return f(),process.exit(0);const p=new M,O=o&&s?.render?s.render:c=>p.render(c),u=c=>{const m=U({...e,...c?{help:c}:{},flags:a});console.log(O(m,p));};if(o&&l.flags.help===!0)return u(),process.exit(0);if(e.parameters){let{parameters:c}=e,m=l._;const g=c.indexOf("--"),v=c.slice(g+1),h=Object.create(null);if(g>-1&&v.length>0){c=c.slice(0,g);const E=l._["--"];m=m.slice(0,-E.length||void 0),b(h,w(c),m,u),b(h,w(v),E,u);}else b(h,w(c),m,u);Object.assign(l._,h);}const $={...l,showVersion:f,showHelp:u};return typeof r=="function"&&r($),{command:t,...$}}function z(t,e){const r=new Map;for(const n of e){const a=[n.options.name],{alias:i}=n.options;i&&(Array.isArray(i)?a.push(...i):a.push(i));for(const s of a){if(r.has(s))throw new Error(`Duplicate command name found: ${d(s)}`);r.set(s,n);}}return r.get(t)}function Z(t,e,r=process.argv.slice(2)){if(!t)throw new Error("Options is required");if("name"in t&&(!t.name||!y.test(t.name)))throw new Error(`Invalid script name: ${d(t.name)}`);const n=r[0];if(t.commands&&y.test(n)){const a=z(n,t.commands);if(a)return x(a.options.name,{...a.options,parent:t},a.callback,r.slice(1))}return x(void 0,t,e,r)}function G(t,e){if(!t)throw new Error("Command options are required");const{name:r}=t;if(t.name===void 0)throw new Error("Command name is required");if(!y.test(r))throw new Error(`Invalid command name ${JSON.stringify(r)}. Command names must be one word.`);return {options:t,callback:e}} ++ ++// In the absence of a WeakSet or WeakMap implementation, don't break, but don't cache either. ++function noop() { ++} ++function createWeakMap() { ++ if (typeof WeakMap !== "undefined") { ++ return new WeakMap(); ++ } ++ else { ++ return fakeSetOrMap(); ++ } ++} ++/** ++ * Creates and returns a no-op implementation of a WeakMap / WeakSet that never stores anything. ++ */ ++function fakeSetOrMap() { ++ return { ++ add: noop, ++ delete: noop, ++ get: noop, ++ set: noop, ++ has: function (k) { ++ return false; ++ }, ++ }; ++} ++// Safe hasOwnProperty ++var hop = Object.prototype.hasOwnProperty; ++var has = function (obj, prop) { ++ return hop.call(obj, prop); ++}; ++// Copy all own enumerable properties from source to target ++function extend(target, source) { ++ for (var prop in source) { ++ if (has(source, prop)) { ++ target[prop] = source[prop]; ++ } ++ } ++ return target; ++} ++var reLeadingNewline = /^[ \t]*(?:\r\n|\r|\n)/; ++var reTrailingNewline = /(?:\r\n|\r|\n)[ \t]*$/; ++var reStartsWithNewlineOrIsEmpty = /^(?:[\r\n]|$)/; ++var reDetectIndentation = /(?:\r\n|\r|\n)([ \t]*)(?:[^ \t\r\n]|$)/; ++var reOnlyWhitespaceWithAtLeastOneNewline = /^[ \t]*[\r\n][ \t\r\n]*$/; ++function _outdentArray(strings, firstInterpolatedValueSetsIndentationLevel, options) { ++ // If first interpolated value is a reference to outdent, ++ // determine indentation level from the indentation of the interpolated value. ++ var indentationLevel = 0; ++ var match = strings[0].match(reDetectIndentation); ++ if (match) { ++ indentationLevel = match[1].length; ++ } ++ var reSource = "(\\r\\n|\\r|\\n).{0," + indentationLevel + "}"; ++ var reMatchIndent = new RegExp(reSource, "g"); ++ if (firstInterpolatedValueSetsIndentationLevel) { ++ strings = strings.slice(1); ++ } ++ var newline = options.newline, trimLeadingNewline = options.trimLeadingNewline, trimTrailingNewline = options.trimTrailingNewline; ++ var normalizeNewlines = typeof newline === "string"; ++ var l = strings.length; ++ var outdentedStrings = strings.map(function (v, i) { ++ // Remove leading indentation from all lines ++ v = v.replace(reMatchIndent, "$1"); ++ // Trim a leading newline from the first string ++ if (i === 0 && trimLeadingNewline) { ++ v = v.replace(reLeadingNewline, ""); ++ } ++ // Trim a trailing newline from the last string ++ if (i === l - 1 && trimTrailingNewline) { ++ v = v.replace(reTrailingNewline, ""); ++ } ++ // Normalize newlines ++ if (normalizeNewlines) { ++ v = v.replace(/\r\n|\n|\r/g, function (_) { return newline; }); ++ } ++ return v; ++ }); ++ return outdentedStrings; ++} ++function concatStringsAndValues(strings, values) { ++ var ret = ""; ++ for (var i = 0, l = strings.length; i < l; i++) { ++ ret += strings[i]; ++ if (i < l - 1) { ++ ret += values[i]; ++ } ++ } ++ return ret; ++} ++function isTemplateStringsArray(v) { ++ return has(v, "raw") && has(v, "length"); ++} ++/** ++ * It is assumed that opts will not change. If this is a problem, clone your options object and pass the clone to ++ * makeInstance ++ * @param options ++ * @return {outdent} ++ */ ++function createInstance(options) { ++ /** Cache of pre-processed template literal arrays */ ++ var arrayAutoIndentCache = createWeakMap(); ++ /** ++ * Cache of pre-processed template literal arrays, where first interpolated value is a reference to outdent, ++ * before interpolated values are injected. ++ */ ++ var arrayFirstInterpSetsIndentCache = createWeakMap(); ++ function outdent(stringsOrOptions) { ++ var values = []; ++ for (var _i = 1; _i < arguments.length; _i++) { ++ values[_i - 1] = arguments[_i]; ++ } ++ /* tslint:enable:no-shadowed-variable */ ++ if (isTemplateStringsArray(stringsOrOptions)) { ++ var strings = stringsOrOptions; ++ // Is first interpolated value a reference to outdent, alone on its own line, without any preceding non-whitespace? ++ var firstInterpolatedValueSetsIndentationLevel = (values[0] === outdent || values[0] === defaultOutdent) && ++ reOnlyWhitespaceWithAtLeastOneNewline.test(strings[0]) && ++ reStartsWithNewlineOrIsEmpty.test(strings[1]); ++ // Perform outdentation ++ var cache = firstInterpolatedValueSetsIndentationLevel ++ ? arrayFirstInterpSetsIndentCache ++ : arrayAutoIndentCache; ++ var renderedArray = cache.get(strings); ++ if (!renderedArray) { ++ renderedArray = _outdentArray(strings, firstInterpolatedValueSetsIndentationLevel, options); ++ cache.set(strings, renderedArray); ++ } ++ /** If no interpolated values, skip concatenation step */ ++ if (values.length === 0) { ++ return renderedArray[0]; ++ } ++ /** Concatenate string literals with interpolated values */ ++ var rendered = concatStringsAndValues(renderedArray, firstInterpolatedValueSetsIndentationLevel ? values.slice(1) : values); ++ return rendered; ++ } ++ else { ++ // Create and return a new instance of outdent with the given options ++ return createInstance(extend(extend({}, options), stringsOrOptions || {})); ++ } ++ } ++ var fullOutdent = extend(outdent, { ++ string: function (str) { ++ return _outdentArray([str], false, options)[0]; ++ }, ++ }); ++ return fullOutdent; ++} ++var defaultOutdent = createInstance({ ++ trimLeadingNewline: true, ++ trimTrailingNewline: true, ++}); ++if (typeof module !== "undefined") { ++ // In webpack harmony-modules environments, module.exports is read-only, ++ // so we fail gracefully. ++ try { ++ module.exports = defaultOutdent; ++ Object.defineProperty(defaultOutdent, "__esModule", { value: true }); ++ defaultOutdent.default = defaultOutdent; ++ defaultOutdent.outdent = defaultOutdent; ++ } ++ catch (e) { } ++} ++ ++let enabled = true; ++// Support both browser and node environments ++const globalVar = typeof self !== 'undefined' ++ ? self ++ : typeof window !== 'undefined' ++ ? window ++ : typeof global !== 'undefined' ++ ? global ++ : {}; ++/** ++ * Detect how much colors the current terminal supports ++ */ ++let supportLevel = 0 /* none */; ++if (globalVar.process && globalVar.process.env && globalVar.process.stdout) { ++ const { FORCE_COLOR, NODE_DISABLE_COLORS, NO_COLOR, TERM, COLORTERM } = globalVar.process.env; ++ if (NODE_DISABLE_COLORS || NO_COLOR || FORCE_COLOR === '0') { ++ enabled = false; ++ } ++ else if (FORCE_COLOR === '1' || ++ FORCE_COLOR === '2' || ++ FORCE_COLOR === '3') { ++ enabled = true; ++ } ++ else if (TERM === 'dumb') { ++ enabled = false; ++ } ++ else if ('CI' in globalVar.process.env && ++ [ ++ 'TRAVIS', ++ 'CIRCLECI', ++ 'APPVEYOR', ++ 'GITLAB_CI', ++ 'GITHUB_ACTIONS', ++ 'BUILDKITE', ++ 'DRONE', ++ ].some(vendor => vendor in globalVar.process.env)) { ++ enabled = true; ++ } ++ else { ++ enabled = process.stdout.isTTY; ++ } ++ if (enabled) { ++ // Windows supports 24bit True Colors since Windows 10 revision #14931, ++ // see https://devblogs.microsoft.com/commandline/24-bit-color-in-the-windows-console/ ++ if (process.platform === 'win32') { ++ supportLevel = 3 /* trueColor */; ++ } ++ else { ++ if (COLORTERM && (COLORTERM === 'truecolor' || COLORTERM === '24bit')) { ++ supportLevel = 3 /* trueColor */; ++ } ++ else if (TERM && (TERM.endsWith('-256color') || TERM.endsWith('256'))) { ++ supportLevel = 2 /* ansi256 */; ++ } ++ else { ++ supportLevel = 1 /* ansi */; ++ } ++ } ++ } ++} ++let options = { ++ enabled, ++ supportLevel, ++}; ++function kolorist(start, end, level = 1 /* ansi */) { ++ const open = `\x1b[${start}m`; ++ const close = `\x1b[${end}m`; ++ const regex = new RegExp(`\\x1b\\[${end}m`, 'g'); ++ return (str) => { ++ return options.enabled && options.supportLevel >= level ++ ? open + ('' + str).replace(regex, open) + close ++ : '' + str; ++ }; ++} ++const bold = kolorist(1, 22); ++const dim = kolorist(2, 22); ++const red = kolorist(31, 39); ++const green = kolorist(32, 39); ++const yellow = kolorist(33, 39); ++const magenta = kolorist(35, 39); ++const cyan = kolorist(36, 39); ++ ++const fsExists = (path) => fs$1.access(path).then( ++ () => true, ++ () => false ++); ++ ++var require$1 = ( ++ false ++ ? /* @__PURE__ */ module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.src || new URL('cli.js', document.baseURI).href))) ++ : require ++ ); ++ ++const readJsonFile = async (filePath) => { ++ const jsonString = await fs$1.readFile(filePath, "utf8"); ++ return JSON.parse(jsonString); ++}; ++ ++const configJsonFile = "link.config.json"; ++const configJsFile = "link.config.js"; ++const loadConfig = async (packageDirectory) => { ++ const configJsonPath = path$1.join(packageDirectory, configJsonFile); ++ if (await fsExists(configJsonPath)) { ++ try { ++ return readJsonFile(configJsonPath); ++ } catch (error) { ++ throw new Error(`Failed to parse config JSON ${configJsonPath}: ${error.message}`); ++ } ++ } ++ const configJsPath = path$1.join(packageDirectory, configJsFile); ++ if (await fsExists(configJsPath)) { ++ try { ++ return require$1(configJsPath); ++ } catch (error) { ++ throw new Error(`Failed to load config file ${configJsFile}: ${error.message}`); ++ } ++ } ++}; ++ ++var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; ++ ++function getDefaultExportFromCjs (x) { ++ return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x; ++} ++ ++var toBatchSyntax$1 = {}; ++ ++toBatchSyntax$1.replaceDollarWithPercentPair = replaceDollarWithPercentPair; ++toBatchSyntax$1.convertToSetCommand = convertToSetCommand; ++toBatchSyntax$1.convertToSetCommands = convertToSetCommands; ++ ++function convertToSetCommand (key, value) { ++ var line = ''; ++ key = key || ''; ++ key = key.trim(); ++ value = value || ''; ++ value = value.trim(); ++ if (key && value && value.length > 0) { ++ line = '@SET ' + key + '=' + replaceDollarWithPercentPair(value) + '\r\n'; ++ } ++ return line ++} ++ ++function extractVariableValuePairs (declarations) { ++ var pairs = {}; ++ declarations.map(function (declaration) { ++ var split = declaration.split('='); ++ pairs[split[0]] = split[1]; ++ }); ++ return pairs ++} ++ ++function convertToSetCommands (variableString) { ++ var variableValuePairs = extractVariableValuePairs(variableString.split(' ')); ++ var variableDeclarationsAsBatch = ''; ++ Object.keys(variableValuePairs).forEach(function (key) { ++ variableDeclarationsAsBatch += convertToSetCommand(key, variableValuePairs[key]); ++ }); ++ return variableDeclarationsAsBatch ++} ++ ++function replaceDollarWithPercentPair (value) { ++ var dollarExpressions = /\$\{?([^$@#?\- \t{}:]+)\}?/g; ++ var result = ''; ++ var startIndex = 0; ++ do { ++ var match = dollarExpressions.exec(value); ++ if (match) { ++ var betweenMatches = value.substring(startIndex, match.index) || ''; ++ result += betweenMatches + '%' + match[1] + '%'; ++ startIndex = dollarExpressions.lastIndex; ++ } ++ } while (dollarExpressions.lastIndex > 0) ++ result += value.slice(startIndex); ++ return result ++} ++ ++// On windows, create a .cmd file. ++// Read the #! in the file to see what it uses. The vast majority ++// of the time, this will be either: ++// "#!/usr/bin/env " ++// or: ++// "#! " ++// ++// Write a binroot/pkg.bin + ".cmd" file that has this line in it: ++// @ %dp0% %* ++ ++const { ++ chmod, ++ mkdir, ++ readFile: readFile$1, ++ stat, ++ unlink, ++ writeFile, ++} = fs$1; ++ ++const { dirname: dirname$1, relative: relative$1 } = path$1; ++const toBatchSyntax = toBatchSyntax$1; ++// linting disabled because this regex is really long ++// eslint-disable-next-line max-len ++const shebangExpr = /^#!\s*(?:\/usr\/bin\/env\s+(?:-S\s+)?((?:[^ \t=]+=[^ \t=]+\s+)*))?([^ \t]+)(.*)$/; ++ ++const cmdShimIfExists = (from, to) => ++ stat(from).then(() => cmdShim(from, to), () => {}); ++ ++// Try to unlink, but ignore errors. ++// Any problems will surface later. ++const rm = path => unlink(path).catch(() => {}); ++ ++const cmdShim = (from, to) => ++ stat(from).then(() => cmdShim_(from, to)); ++ ++const cmdShim_ = (from, to) => Promise.all([ ++ rm(to), ++ rm(to + '.cmd'), ++ rm(to + '.ps1'), ++]).then(() => writeShim(from, to)); ++ ++const writeShim = (from, to) => ++ // make a cmd file and a sh script ++ // First, check if the bin is a #! of some sort. ++ // If not, then assume it's something that'll be compiled, or some other ++ // sort of script, and just call it directly. ++ mkdir(dirname$1(to), { recursive: true }) ++ .then(() => readFile$1(from, 'utf8')) ++ .then(data => { ++ const firstLine = data.trim().split(/\r*\n/)[0]; ++ const shebang = firstLine.match(shebangExpr); ++ if (!shebang) { ++ return writeShim_(from, to) ++ } ++ const vars = shebang[1] || ''; ++ const prog = shebang[2]; ++ const args = shebang[3] || ''; ++ return writeShim_(from, to, prog, args, vars) ++ }, () => writeShim_(from, to)); ++ ++const writeShim_ = (from, to, prog, args, variables) => { ++ let shTarget = relative$1(dirname$1(to), from); ++ let target = shTarget.split('/').join('\\'); ++ let longProg; ++ let shProg = prog && prog.split('\\').join('/'); ++ let shLongProg; ++ let pwshProg = shProg && `"${shProg}$exe"`; ++ let pwshLongProg; ++ shTarget = shTarget.split('\\').join('/'); ++ args = args || ''; ++ variables = variables || ''; ++ if (!prog) { ++ prog = `"%dp0%\\${target}"`; ++ shProg = `"$basedir/${shTarget}"`; ++ pwshProg = shProg; ++ args = ''; ++ target = ''; ++ shTarget = ''; + } else { +- & ${g} ${D} ${i} $args ++ longProg = `"%dp0%\\${prog}.exe"`; ++ shLongProg = `"$basedir/${prog}"`; ++ pwshLongProg = `"$basedir/${prog}$exe"`; ++ target = `"%dp0%\\${target}"`; ++ shTarget = `"$basedir/${shTarget}"`; ++ } ++ ++ // Subroutine trick to fix https://github.com/npm/cmd-shim/issues/10 ++ // and https://github.com/npm/cli/issues/969 ++ const head = '@ECHO off\r\n' + ++ 'GOTO start\r\n' + ++ ':find_dp0\r\n' + ++ 'SET dp0=%~dp0\r\n' + ++ 'EXIT /b\r\n' + ++ ':start\r\n' + ++ 'SETLOCAL\r\n' + ++ 'CALL :find_dp0\r\n'; ++ ++ let cmd; ++ if (longProg) { ++ shLongProg = shLongProg.trim(); ++ args = args.trim(); ++ const variablesBatch = toBatchSyntax.convertToSetCommands(variables); ++ cmd = head ++ + variablesBatch ++ + '\r\n' ++ + `IF EXIST ${longProg} (\r\n` ++ + ` SET "_prog=${longProg.replace(/(^")|("$)/g, '')}"\r\n` ++ + ') ELSE (\r\n' ++ + ` SET "_prog=${prog.replace(/(^")|("$)/g, '')}"\r\n` ++ + ' SET PATHEXT=%PATHEXT:;.JS;=;%\r\n' ++ + ')\r\n' ++ + '\r\n' ++ // prevent "Terminate Batch Job? (Y/n)" message ++ // https://github.com/npm/cli/issues/969#issuecomment-737496588 ++ + 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & ' ++ + `"%_prog%" ${args} ${target} %*\r\n`; ++ } else { ++ cmd = `${head}${prog} ${args} ${target} %*\r\n`; ++ } ++ ++ // #!/bin/sh ++ // basedir=`dirname "$0"` ++ // ++ // case `uname` in ++ // *CYGWIN*|*MINGW*|*MSYS*) ++ // if command -v cygpath > /dev/null 2>&1; then ++ // basedir=`cygpath -w "$basedir"` ++ // fi ++ // ;; ++ // esac ++ // ++ // if [ -x "$basedir/node.exe" ]; then ++ // exec "$basedir/node.exe" "$basedir/node_modules/npm/bin/npm-cli.js" "$@" ++ // else ++ // exec node "$basedir/node_modules/npm/bin/npm-cli.js" "$@" ++ // fi ++ ++ let sh = '#!/bin/sh\n'; ++ ++ sh = sh ++ + `basedir=$(dirname "$(echo "$0" | sed -e 's,\\\\,/,g')")\n` ++ + '\n' ++ + 'case `uname` in\n' ++ + ' *CYGWIN*|*MINGW*|*MSYS*)\n' ++ + ' if command -v cygpath > /dev/null 2>&1; then\n' ++ + ' basedir=`cygpath -w "$basedir"`\n' ++ + ' fi\n' ++ + ' ;;\n' ++ + 'esac\n' ++ + '\n'; ++ ++ if (shLongProg) { ++ sh = sh ++ + `if [ -x ${shLongProg} ]; then\n` ++ + ` exec ${variables}${shLongProg} ${args} ${shTarget} "$@"\n` ++ + 'else \n' ++ + ` exec ${variables}${shProg} ${args} ${shTarget} "$@"\n` ++ + 'fi\n'; ++ } else { ++ sh = sh ++ + `exec ${shProg} ${args} ${shTarget} "$@"\n`; ++ } ++ ++ // #!/usr/bin/env pwsh ++ // $basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent ++ // ++ // $ret=0 ++ // $exe = "" ++ // if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { ++ // # Fix case when both the Windows and Linux builds of Node ++ // # are installed in the same directory ++ // $exe = ".exe" ++ // } ++ // if (Test-Path "$basedir/node") { ++ // # Suport pipeline input ++ // if ($MyInvocation.ExpectingInput) { ++ // input | & "$basedir/node$exe" "$basedir/node_modules/npm/bin/npm-cli.js" $args ++ // } else { ++ // & "$basedir/node$exe" "$basedir/node_modules/npm/bin/npm-cli.js" $args ++ // } ++ // $ret=$LASTEXITCODE ++ // } else { ++ // # Support pipeline input ++ // if ($MyInvocation.ExpectingInput) { ++ // $input | & "node$exe" "$basedir/node_modules/npm/bin/npm-cli.js" $args ++ // } else { ++ // & "node$exe" "$basedir/node_modules/npm/bin/npm-cli.js" $args ++ // } ++ // $ret=$LASTEXITCODE ++ // } ++ // exit $ret ++ let pwsh = '#!/usr/bin/env pwsh\n' ++ + '$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent\n' ++ + '\n' ++ + '$exe=""\n' ++ + 'if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {\n' ++ + ' # Fix case when both the Windows and Linux builds of Node\n' ++ + ' # are installed in the same directory\n' ++ + ' $exe=".exe"\n' ++ + '}\n'; ++ if (shLongProg) { ++ pwsh = pwsh ++ + '$ret=0\n' ++ + `if (Test-Path ${pwshLongProg}) {\n` ++ + ' # Support pipeline input\n' ++ + ' if ($MyInvocation.ExpectingInput) {\n' ++ + ` $input | & ${pwshLongProg} ${args} ${shTarget} $args\n` ++ + ' } else {\n' ++ + ` & ${pwshLongProg} ${args} ${shTarget} $args\n` ++ + ' }\n' ++ + ' $ret=$LASTEXITCODE\n' ++ + '} else {\n' ++ + ' # Support pipeline input\n' ++ + ' if ($MyInvocation.ExpectingInput) {\n' ++ + ` $input | & ${pwshProg} ${args} ${shTarget} $args\n` ++ + ' } else {\n' ++ + ` & ${pwshProg} ${args} ${shTarget} $args\n` ++ + ' }\n' ++ + ' $ret=$LASTEXITCODE\n' ++ + '}\n' ++ + 'exit $ret\n'; ++ } else { ++ pwsh = pwsh ++ + '# Support pipeline input\n' ++ + 'if ($MyInvocation.ExpectingInput) {\n' ++ + ` $input | & ${pwshProg} ${args} ${shTarget} $args\n` ++ + '} else {\n' ++ + ` & ${pwshProg} ${args} ${shTarget} $args\n` ++ + '}\n' ++ + 'exit $LASTEXITCODE\n'; ++ } ++ ++ return Promise.all([ ++ writeFile(to + '.ps1', pwsh, 'utf8'), ++ writeFile(to + '.cmd', cmd, 'utf8'), ++ writeFile(to, sh, 'utf8'), ++ ]).then(() => chmodShim(to)) ++}; ++ ++const chmodShim = to => Promise.all([ ++ chmod(to, 0o755), ++ chmod(to + '.cmd', 0o755), ++ chmod(to + '.ps1', 0o755), ++]); ++ ++var lib$2 = cmdShim; ++cmdShim.ifExists = cmdShimIfExists; ++ ++var cmdShim$1 = /*@__PURE__*/getDefaultExportFromCjs(lib$2); ++ ++const readPackageJson = async (packagePath) => { ++ const packageJsonPath = path$1.join(packagePath, "package.json"); ++ const packageJsonExists = await fsExists(packageJsonPath); ++ if (!packageJsonExists) { ++ throw new Error(`package.json not found in ${packagePath}`); ++ } ++ const packageJson = await readJsonFile(packageJsonPath); ++ if (!packageJson.name) { ++ throw new Error(`package.json must contain a name: ${packageJsonPath}`); ++ } ++ return packageJson; ++}; ++ ++const symlink = async (targetPath, symlinkPath, type) => { ++ const stats = await fs$1.lstat(symlinkPath).catch(() => null); ++ if (stats) { ++ if (stats.isSymbolicLink()) { ++ const symlinkRealpath = await fs$1.realpath(symlinkPath).catch(() => null); ++ if (targetPath === symlinkRealpath) { ++ return; ++ } ++ } ++ await fs$1.rm(symlinkPath, { ++ recursive: true ++ }); ++ } ++ await fs$1.symlink( ++ targetPath, ++ symlinkPath, ++ type ++ ); ++}; ++const symlinkBinary = async (binaryPath, linkPath) => { ++ await symlink(binaryPath, linkPath); ++ await fs$1.chmod(linkPath, 493); ++}; ++const hardlink = async (sourcePath, hardlinkPath) => { ++ if (await fsExists(hardlinkPath)) { ++ const [ ++ existingStat, ++ sourceStat ++ ] = await Promise.all([ ++ fs$1.stat(hardlinkPath), ++ fs$1.stat(sourcePath) ++ ]); ++ if (existingStat.ino === sourceStat.ino) { ++ return; ++ } ++ await fs$1.rm(hardlinkPath, { ++ recursive: true ++ }); ++ } ++ await fs$1.link(sourcePath, hardlinkPath); ++}; ++ ++const linkBinaries = async (linkPackagePath, nodeModulesPath, { ++ name, ++ bin ++}, linkFunction) => { ++ if (!bin) { ++ return []; ++ } ++ if (name?.startsWith("@")) { ++ [, name] = name.split("/"); ++ } ++ const binDirectoryPath = path$1.join(nodeModulesPath, ".bin"); ++ await fs$1.mkdir(binDirectoryPath, { ++ recursive: true ++ }); ++ if (typeof bin === "string") { ++ await linkFunction( ++ path$1.resolve(linkPackagePath, bin), ++ path$1.join(binDirectoryPath, name) ++ ); ++ return; ++ } ++ await Promise.all( ++ Object.entries(bin).map( ++ async ([binaryName, binaryPath]) => await linkFunction( ++ path$1.resolve(linkPackagePath, binaryPath), ++ path$1.join(binDirectoryPath, binaryName) ++ ) ++ ) ++ ); ++}; ++ ++const nodeModulesDirectory = "node_modules"; ++const symlinkPackage = async (basePackagePath, linkPackagePath) => { ++ const absoluteLinkPackagePath = path$1.resolve(basePackagePath, linkPackagePath); ++ const packageJson = await readPackageJson(absoluteLinkPackagePath); ++ const nodeModulesPath = path$1.join(basePackagePath, nodeModulesDirectory); ++ const symlinkPath = path$1.join(nodeModulesPath, packageJson.name); ++ const symlinkDirectory = path$1.dirname(symlinkPath); ++ await fs$1.mkdir(symlinkDirectory, { ++ recursive: true ++ }); ++ const targetPath = path$1.relative(symlinkDirectory, absoluteLinkPackagePath); ++ await symlink( ++ targetPath, ++ symlinkPath, ++ /** ++ * On Windows, 'dir' requires admin privileges so use 'junction' instead ++ * ++ * npm also uses junction: ++ * https://github.com/npm/cli/blob/v9.9.3/workspaces/arborist/lib/arborist/reify.js#L738 ++ */ ++ "junction" ++ ); ++ await linkBinaries( ++ absoluteLinkPackagePath, ++ nodeModulesPath, ++ packageJson, ++ process.platform === "win32" ? cmdShim$1 : symlinkBinary ++ ); ++ return { ++ name: packageJson.name, ++ path: symlinkPath, ++ target: targetPath ++ }; ++}; ++ ++const linkPackage = async (basePackagePath, linkPackagePath, options) => { ++ const absoluteLinkPackagePath = path$1.resolve(basePackagePath, linkPackagePath); ++ const pathExists = await fsExists(absoluteLinkPackagePath); ++ if (!pathExists) { ++ console.warn(red("\u2716"), `Package path does not exist: ${linkPackagePath}`); ++ process.exitCode = 1; ++ return; ++ } ++ try { ++ const link = await symlinkPackage( ++ basePackagePath, ++ linkPackagePath ++ ); ++ console.log(green("\u2714"), `Symlinked ${magenta(link.name)}:`, cyan(link.path), "\u2192", cyan(link.target)); ++ } catch (error) { ++ console.warn(red("\u2716"), "Failed to symlink", cyan(linkPackagePath), "with error:", error.message); ++ process.exitCode = 1; ++ return; ++ } ++ if (options.deep) { ++ const config = await loadConfig(absoluteLinkPackagePath); ++ if (config) { ++ await linkFromConfig( ++ absoluteLinkPackagePath, ++ config, ++ options ++ ); ++ } ++ } ++}; ++const linkFromConfig = async (basePackagePath, config, options) => { ++ if (!config.packages) { ++ return; ++ } ++ const newOptions = { ++ deep: options.deep ?? config.deepLink ?? false ++ }; ++ await Promise.all( ++ config.packages.map( ++ async (linkPackagePath) => await linkPackage( ++ basePackagePath, ++ linkPackagePath, ++ newOptions ++ ) ++ ) ++ ); ++}; ++ ++var debounce$2 = {exports: {}}; ++ ++function debounce(function_, wait = 100, options = {}) { ++ if (typeof function_ !== 'function') { ++ throw new TypeError(`Expected the first parameter to be a function, got \`${typeof function_}\`.`); ++ } ++ ++ if (wait < 0) { ++ throw new RangeError('`wait` must not be negative.'); ++ } ++ ++ // TODO: Deprecate the boolean parameter at some point. ++ const {immediate} = typeof options === 'boolean' ? {immediate: options} : options; ++ ++ let storedContext; ++ let storedArguments; ++ let timeoutId; ++ let timestamp; ++ let result; ++ ++ function run() { ++ const callContext = storedContext; ++ const callArguments = storedArguments; ++ storedContext = undefined; ++ storedArguments = undefined; ++ result = function_.apply(callContext, callArguments); ++ return result; ++ } ++ ++ function later() { ++ const last = Date.now() - timestamp; ++ ++ if (last < wait && last >= 0) { ++ timeoutId = setTimeout(later, wait - last); ++ } else { ++ timeoutId = undefined; ++ ++ if (!immediate) { ++ result = run(); ++ } ++ } ++ } ++ ++ const debounced = function (...arguments_) { ++ if (storedContext && this !== storedContext) { ++ throw new Error('Debounced method called with different contexts.'); ++ } ++ ++ storedContext = this; // eslint-disable-line unicorn/no-this-assignment ++ storedArguments = arguments_; ++ timestamp = Date.now(); ++ ++ const callNow = immediate && !timeoutId; ++ ++ if (!timeoutId) { ++ timeoutId = setTimeout(later, wait); ++ } ++ ++ if (callNow) { ++ result = run(); ++ } ++ ++ return result; ++ }; ++ ++ debounced.clear = () => { ++ if (!timeoutId) { ++ return; ++ } ++ ++ clearTimeout(timeoutId); ++ timeoutId = undefined; ++ }; ++ ++ debounced.flush = () => { ++ if (!timeoutId) { ++ return; ++ } ++ ++ debounced.trigger(); ++ }; ++ ++ debounced.trigger = () => { ++ result = run(); ++ ++ debounced.clear(); ++ }; ++ ++ return debounced; ++} ++ ++// Adds compatibility for ES modules ++debounce$2.exports.debounce = debounce; ++ ++debounce$2.exports = debounce; ++ ++var debounceExports = debounce$2.exports; ++var debounce$1 = /*@__PURE__*/getDefaultExportFromCjs(debounceExports); ++ ++const pDebounce = (fn, wait, options = {}) => { ++ if (!Number.isFinite(wait)) { ++ throw new TypeError('Expected `wait` to be a finite number'); ++ } ++ ++ let leadingValue; ++ let timeout; ++ let resolveList = []; ++ ++ return function (...arguments_) { ++ return new Promise(resolve => { ++ const shouldCallNow = options.before && !timeout; ++ ++ clearTimeout(timeout); ++ ++ timeout = setTimeout(() => { ++ timeout = null; ++ ++ const result = options.before ? leadingValue : fn.apply(this, arguments_); ++ ++ for (resolve of resolveList) { ++ resolve(result); ++ } ++ ++ resolveList = []; ++ }, wait); ++ ++ if (shouldCallNow) { ++ leadingValue = fn.apply(this, arguments_); ++ resolve(leadingValue); ++ } else { ++ resolveList.push(resolve); ++ } ++ }); ++ }; ++}; ++ ++pDebounce.promise = function_ => { ++ let currentPromise; ++ ++ return async function (...arguments_) { ++ if (currentPromise) { ++ return currentPromise; ++ } ++ ++ try { ++ currentPromise = function_.apply(this, arguments_); ++ return await currentPromise; ++ } finally { ++ currentPromise = undefined; ++ } ++ }; ++}; ++ ++var globToRegexp = function (glob, opts) { ++ if (typeof glob !== 'string') { ++ throw new TypeError('Expected a string'); ++ } ++ ++ var str = String(glob); ++ ++ // The regexp we are building, as a string. ++ var reStr = ""; ++ ++ // Whether we are matching so called "extended" globs (like bash) and should ++ // support single character matching, matching ranges of characters, group ++ // matching, etc. ++ var extended = opts ? !!opts.extended : false; ++ ++ // When globstar is _false_ (default), '/foo/*' is translated a regexp like ++ // '^\/foo\/.*$' which will match any string beginning with '/foo/' ++ // When globstar is _true_, '/foo/*' is translated to regexp like ++ // '^\/foo\/[^/]*$' which will match any string beginning with '/foo/' BUT ++ // which does not have a '/' to the right of it. ++ // E.g. with '/foo/*' these will match: '/foo/bar', '/foo/bar.txt' but ++ // these will not '/foo/bar/baz', '/foo/bar/baz.txt' ++ // Lastely, when globstar is _true_, '/foo/**' is equivelant to '/foo/*' when ++ // globstar is _false_ ++ var globstar = opts ? !!opts.globstar : false; ++ ++ // If we are doing extended matching, this boolean is true when we are inside ++ // a group (eg {*.html,*.js}), and false otherwise. ++ var inGroup = false; ++ ++ // RegExp flags (eg "i" ) to pass in to RegExp constructor. ++ var flags = opts && typeof( opts.flags ) === "string" ? opts.flags : ""; ++ ++ var c; ++ for (var i = 0, len = str.length; i < len; i++) { ++ c = str[i]; ++ ++ switch (c) { ++ case "/": ++ case "$": ++ case "^": ++ case "+": ++ case ".": ++ case "(": ++ case ")": ++ case "=": ++ case "!": ++ case "|": ++ reStr += "\\" + c; ++ break; ++ ++ case "?": ++ if (extended) { ++ reStr += "."; ++ break; ++ } ++ ++ case "[": ++ case "]": ++ if (extended) { ++ reStr += c; ++ break; ++ } ++ ++ case "{": ++ if (extended) { ++ inGroup = true; ++ reStr += "("; ++ break; ++ } ++ ++ case "}": ++ if (extended) { ++ inGroup = false; ++ reStr += ")"; ++ break; ++ } ++ ++ case ",": ++ if (inGroup) { ++ reStr += "|"; ++ break; ++ } ++ reStr += "\\" + c; ++ break; ++ ++ case "*": ++ // Move over all consecutive "*"'s. ++ // Also store the previous and next characters ++ var prevChar = str[i - 1]; ++ var starCount = 1; ++ while(str[i + 1] === "*") { ++ starCount++; ++ i++; ++ } ++ var nextChar = str[i + 1]; ++ ++ if (!globstar) { ++ // globstar is disabled, so treat any number of "*" as one ++ reStr += ".*"; ++ } else { ++ // globstar is enabled, so determine if this is a globstar segment ++ var isGlobstar = starCount > 1 // multiple "*"'s ++ && (prevChar === "/" || prevChar === undefined) // from the start of the segment ++ && (nextChar === "/" || nextChar === undefined); // to the end of the segment ++ ++ if (isGlobstar) { ++ // it's a globstar, so match zero or more path segments ++ reStr += "((?:[^/]*(?:\/|$))*)"; ++ i++; // move over the "/" ++ } else { ++ // it's not a globstar, so only match one path segment ++ reStr += "([^/]*)"; ++ } ++ } ++ break; ++ ++ default: ++ reStr += c; ++ } + } +- $ret=$LASTEXITCODE +-} else { +- # Support pipeline input +- if ($MyInvocation.ExpectingInput) { +- $input | & ${f} ${D} ${i} $args ++ ++ // When regexp 'g' flag is specified don't ++ // constrain the regular expression with ^ & $ ++ if (!flags || !~flags.indexOf('g')) { ++ reStr = "^" + reStr + "$"; ++ } ++ ++ return new RegExp(reStr, flags); ++}; ++ ++var globToRegexp$1 = /*@__PURE__*/getDefaultExportFromCjs(globToRegexp); ++ ++var commonjs = {}; ++ ++var balancedMatch = balanced$1; ++function balanced$1(a, b, str) { ++ if (a instanceof RegExp) a = maybeMatch(a, str); ++ if (b instanceof RegExp) b = maybeMatch(b, str); ++ ++ var r = range(a, b, str); ++ ++ return r && { ++ start: r[0], ++ end: r[1], ++ pre: str.slice(0, r[0]), ++ body: str.slice(r[0] + a.length, r[1]), ++ post: str.slice(r[1] + b.length) ++ }; ++} ++ ++function maybeMatch(reg, str) { ++ var m = str.match(reg); ++ return m ? m[0] : null; ++} ++ ++balanced$1.range = range; ++function range(a, b, str) { ++ var begs, beg, left, right, result; ++ var ai = str.indexOf(a); ++ var bi = str.indexOf(b, ai + 1); ++ var i = ai; ++ ++ if (ai >= 0 && bi > 0) { ++ if(a===b) { ++ return [ai, bi]; ++ } ++ begs = []; ++ left = str.length; ++ ++ while (i >= 0 && !result) { ++ if (i == ai) { ++ begs.push(i); ++ ai = str.indexOf(a, i + 1); ++ } else if (begs.length == 1) { ++ result = [ begs.pop(), bi ]; ++ } else { ++ beg = begs.pop(); ++ if (beg < left) { ++ left = beg; ++ right = bi; ++ } ++ ++ bi = str.indexOf(b, i + 1); ++ } ++ ++ i = ai < bi && ai >= 0 ? ai : bi; ++ } ++ ++ if (begs.length) { ++ result = [ left, right ]; ++ } ++ } ++ ++ return result; ++} ++ ++var balanced = balancedMatch; ++ ++var braceExpansion = expandTop; ++ ++var escSlash = '\0SLASH'+Math.random()+'\0'; ++var escOpen = '\0OPEN'+Math.random()+'\0'; ++var escClose = '\0CLOSE'+Math.random()+'\0'; ++var escComma = '\0COMMA'+Math.random()+'\0'; ++var escPeriod = '\0PERIOD'+Math.random()+'\0'; ++ ++function numeric(str) { ++ return parseInt(str, 10) == str ++ ? parseInt(str, 10) ++ : str.charCodeAt(0); ++} ++ ++function escapeBraces(str) { ++ return str.split('\\\\').join(escSlash) ++ .split('\\{').join(escOpen) ++ .split('\\}').join(escClose) ++ .split('\\,').join(escComma) ++ .split('\\.').join(escPeriod); ++} ++ ++function unescapeBraces(str) { ++ return str.split(escSlash).join('\\') ++ .split(escOpen).join('{') ++ .split(escClose).join('}') ++ .split(escComma).join(',') ++ .split(escPeriod).join('.'); ++} ++ ++ ++// Basically just str.split(","), but handling cases ++// where we have nested braced sections, which should be ++// treated as individual members, like {a,{b,c},d} ++function parseCommaParts(str) { ++ if (!str) ++ return ['']; ++ ++ var parts = []; ++ var m = balanced('{', '}', str); ++ ++ if (!m) ++ return str.split(','); ++ ++ var pre = m.pre; ++ var body = m.body; ++ var post = m.post; ++ var p = pre.split(','); ++ ++ p[p.length-1] += '{' + body + '}'; ++ var postParts = parseCommaParts(post); ++ if (post.length) { ++ p[p.length-1] += postParts.shift(); ++ p.push.apply(p, postParts); ++ } ++ ++ parts.push.apply(parts, p); ++ ++ return parts; ++} ++ ++function expandTop(str) { ++ if (!str) ++ return []; ++ ++ // I don't know why Bash 4.3 does this, but it does. ++ // Anything starting with {} will have the first two bytes preserved ++ // but *only* at the top level, so {},a}b will not expand to anything, ++ // but a{},b}c will be expanded to [a}c,abc]. ++ // One could argue that this is a bug in Bash, but since the goal of ++ // this module is to match Bash's rules, we escape a leading {} ++ if (str.substr(0, 2) === '{}') { ++ str = '\\{\\}' + str.substr(2); ++ } ++ ++ return expand(escapeBraces(str), true).map(unescapeBraces); ++} ++ ++function embrace(str) { ++ return '{' + str + '}'; ++} ++function isPadded(el) { ++ return /^-?0\d/.test(el); ++} ++ ++function lte(i, y) { ++ return i <= y; ++} ++function gte(i, y) { ++ return i >= y; ++} ++ ++function expand(str, isTop) { ++ var expansions = []; ++ ++ var m = balanced('{', '}', str); ++ if (!m) return [str]; ++ ++ // no need to expand pre, since it is guaranteed to be free of brace-sets ++ var pre = m.pre; ++ var post = m.post.length ++ ? expand(m.post, false) ++ : ['']; ++ ++ if (/\$$/.test(m.pre)) { ++ for (var k = 0; k < post.length; k++) { ++ var expansion = pre+ '{' + m.body + '}' + post[k]; ++ expansions.push(expansion); ++ } + } else { +- & ${f} ${D} ${i} $args +- } +- $ret=$LASTEXITCODE +-} +-exit $ret +-`:w=w+`# Support pipeline input +-if ($MyInvocation.ExpectingInput) { +- $input | & ${f} ${D} ${i} $args +-} else { +- & ${f} ${D} ${i} $args +-} +-exit $LASTEXITCODE +-`,Promise.all([Ue(e+".ps1",w,"utf8"),Ue(e+".cmd",d,"utf8"),Ue(e,m,"utf8")]).then(()=>Bn(e))},Bn=u=>Promise.all([Ve(u,493),Ve(u+".cmd",493),Ve(u+".ps1",493)]);var wn=He;He.ifExists=En;var yn=Nu(wn);const qu=async u=>{const e=A.join(u,"package.json");if(!await oe(e))throw new Error(`package.json not found in ${u}`);const D=await Ru(e);if(!D.name)throw new Error(`package.json must contain a name: ${e}`);return D},zu=async(u,e,t)=>{const D=await O.lstat(e).catch(()=>null);if(D){if(D.isSymbolicLink()){const n=await O.realpath(e).catch(()=>null);if(u===n)return}await O.rm(e,{recursive:!0})}await O.symlink(u,e,t)},$n=async(u,e)=>{await zu(u,e),await O.chmod(e,493)},An=async(u,e)=>{await oe(e)&&await O.rm(e,{recursive:!0}),await O.link(u,e)},bn=async(u,e,{name:t,bin:D},n)=>{if(!D)return[];t?.startsWith("@")&&([,t]=t.split("/"));const i=A.join(e,".bin");if(await O.mkdir(i,{recursive:!0}),typeof D=="string"){await n(A.resolve(u,D),A.join(i,t));return}await Promise.all(Object.entries(D).map(async([s,o])=>await n(A.resolve(u,o),A.join(i,s))))},vn="node_modules",Sn=async(u,e)=>{const t=A.resolve(u,e),D=await qu(t),n=A.join(u,vn),i=A.join(n,D.name),s=A.dirname(i);await O.mkdir(s,{recursive:!0});const o=A.relative(s,t);return await zu(o,i,"junction"),await bn(t,n,D,process.platform==="win32"?yn:$n),{name:D.name,path:i,target:o}},Ju=async(u,e,t)=>{const D=A.resolve(u,e);if(!await oe(D)){console.warn(xu("\u2716"),`Package path does not exist: ${e}`),process.exitCode=1;return}try{const i=await Sn(u,e);console.log(Tu("\u2714"),`Symlinked ${Je(i.name)}:`,re(i.path),"\u2192",re(i.target))}catch(i){console.warn(xu("\u2716"),"Failed to symlink",re(e),"with error:",i.message),process.exitCode=1;return}if(t.deep){const i=await Lu(D);i&&await Vu(D,i,t)}},Vu=async(u,e,t)=>{if(!e.packages)return;const D={deep:t.deep??e.deepLink??!1};await Promise.all(e.packages.map(async n=>await Ju(u,n,D)))};var Uu={},On=Zu;function Zu(u,e,t){u instanceof RegExp&&(u=Hu(u,t)),e instanceof RegExp&&(e=Hu(e,t));var D=Xu(u,e,t);return D&&{start:D[0],end:D[1],pre:t.slice(0,D[0]),body:t.slice(D[0]+u.length,D[1]),post:t.slice(D[1]+e.length)}}function Hu(u,e){var t=e.match(u);return t?t[0]:null}Zu.range=Xu;function Xu(u,e,t){var D,n,i,s,o,a=t.indexOf(u),h=t.indexOf(e,a+1),f=a;if(a>=0&&h>0){if(u===e)return[a,h];for(D=[],i=t.length;f>=0&&!o;)f==a?(D.push(f),a=t.indexOf(u,f+1)):D.length==1?o=[D.pop(),h]:(n=D.pop(),n=0?a:h;D.length&&(o=[i,s])}return o}var Yu=On,kn=Tn,Ku="\0SLASH"+Math.random()+"\0",Qu="\0OPEN"+Math.random()+"\0",Ye="\0CLOSE"+Math.random()+"\0",et="\0COMMA"+Math.random()+"\0",ut="\0PERIOD"+Math.random()+"\0";function Ke(u){return parseInt(u,10)==u?parseInt(u,10):u.charCodeAt(0)}function jn(u){return u.split("\\\\").join(Ku).split("\\{").join(Qu).split("\\}").join(Ye).split("\\,").join(et).split("\\.").join(ut)}function xn(u){return u.split(Ku).join("\\").split(Qu).join("{").split(Ye).join("}").split(et).join(",").split(ut).join(".")}function tt(u){if(!u)return[""];var e=[],t=Yu("{","}",u);if(!t)return u.split(",");var D=t.pre,n=t.body,i=t.post,s=D.split(",");s[s.length-1]+="{"+n+"}";var o=tt(i);return i.length&&(s[s.length-1]+=o.shift(),s.push.apply(s,o)),e.push.apply(e,s),e}function Tn(u){return u?(u.substr(0,2)==="{}"&&(u="\\{\\}"+u.substr(2)),ae(jn(u),!0).map(xn)):[]}function Rn(u){return"{"+u+"}"}function Pn(u){return/^-?0\d/.test(u)}function Ln(u,e){return u<=e}function Mn(u,e){return u>=e}function ae(u,e){var t=[],D=Yu("{","}",u);if(!D)return[u];var n=D.pre,i=D.post.length?ae(D.post,!1):[""];if(/\$$/.test(D.pre))for(var s=0;s=0;if(!f&&!g)return D.post.match(/,.*\}/)?(u=D.pre+"{"+D.body+Ye+D.post,ae(u)):[u];var F;if(f)F=D.body.split(/\.\./);else if(F=tt(D.body),F.length===1&&(F=ae(F[0],!1).map(Rn),F.length===1))return i.map(function(Ee){return D.pre+F[0]+Ee});var d;if(f){var m=Ke(F[0]),w=Ke(F[1]),S=Math.max(F[0].length,F[1].length),R=F.length==3?Math.abs(Ke(F[2])):1,M=Ln,G=w0){var Ce=new Array(de+1).join("0");J<0?P="-"+Ce+P.slice(1):P=Ce+P}}d.push(P)}}else{d=[];for(var _=0;_{if(typeof u!="string")throw new TypeError("invalid pattern");if(u.length>Nn)throw new TypeError("pattern is too long")};Ae.assertValidPattern=In;var le={},be={};Object.defineProperty(be,"__esModule",{value:!0}),be.parseClass=void 0;const _n={"[:alnum:]":["\\p{L}\\p{Nl}\\p{Nd}",!0],"[:alpha:]":["\\p{L}\\p{Nl}",!0],"[:ascii:]":["\\x00-\\x7f",!1],"[:blank:]":["\\p{Zs}\\t",!0],"[:cntrl:]":["\\p{Cc}",!0],"[:digit:]":["\\p{Nd}",!0],"[:graph:]":["\\p{Z}\\p{C}",!0,!0],"[:lower:]":["\\p{Ll}",!0],"[:print:]":["\\p{C}",!0],"[:punct:]":["\\p{P}",!0],"[:space:]":["\\p{Z}\\t\\r\\n\\v\\f",!0],"[:upper:]":["\\p{Lu}",!0],"[:word:]":["\\p{L}\\p{Nl}\\p{Nd}\\p{Pc}",!0],"[:xdigit:]":["A-Fa-f0-9",!1]},ce=u=>u.replace(/[[\]\\-]/g,"\\$&"),Wn=u=>u.replace(/[-[\]{}()*+?.,\\^$|#\s]/g,"\\$&"),Dt=u=>u.join(""),Gn=(u,e)=>{const t=e;if(u.charAt(t)!=="[")throw new Error("not in a brace expression");const D=[],n=[];let i=t+1,s=!1,o=!1,a=!1,h=!1,f=t,g="";e:for(;ig?D.push(ce(g)+"-"+ce(w)):w===g&&D.push(ce(w)),g="",i++;continue}if(u.startsWith("-]",i+1)){D.push(ce(w+"-")),i+=2;continue}if(u.startsWith("-",i+1)){g=w,i+=2;continue}D.push(ce(w)),i++}if(fe?u.replace(/\[([^\/\\])\]/g,"$1"):u.replace(/((?!\\).|^)\[([^\/\\])\]/g,"$1$2").replace(/\\([^\/])/g,"$1");Y.unescape=qn,Object.defineProperty(le,"__esModule",{value:!0}),le.AST=void 0;const zn=be,ve=Y,Jn=new Set(["!","?","+","*","@"]),nt=u=>Jn.has(u),Vn="(?!(?:^|/)\\.\\.?(?:$|/))",Se="(?!\\.)",Un=new Set(["[","."]),Zn=new Set(["..","."]),Hn=new Set("().*{}+?[]^$\\!"),Xn=u=>u.replace(/[-[\]{}()*+?.,\\^$|#\s]/g,"\\$&"),Qe="[^/]",it=Qe+"*?",st=Qe+"+?";class T{type;#t;#D;#n=!1;#e=[];#u;#s;#o;#r=!1;#i;#a;#c=!1;constructor(e,t,D={}){this.type=e,e&&(this.#D=!0),this.#u=t,this.#t=this.#u?this.#u.#t:this,this.#i=this.#t===this?D:this.#t.#i,this.#o=this.#t===this?[]:this.#t.#o,e==="!"&&!this.#t.#r&&this.#o.push(this),this.#s=this.#u?this.#u.#e.length:0}get hasMagic(){if(this.#D!==void 0)return this.#D;for(const e of this.#e)if(typeof e!="string"&&(e.type||e.hasMagic))return this.#D=!0;return this.#D}toString(){return this.#a!==void 0?this.#a:this.type?this.#a=this.type+"("+this.#e.map(e=>String(e)).join("|")+")":this.#a=this.#e.map(e=>String(e)).join("")}#p(){if(this!==this.#t)throw new Error("should only call on root");if(this.#r)return this;this.toString(),this.#r=!0;let e;for(;e=this.#o.pop();){if(e.type!=="!")continue;let t=e,D=t.#u;for(;D;){for(let n=t.#s+1;!D.type&&ntypeof t=="string"?t:t.toJSON()):[this.type,...this.#e.map(t=>t.toJSON())];return this.isStart()&&!this.type&&e.unshift([]),this.isEnd()&&(this===this.#t||this.#t.#r&&this.#u?.type==="!")&&e.push({}),e}isStart(){if(this.#t===this)return!0;if(!this.#u?.isStart())return!1;if(this.#s===0)return!0;const e=this.#u;for(let t=0;t{const[m,w,S,R]=typeof d=="string"?T.#f(d,this.#D,a):d.toRegExpSource(e);return this.#D=this.#D||S,this.#n=this.#n||R,m}).join("");let f="";if(this.isStart()&&typeof this.#e[0]=="string"&&!(this.#e.length===1&&Zn.has(this.#e[0]))){const m=Un,w=t&&m.has(h.charAt(0))||h.startsWith("\\.")&&m.has(h.charAt(2))||h.startsWith("\\.\\.")&&m.has(h.charAt(4)),S=!t&&!e&&m.has(h.charAt(0));f=w?Vn:S?Se:""}let g="";return this.isEnd()&&this.#t.#r&&this.#u?.type==="!"&&(g="(?:$|\\/)"),[f+h+g,(0,ve.unescape)(h),this.#D=!!this.#D,this.#n]}const D=this.type==="*"||this.type==="+",n=this.type==="!"?"(?:(?!(?:":"(?:";let i=this.#h(t);if(this.isStart()&&this.isEnd()&&!i&&this.type!=="!"){const a=this.toString();return this.#e=[a],this.type=null,this.#D=void 0,[a,(0,ve.unescape)(this.toString()),!1,!1]}let s=!D||e||t||!Se?"":this.#h(!0);s===i&&(s=""),s&&(i=`(?:${i})(?:${s})*?`);let o="";if(this.type==="!"&&this.#c)o=(this.isStart()&&!t?Se:"")+st;else{const a=this.type==="!"?"))"+(this.isStart()&&!t&&!e?Se:"")+it+")":this.type==="@"?")":this.type==="?"?")?":this.type==="+"&&s?")":this.type==="*"&&s?")?":`)${this.type}`;o=n+i+a}return[o,(0,ve.unescape)(i),this.#D=!!this.#D,this.#n]}#h(e){return this.#e.map(t=>{if(typeof t=="string")throw new Error("string type in extglob ast??");const[D,n,i,s]=t.toRegExpSource(e);return this.#n=this.#n||s,D}).filter(t=>!(this.isStart()&&this.isEnd())||!!t).join("|")}static#f(e,t,D=!1){let n=!1,i="",s=!1;for(let o=0;oe?u.replace(/[?*()[\]]/g,"[$&]"):u.replace(/[?*()[\]\\]/g,"\\$&");he.escape=Yn,function(u){var e=Mu&&Mu.__importDefault||function(C){return C&&C.__esModule?C:{default:C}};Object.defineProperty(u,"__esModule",{value:!0}),u.unescape=u.escape=u.AST=u.Minimatch=u.match=u.makeRe=u.braceExpand=u.defaults=u.filter=u.GLOBSTAR=u.sep=u.minimatch=void 0;const t=e(kn),D=Ae,n=le,i=he,s=Y,o=(C,r,l={})=>((0,D.assertValidPattern)(r),!l.nocomment&&r.charAt(0)==="#"?!1:new ue(r,l).match(C));u.minimatch=o;const a=/^\*+([^+@!?\*\[\(]*)$/,h=C=>r=>!r.startsWith(".")&&r.endsWith(C),f=C=>r=>r.endsWith(C),g=C=>(C=C.toLowerCase(),r=>!r.startsWith(".")&&r.toLowerCase().endsWith(C)),F=C=>(C=C.toLowerCase(),r=>r.toLowerCase().endsWith(C)),d=/^\*+\.\*+$/,m=C=>!C.startsWith(".")&&C.includes("."),w=C=>C!=="."&&C!==".."&&C.includes("."),S=/^\.\*+$/,R=C=>C!=="."&&C!==".."&&C.startsWith("."),M=/^\*+$/,G=C=>C.length!==0&&!C.startsWith("."),Te=C=>C.length!==0&&C!=="."&&C!=="..",J=/^\?+([^+@!?\*\[\(]*)?$/,P=([C,r=""])=>{const l=Ee([C]);return r?(r=r.toLowerCase(),c=>l(c)&&c.toLowerCase().endsWith(r)):l},de=([C,r=""])=>{const l=uu([C]);return r?(r=r.toLowerCase(),c=>l(c)&&c.toLowerCase().endsWith(r)):l},Ce=([C,r=""])=>{const l=uu([C]);return r?c=>l(c)&&c.endsWith(r):l},_=([C,r=""])=>{const l=Ee([C]);return r?c=>l(c)&&c.endsWith(r):l},Ee=([C])=>{const r=C.length;return l=>l.length===r&&!l.startsWith(".")},uu=([C])=>{const r=C.length;return l=>l.length===r&&l!=="."&&l!==".."},tu=typeof process=="object"&&process?typeof process.env=="object"&&process.env&&process.env.__MINIMATCH_TESTING_PLATFORM__||process.platform:"posix",Du={win32:{sep:"\\"},posix:{sep:"/"}};u.sep=tu==="win32"?Du.win32.sep:Du.posix.sep,u.minimatch.sep=u.sep,u.GLOBSTAR=Symbol("globstar **"),u.minimatch.GLOBSTAR=u.GLOBSTAR;const ft="[^/]"+"*?",Ft="(?:(?!(?:\\/|^)(?:\\.{1,2})($|\\/)).)*?",dt="(?:(?!(?:\\/|^)\\.).)*?",Ct=(C,r={})=>l=>(0,u.minimatch)(l,C,r);u.filter=Ct,u.minimatch.filter=u.filter;const L=(C,r={})=>Object.assign({},C,r),Et=C=>{if(!C||typeof C!="object"||!Object.keys(C).length)return u.minimatch;const r=u.minimatch;return Object.assign((c,p,E={})=>r(c,p,L(C,E)),{Minimatch:class extends r.Minimatch{constructor(p,E={}){super(p,L(C,E))}static defaults(p){return r.defaults(L(C,p)).Minimatch}},AST:class extends r.AST{constructor(p,E,B={}){super(p,E,L(C,B))}static fromGlob(p,E={}){return r.AST.fromGlob(p,L(C,E))}},unescape:(c,p={})=>r.unescape(c,L(C,p)),escape:(c,p={})=>r.escape(c,L(C,p)),filter:(c,p={})=>r.filter(c,L(C,p)),defaults:c=>r.defaults(L(C,c)),makeRe:(c,p={})=>r.makeRe(c,L(C,p)),braceExpand:(c,p={})=>r.braceExpand(c,L(C,p)),match:(c,p,E={})=>r.match(c,p,L(C,E)),sep:r.sep,GLOBSTAR:u.GLOBSTAR})};u.defaults=Et,u.minimatch.defaults=u.defaults;const gt=(C,r={})=>((0,D.assertValidPattern)(C),r.nobrace||!/\{(?:(?!\{).)*\}/.test(C)?[C]:(0,t.default)(C));u.braceExpand=gt,u.minimatch.braceExpand=u.braceExpand;const mt=(C,r={})=>new ue(C,r).makeRe();u.makeRe=mt,u.minimatch.makeRe=u.makeRe;const Bt=(C,r,l={})=>{const c=new ue(r,l);return C=C.filter(p=>c.match(p)),c.options.nonull&&!C.length&&C.push(r),C};u.match=Bt,u.minimatch.match=u.match;const nu=/[?*]|[+@!]\(.*?\)|\[|\]/,wt=C=>C.replace(/[-[\]{}()*+?.,\\^$|#\s]/g,"\\$&");class ue{options;set;pattern;windowsPathsNoEscape;nonegate;negate;comment;empty;preserveMultipleSlashes;partial;globSet;globParts;nocase;isWindows;platform;windowsNoMagicRoot;regexp;constructor(r,l={}){(0,D.assertValidPattern)(r),l=l||{},this.options=l,this.pattern=r,this.platform=l.platform||tu,this.isWindows=this.platform==="win32",this.windowsPathsNoEscape=!!l.windowsPathsNoEscape||l.allowWindowsEscape===!1,this.windowsPathsNoEscape&&(this.pattern=this.pattern.replace(/\\/g,"/")),this.preserveMultipleSlashes=!!l.preserveMultipleSlashes,this.regexp=null,this.negate=!1,this.nonegate=!!l.nonegate,this.comment=!1,this.empty=!1,this.partial=!!l.partial,this.nocase=!!this.options.nocase,this.windowsNoMagicRoot=l.windowsNoMagicRoot!==void 0?l.windowsNoMagicRoot:!!(this.isWindows&&this.nocase),this.globSet=[],this.globParts=[],this.set=[],this.make()}hasMagic(){if(this.options.magicalBraces&&this.set.length>1)return!0;for(const r of this.set)for(const l of r)if(typeof l!="string")return!0;return!1}debug(...r){}make(){const r=this.pattern,l=this.options;if(!l.nocomment&&r.charAt(0)==="#"){this.comment=!0;return}if(!r){this.empty=!0;return}this.parseNegate(),this.globSet=[...new Set(this.braceExpand())],l.debug&&(this.debug=(...E)=>console.error(...E)),this.debug(this.pattern,this.globSet);const c=this.globSet.map(E=>this.slashSplit(E));this.globParts=this.preprocess(c),this.debug(this.pattern,this.globParts);let p=this.globParts.map((E,B,y)=>{if(this.isWindows&&this.windowsNoMagicRoot){const b=E[0]===""&&E[1]===""&&(E[2]==="?"||!nu.test(E[2]))&&!nu.test(E[3]),v=/^[a-z]:/i.test(E[0]);if(b)return[...E.slice(0,4),...E.slice(4).map($=>this.parse($))];if(v)return[E[0],...E.slice(1).map($=>this.parse($))]}return E.map(b=>this.parse(b))});if(this.debug(this.pattern,p),this.set=p.filter(E=>E.indexOf(!1)===-1),this.isWindows)for(let E=0;E=2?(r=this.firstPhasePreProcess(r),r=this.secondPhasePreProcess(r)):l>=1?r=this.levelOneOptimize(r):r=this.adjascentGlobstarOptimize(r),r}adjascentGlobstarOptimize(r){return r.map(l=>{let c=-1;for(;(c=l.indexOf("**",c+1))!==-1;){let p=c;for(;l[p+1]==="**";)p++;p!==c&&l.splice(c,p-c)}return l})}levelOneOptimize(r){return r.map(l=>(l=l.reduce((c,p)=>{const E=c[c.length-1];return p==="**"&&E==="**"?c:p===".."&&E&&E!==".."&&E!=="."&&E!=="**"?(c.pop(),c):(c.push(p),c)},[]),l.length===0?[""]:l))}levelTwoFileOptimize(r){Array.isArray(r)||(r=this.slashSplit(r));let l=!1;do{if(l=!1,!this.preserveMultipleSlashes){for(let p=1;pp&&c.splice(p+1,B-p);let y=c[p+1];const b=c[p+2],v=c[p+3];if(y!==".."||!b||b==="."||b===".."||!v||v==="."||v==="..")continue;l=!0,c.splice(p,1);const $=c.slice(0);$[p]="**",r.push($),p--}if(!this.preserveMultipleSlashes){for(let B=1;Bl.length)}partsMatch(r,l,c=!1){let p=0,E=0,B=[],y="";for(;pDe?l=l.slice(Z):De>Z&&(r=r.slice(De)))}}const{optimizationLevel:E=1}=this.options;E>=2&&(r=this.levelTwoFileOptimize(r)),this.debug("matchOne",this,{file:r,pattern:l}),this.debug("matchOne",r.length,l.length);for(var B=0,y=0,b=r.length,v=l.length;B>> no match, partial?`,r,j,l,N),j===b))}let q;if(typeof $=="string"?(q=k===$,this.debug("string match",$,k,q)):(q=$.test(k),this.debug("pattern match",$,k,q)),!q)return!1}if(B===b&&y===v)return!0;if(B===b)return c;if(y===v)return B===b-1&&r[B]==="";throw new Error("wtf?")}braceExpand(){return(0,u.braceExpand)(this.pattern,this.options)}parse(r){(0,D.assertValidPattern)(r);const l=this.options;if(r==="**")return u.GLOBSTAR;if(r==="")return"";let c,p=null;(c=r.match(M))?p=l.dot?Te:G:(c=r.match(a))?p=(l.nocase?l.dot?F:g:l.dot?f:h)(c[1]):(c=r.match(J))?p=(l.nocase?l.dot?de:P:l.dot?Ce:_)(c):(c=r.match(d))?p=l.dot?w:m:(c=r.match(S))&&(p=R);const E=n.AST.fromGlob(r,this.options).toMMPattern();return p?Object.assign(E,{test:p}):E}makeRe(){if(this.regexp||this.regexp===!1)return this.regexp;const r=this.set;if(!r.length)return this.regexp=!1,this.regexp;const l=this.options,c=l.noglobstar?ft:l.dot?Ft:dt,p=new Set(l.nocase?["i"]:[]);let E=r.map(b=>{const v=b.map($=>{if($ instanceof RegExp)for(const k of $.flags.split(""))p.add(k);return typeof $=="string"?wt($):$===u.GLOBSTAR?u.GLOBSTAR:$._src});return v.forEach(($,k)=>{const j=v[k+1],N=v[k-1];$!==u.GLOBSTAR||N===u.GLOBSTAR||(N===void 0?j!==void 0&&j!==u.GLOBSTAR?v[k+1]="(?:\\/|"+c+"\\/)?"+j:v[k]=c:j===void 0?v[k-1]=N+"(?:\\/|"+c+")?":j!==u.GLOBSTAR&&(v[k-1]=N+"(?:\\/|\\/"+c+"\\/)"+j,v[k+1]=u.GLOBSTAR))}),v.filter($=>$!==u.GLOBSTAR).join("/")}).join("|");const[B,y]=r.length>1?["(?:",")"]:["",""];E="^"+B+E+y+"$",this.negate&&(E="^(?!"+E+").+$");try{this.regexp=new RegExp(E,[...p].join(""))}catch{this.regexp=!1}return this.regexp}slashSplit(r){return this.preserveMultipleSlashes?r.split("/"):this.isWindows&&/^\/\/[^\/]+/.test(r)?["",...r.split(/\/+/)]:r.split(/\/+/)}match(r,l=this.partial){if(this.debug("match",r,this.pattern),this.comment)return!1;if(this.empty)return r==="";if(r==="/"&&l)return!0;const c=this.options;this.isWindows&&(r=r.split("\\").join("/"));const p=this.slashSplit(r);this.debug(this.pattern,"split",p);const E=this.set;this.debug(this.pattern,"set",E);let B=p[p.length-1];if(!B)for(let y=p.length-2;!B&&y>=0;y--)B=p[y];for(let y=0;y/^@/.test(n)?`./${n}`:n).sort(this.sort),this.result=t),e==="error"&&this.parent?D=this.parent.emit("error",t):D=super.emit(e,t)),D}start(){return z.readdir(this.path,(e,t)=>e?this.emit("error",e):this.onReaddir(t)),this}isIgnoreFile(e){return e!=="."&&e!==".."&&this.ignoreFiles.indexOf(e)!==-1}onReaddir(e){this.entries=e,e.length===0?(this.includeEmpty&&this.result.add(this.path.slice(this.root.length+1)),this.emit("done",this.result)):this.entries.some(D=>this.isIgnoreFile(D))?this.addIgnoreFiles():this.filterEntries()}addIgnoreFiles(){const e=this.entries.filter(n=>this.isIgnoreFile(n));let t=e.length;const D=n=>{--t===0&&this.filterEntries()};e.forEach(n=>this.addIgnoreFile(n,D))}addIgnoreFile(e,t){const D=eu.resolve(this.path,e);z.readFile(D,"utf8",(n,i)=>n?this.emit("error",n):this.onReadIgnoreFile(e,i,t))}onReadIgnoreFile(e,t,D){const n={matchBase:!0,dot:!0,flipNegate:!0,nocase:!0},i=t.split(/\r?\n/).filter(s=>!/^#|^$/.test(s.trim())).map(s=>new Qn(s.trim(),n));this.ignoreRules[e]=i,D()}filterEntries(){const e=this.entries.map(D=>{const n=this.filterEntry(D),i=this.filterEntry(D,!0);return n||i?[D,n,i]:!1}).filter(D=>D);let t=e.length;if(t===0)this.emit("done",this.result);else{const D=n=>{--t===0&&this.emit("done",this.result)};e.forEach(n=>{const i=n[0],s=n[1],o=n[2];this.stat({entry:i,file:s,dir:o},D)})}}onstat({st:e,entry:t,file:D,dir:n,isSymbolicLink:i},s){const o=this.path+"/"+t;e.isDirectory()?n?this.walker(t,{isSymbolicLink:i},s):s():(D&&this.result.add(o.slice(this.root.length+1)),s())}stat({entry:e,file:t,dir:D},n){const i=this.path+"/"+e;z.lstat(i,(s,o)=>{if(s)this.emit("error",s);else{const a=o.isSymbolicLink();this.follow&&a?z.stat(i,(h,f)=>{h?this.emit("error",h):this.onstat({st:f,entry:e,file:t,dir:D,isSymbolicLink:a},n)}):this.onstat({st:o,entry:e,file:t,dir:D,isSymbolicLink:a},n)}})}walkerOpt(e,t){return{path:this.path+"/"+e,parent:this,ignoreFiles:this.ignoreFiles,follow:this.follow,includeEmpty:this.includeEmpty,...t}}walker(e,t,D){new fe(this.walkerOpt(e,t)).on("done",D).start()}filterEntry(e,t){let D=!0;if(this.parent&&this.parent.filterEntry){var n=this.basename+"/"+e;D=this.parent.filterEntry(n,t)}return this.ignoreFiles.forEach(i=>{this.ignoreRules[i]&&this.ignoreRules[i].forEach(s=>{s.negate!==D&&(s.match("/"+e)||s.match(e)||t&&(s.match("/"+e+"/")||s.match(e+"/"))||t&&s.negate&&(s.match("/"+e,!0)||s.match(e,!0)))&&(D=s.negate)})}),D}}class xe extends fe{start(){return this.onReaddir(z.readdirSync(this.path)),this}addIgnoreFile(e,t){const D=eu.resolve(this.path,e);this.onReadIgnoreFile(e,z.readFileSync(D,"utf8"),t)}stat({entry:e,file:t,dir:D},n){const i=this.path+"/"+e;let s=z.lstatSync(i);const o=s.isSymbolicLink();this.follow&&o&&(s=z.statSync(i)),this.onstat({st:s,entry:e,file:t,dir:D,isSymbolicLink:o},n)}walker(e,t,D){new xe(this.walkerOpt(e,t)).start(),D()}}const Oe=(u,e)=>{const t=new Promise((D,n)=>{new fe(u).on("done",D).on("error",n).start()});return e?t.then(D=>e(null,D),e):t},ei=u=>new xe(u).start().result;var ui=Oe;Oe.sync=ei,Oe.Walker=fe,Oe.WalkerSync=xe;const{Walker:ti}=ui,{lstatSync:Di,readFileSync:ni}=ru,{basename:rt,dirname:ii,extname:ot,join:K,relative:Q,resolve:si,sep:ri}=A,pe=Symbol("npm-packlist.rules.default"),ee=Symbol("npm-packlist.rules.strict"),oi=u=>/\*/.test(u),ai=[".npmignore",".gitignore","**/.git","**/.svn","**/.hg","**/CVS","**/.git/**","**/.svn/**","**/.hg/**","**/CVS/**","/.lock-wscript","/.wafpickle-*","/build/config.gypi","npm-debug.log","**/.npmrc",".*.swp",".DS_Store","**/.DS_Store/**","._*","**/._*/**","*.orig","/archived-packages/**"],at=["/.git"],ke=["!/readme{,.*[^~$]}","!/copying{,.*[^~$]}","!/license{,.*[^~$]}","!/licence{,.*[^~$]}"],li=[/^!.*readme/i,/^!.*copying/i,/^!.*licen[sc]e/i],je=u=>u.split("\\").join("/"),lt=(u,e,t=[])=>{for(const s of[".npmignore",".gitignore"])try{const o=ni(K(u,s),{encoding:"utf8"});t.push(o);break}catch(o){if(o.code!=="ENOENT")throw o}if(!e)return t;const D=e.split(ri,1)[0],n=K(u,D),i=Q(n,K(u,e));return lt(n,i,t)};class Fe extends ti{constructor(e,t){const D={...t,includeEmpty:!1,follow:!1,path:si(t?.path||e.path).replace(/\\/g,"/"),ignoreFiles:t?.ignoreFiles||[pe,"package.json",".npmignore",".gitignore",ee]};super(D),this.isPackage=D.isPackage,this.seen=D.seen||new Set,this.tree=e,this.requiredFiles=D.requiredFiles||[];const n=[];if(D.prefix&&D.workspaces){const i=je(D.path),s=je(D.prefix),o=D.workspaces.map(a=>je(a));if(i!==s&&o.includes(i)){const a=Q(D.prefix,ii(D.path));n.push(...lt(D.prefix,a))}else i===s&&n.push(...o.map(a=>je(Q(D.path,a))))}this.injectRules(pe,[...ai,...n]),this.isPackage||this.injectRules(ee,[...at,...ke,...this.requiredFiles.map(i=>`!${i}`)])}addIgnoreFile(e,t){return e!=="package.json"||!this.isPackage?super.addIgnoreFile(e,t):this.processPackage(t)}emit(e,t){return e!=="done"||!this.isPackage?super.emit(e,t):(this.gatherBundles().then(()=>{super.emit("done",this.result)}),!0)}filterEntries(){return this.ignoreRules["package.json"]?(this.ignoreRules[".npmignore"]=null,this.ignoreRules[".gitignore"]=null):this.ignoreRules[".npmignore"]&&(this.ignoreRules[".gitignore"]=null),super.filterEntries()}onstat(e,t){return!e.st.isFile()&&!e.st.isDirectory()?t():super.onstat(e,t)}stat(e,t){return oi(e.entry)?t():super.stat(e,t)}walkerOpt(e,t){let D=null;if(this.tree.workspaces){const n=[...this.tree.workspaces.values()].map(s=>s.replace(/\\/g,"/")),i=K(this.path,e).replace(/\\/g,"/");n.includes(i)&&(D=[pe,"package.json",".npmignore",".gitignore",ee])}else D=[pe,".npmignore",".gitignore",ee];return{...super.walkerOpt(e,t),ignoreFiles:D,requiredFiles:this.requiredFiles.map(n=>Q(n,e)===".."?Q(e,n).replace(/\\/g,"/"):!1).filter(Boolean)}}walker(e,t,D){new Fe(this.tree,this.walkerOpt(e,t)).on("done",D).start()}sort(e,t){const D=ot(e).toLowerCase(),n=ot(t).toLowerCase(),i=rt(e).toLowerCase(),s=rt(t).toLowerCase();return D.localeCompare(n,"en")||i.localeCompare(s,"en")||e.localeCompare(t,"en")}injectRules(e,t,D=()=>{}){this.onReadIgnoreFile(e,`${t.join(` +-`)} +-`,D)}processPackage(e){const{bin:t,browser:D,files:n,main:i}=this.tree.package,s=[],o=[...at,...ke,"!/package.json","/.git","/node_modules","/package-lock.json","/yarn.lock","/pnpm-lock.yaml"];if(n){for(let a of n){a.startsWith("/")?a=a.slice(1):a.startsWith("./")?a=a.slice(2):a.endsWith("/*")&&(a=a.slice(0,-2));const h=`!${a}`;this.excludeNonRoot(a);try{const f=Di(K(this.path,a.replace(/^!+/,"")).replace(/\\/g,"/"));f.isFile()?(o.unshift(h),this.requiredFiles.push(a)):f.isDirectory()&&(s.push(h),s.push(`${h}/**`))}catch{s.push(h)}}this.injectRules("package.json",["*",...s])}if(D&&o.push(`!/${D}`),i&&o.push(`!/${i}`),t)for(const a in t)o.push(`!/${t[a]}`);this.injectRules(ee,o,e)}excludeNonRoot(e){const t=li.find(D=>D.test(e));if(t){const D=ke.findIndex(n=>t.test(n));ke.splice(D,1)}}async gatherBundles(){if(this.seen.has(this.tree))return;this.seen.add(this.tree);let e;if(this.tree.isProjectRoot){const{bundleDependencies:t}=this.tree.package;e=t||[]}else{const{dependencies:t,optionalDependencies:D}=this.tree.package;e=Object.keys(t||{}).concat(Object.keys(D||{}))}for(const t of e){const D=this.tree.edgesOut.get(t);if(!D||D.peer||D.dev)continue;const n=this.tree.edgesOut.get(t).to;if(!n)continue;const i=n.path,s=n.target,o={path:i,isPackage:!0,ignoreFiles:[],seen:this.seen};n.isLink&&o.ignoreFiles.push(pe),o.ignoreFiles.push("package.json"),n.isLink&&(o.ignoreFiles.push(".npmignore"),o.ignoreFiles.push(".gitignore")),o.ignoreFiles.push(ee);const a=new Fe(s,o),h=await new Promise((g,F)=>{a.on("error",F),a.on("done",g),a.start()}),f=Q(this.root,a.path);for(const g of h)this.result.add(K(f,g).replace(/\\/g,"/"))}}}const ct=(u,e,t)=>{typeof e=="function"&&(t=e,e={});const D=new Promise((n,i)=>{new Fe(u,{...e,isPackage:!0}).on("done",n).on("error",i).start()});return t?D.then(n=>t(null,n),t):D};var ci=ct;ct.Walker=Fe;var ht=Nu(ci);const hi=async(u,e)=>{const t=A.resolve(u,e),D=await qu(t),n=A.join(u,"node_modules/"),i=A.join(n,D.name);if((await O.stat(i).catch(()=>null))?.isDirectory()){const o=await O.realpath(i);if(o.startsWith(n)){const a=new Map,[h,f]=await Promise.all([ht({path:o,package:D,edgesOut:a}),ht({path:t,package:D,edgesOut:a})]);console.log(`Symlinking ${Je(D.name)}:`),await Promise.all(f.map(async g=>{const F=A.join(t,g),d=A.join(i,g);await O.mkdir(A.dirname(d),{recursive:!0}),await An(F,d);const m=h.indexOf(g);m>-1&&h.splice(m,1),console.log(` ${Tu("\u2714")}`,re(A.relative(u,d)),"\u2192",re(A.relative(u,F)))})),await Promise.all(h.map(async g=>{const F=A.join(i,g);await O.rm(F)}));return}}console.error(W` +- Error: Package ${Je(D.name)} is not set up +- +- ${rn("Setup instructions")} +- 1. In the Dependency package, create a tarball: +- ${ze("$ npm pack")} +- +- 2. In the Consuming package, install the tarball and link the Dependency: +- ${ze("$ npm install --no-save ")} +- ${ze("$ npx link publish ")} +- +- 3. Start developing! +- +- Learn more: https://npmjs.com/link +- `)};var pt={command:XD({name:"publish",parameters:[""],flags:{},help:{description:"Link a package to simulate an environment similar to `npm install`"}}),handler:async(u,e)=>{e.length>0&&await Promise.all(e.map(t=>hi(u,t)))}};(async()=>{const u=HD({name:"link",parameters:["[package paths...]"],flags:{deep:{type:Boolean,alias:"d",description:"Run `npx link` on dependencies if they have a link.config.json"}},help:{description:"A better `npm link` -- symlink local dependencies to the current project",render:(t,D)=>(t[0].data=`npx link +-`,t.splice(2,0,{type:"section",data:{title:"Website",body:"https://www.npmjs.com/package/link"}}),D.render(t))},commands:[pt.command]}),e=await O.realpath(process.cwd());if(u.command)u.command==="publish"&&await pt.handler(e,u._);else{const{packagePaths:t}=u._;if(t.length>0){await Promise.all(t.map(n=>Ju(e,n,u.flags)));return}const D=await Lu(e);if(!D){console.warn(W` ++ var isNumericSequence = /^-?\d+\.\.-?\d+(?:\.\.-?\d+)?$/.test(m.body); ++ var isAlphaSequence = /^[a-zA-Z]\.\.[a-zA-Z](?:\.\.-?\d+)?$/.test(m.body); ++ var isSequence = isNumericSequence || isAlphaSequence; ++ var isOptions = m.body.indexOf(',') >= 0; ++ if (!isSequence && !isOptions) { ++ // {a},b} ++ if (m.post.match(/,.*\}/)) { ++ str = m.pre + '{' + m.body + escClose + m.post; ++ return expand(str); ++ } ++ return [str]; ++ } ++ ++ var n; ++ if (isSequence) { ++ n = m.body.split(/\.\./); ++ } else { ++ n = parseCommaParts(m.body); ++ if (n.length === 1) { ++ // x{{a,b}}y ==> x{a}y x{b}y ++ n = expand(n[0], false).map(embrace); ++ if (n.length === 1) { ++ return post.map(function(p) { ++ return m.pre + n[0] + p; ++ }); ++ } ++ } ++ } ++ ++ // at this point, n is the parts, and we know it's not a comma set ++ // with a single entry. ++ var N; ++ ++ if (isSequence) { ++ var x = numeric(n[0]); ++ var y = numeric(n[1]); ++ var width = Math.max(n[0].length, n[1].length); ++ var incr = n.length == 3 ++ ? Math.abs(numeric(n[2])) ++ : 1; ++ var test = lte; ++ var reverse = y < x; ++ if (reverse) { ++ incr *= -1; ++ test = gte; ++ } ++ var pad = n.some(isPadded); ++ ++ N = []; ++ ++ for (var i = x; test(i, y); i += incr) { ++ var c; ++ if (isAlphaSequence) { ++ c = String.fromCharCode(i); ++ if (c === '\\') ++ c = ''; ++ } else { ++ c = String(i); ++ if (pad) { ++ var need = width - c.length; ++ if (need > 0) { ++ var z = new Array(need + 1).join('0'); ++ if (i < 0) ++ c = '-' + z + c.slice(1); ++ else ++ c = z + c; ++ } ++ } ++ } ++ N.push(c); ++ } ++ } else { ++ N = []; ++ ++ for (var j = 0; j < n.length; j++) { ++ N.push.apply(N, expand(n[j], false)); ++ } ++ } ++ ++ for (var j = 0; j < N.length; j++) { ++ for (var k = 0; k < post.length; k++) { ++ var expansion = pre + N[j] + post[k]; ++ if (!isTop || isSequence || expansion) ++ expansions.push(expansion); ++ } ++ } ++ } ++ ++ return expansions; ++} ++ ++var assertValidPattern$1 = {}; ++ ++Object.defineProperty(assertValidPattern$1, "__esModule", { value: true }); ++assertValidPattern$1.assertValidPattern = void 0; ++const MAX_PATTERN_LENGTH = 1024 * 64; ++const assertValidPattern = (pattern) => { ++ if (typeof pattern !== 'string') { ++ throw new TypeError('invalid pattern'); ++ } ++ if (pattern.length > MAX_PATTERN_LENGTH) { ++ throw new TypeError('pattern is too long'); ++ } ++}; ++assertValidPattern$1.assertValidPattern = assertValidPattern; ++ ++var ast = {}; ++ ++var braceExpressions = {}; ++ ++// translate the various posix character classes into unicode properties ++// this works across all unicode locales ++Object.defineProperty(braceExpressions, "__esModule", { value: true }); ++braceExpressions.parseClass = void 0; ++// { : [, /u flag required, negated] ++const posixClasses = { ++ '[:alnum:]': ['\\p{L}\\p{Nl}\\p{Nd}', true], ++ '[:alpha:]': ['\\p{L}\\p{Nl}', true], ++ '[:ascii:]': ['\\x' + '00-\\x' + '7f', false], ++ '[:blank:]': ['\\p{Zs}\\t', true], ++ '[:cntrl:]': ['\\p{Cc}', true], ++ '[:digit:]': ['\\p{Nd}', true], ++ '[:graph:]': ['\\p{Z}\\p{C}', true, true], ++ '[:lower:]': ['\\p{Ll}', true], ++ '[:print:]': ['\\p{C}', true], ++ '[:punct:]': ['\\p{P}', true], ++ '[:space:]': ['\\p{Z}\\t\\r\\n\\v\\f', true], ++ '[:upper:]': ['\\p{Lu}', true], ++ '[:word:]': ['\\p{L}\\p{Nl}\\p{Nd}\\p{Pc}', true], ++ '[:xdigit:]': ['A-Fa-f0-9', false], ++}; ++// only need to escape a few things inside of brace expressions ++// escapes: [ \ ] - ++const braceEscape = (s) => s.replace(/[[\]\\-]/g, '\\$&'); ++// escape all regexp magic characters ++const regexpEscape = (s) => s.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); ++// everything has already been escaped, we just have to join ++const rangesToString = (ranges) => ranges.join(''); ++// takes a glob string at a posix brace expression, and returns ++// an equivalent regular expression source, and boolean indicating ++// whether the /u flag needs to be applied, and the number of chars ++// consumed to parse the character class. ++// This also removes out of order ranges, and returns ($.) if the ++// entire class just no good. ++const parseClass = (glob, position) => { ++ const pos = position; ++ /* c8 ignore start */ ++ if (glob.charAt(pos) !== '[') { ++ throw new Error('not in a brace expression'); ++ } ++ /* c8 ignore stop */ ++ const ranges = []; ++ const negs = []; ++ let i = pos + 1; ++ let sawStart = false; ++ let uflag = false; ++ let escaping = false; ++ let negate = false; ++ let endPos = pos; ++ let rangeStart = ''; ++ WHILE: while (i < glob.length) { ++ const c = glob.charAt(i); ++ if ((c === '!' || c === '^') && i === pos + 1) { ++ negate = true; ++ i++; ++ continue; ++ } ++ if (c === ']' && sawStart && !escaping) { ++ endPos = i + 1; ++ break; ++ } ++ sawStart = true; ++ if (c === '\\') { ++ if (!escaping) { ++ escaping = true; ++ i++; ++ continue; ++ } ++ // escaped \ char, fall through and treat like normal char ++ } ++ if (c === '[' && !escaping) { ++ // either a posix class, a collation equivalent, or just a [ ++ for (const [cls, [unip, u, neg]] of Object.entries(posixClasses)) { ++ if (glob.startsWith(cls, i)) { ++ // invalid, [a-[] is fine, but not [a-[:alpha]] ++ if (rangeStart) { ++ return ['$.', false, glob.length - pos, true]; ++ } ++ i += cls.length; ++ if (neg) ++ negs.push(unip); ++ else ++ ranges.push(unip); ++ uflag = uflag || u; ++ continue WHILE; ++ } ++ } ++ } ++ // now it's just a normal character, effectively ++ escaping = false; ++ if (rangeStart) { ++ // throw this range away if it's not valid, but others ++ // can still match. ++ if (c > rangeStart) { ++ ranges.push(braceEscape(rangeStart) + '-' + braceEscape(c)); ++ } ++ else if (c === rangeStart) { ++ ranges.push(braceEscape(c)); ++ } ++ rangeStart = ''; ++ i++; ++ continue; ++ } ++ // now might be the start of a range. ++ // can be either c-d or c-] or c] or c] at this point ++ if (glob.startsWith('-]', i + 1)) { ++ ranges.push(braceEscape(c + '-')); ++ i += 2; ++ continue; ++ } ++ if (glob.startsWith('-', i + 1)) { ++ rangeStart = c; ++ i += 2; ++ continue; ++ } ++ // not the start of a range, just a single character ++ ranges.push(braceEscape(c)); ++ i++; ++ } ++ if (endPos < i) { ++ // didn't see the end of the class, not a valid class, ++ // but might still be valid as a literal match. ++ return ['', false, 0, false]; ++ } ++ // if we got no ranges and no negates, then we have a range that ++ // cannot possibly match anything, and that poisons the whole glob ++ if (!ranges.length && !negs.length) { ++ return ['$.', false, glob.length - pos, true]; ++ } ++ // if we got one positive range, and it's a single character, then that's ++ // not actually a magic pattern, it's just that one literal character. ++ // we should not treat that as "magic", we should just return the literal ++ // character. [_] is a perfectly valid way to escape glob magic chars. ++ if (negs.length === 0 && ++ ranges.length === 1 && ++ /^\\?.$/.test(ranges[0]) && ++ !negate) { ++ const r = ranges[0].length === 2 ? ranges[0].slice(-1) : ranges[0]; ++ return [regexpEscape(r), false, endPos - pos, false]; ++ } ++ const sranges = '[' + (negate ? '^' : '') + rangesToString(ranges) + ']'; ++ const snegs = '[' + (negate ? '' : '^') + rangesToString(negs) + ']'; ++ const comb = ranges.length && negs.length ++ ? '(' + sranges + '|' + snegs + ')' ++ : ranges.length ++ ? sranges ++ : snegs; ++ return [comb, uflag, endPos - pos, true]; ++}; ++braceExpressions.parseClass = parseClass; ++ ++var _unescape = {}; ++ ++Object.defineProperty(_unescape, "__esModule", { value: true }); ++_unescape.unescape = void 0; ++/** ++ * Un-escape a string that has been escaped with {@link escape}. ++ * ++ * If the {@link windowsPathsNoEscape} option is used, then square-brace ++ * escapes are removed, but not backslash escapes. For example, it will turn ++ * the string `'[*]'` into `*`, but it will not turn `'\\*'` into `'*'`, ++ * becuase `\` is a path separator in `windowsPathsNoEscape` mode. ++ * ++ * When `windowsPathsNoEscape` is not set, then both brace escapes and ++ * backslash escapes are removed. ++ * ++ * Slashes (and backslashes in `windowsPathsNoEscape` mode) cannot be escaped ++ * or unescaped. ++ */ ++const unescape = (s, { windowsPathsNoEscape = false, } = {}) => { ++ return windowsPathsNoEscape ++ ? s.replace(/\[([^\/\\])\]/g, '$1') ++ : s.replace(/((?!\\).|^)\[([^\/\\])\]/g, '$1$2').replace(/\\([^\/])/g, '$1'); ++}; ++_unescape.unescape = unescape; ++ ++// parse a single path portion ++Object.defineProperty(ast, "__esModule", { value: true }); ++ast.AST = void 0; ++const brace_expressions_js_1 = braceExpressions; ++const unescape_js_1 = _unescape; ++const types = new Set(['!', '?', '+', '*', '@']); ++const isExtglobType = (c) => types.has(c); ++// Patterns that get prepended to bind to the start of either the ++// entire string, or just a single path portion, to prevent dots ++// and/or traversal patterns, when needed. ++// Exts don't need the ^ or / bit, because the root binds that already. ++const startNoTraversal = '(?!(?:^|/)\\.\\.?(?:$|/))'; ++const startNoDot = '(?!\\.)'; ++// characters that indicate a start of pattern needs the "no dots" bit, ++// because a dot *might* be matched. ( is not in the list, because in ++// the case of a child extglob, it will handle the prevention itself. ++const addPatternStart = new Set(['[', '.']); ++// cases where traversal is A-OK, no dot prevention needed ++const justDots = new Set(['..', '.']); ++const reSpecials = new Set('().*{}+?[]^$\\!'); ++const regExpEscape = (s) => s.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); ++// any single thing other than / ++const qmark = '[^/]'; ++// * => any number of characters ++const star = qmark + '*?'; ++// use + when we need to ensure that *something* matches, because the * is ++// the only thing in the path portion. ++const starNoEmpty = qmark + '+?'; ++// remove the \ chars that we added if we end up doing a nonmagic compare ++// const deslash = (s: string) => s.replace(/\\(.)/g, '$1') ++class AST { ++ type; ++ #root; ++ #hasMagic; ++ #uflag = false; ++ #parts = []; ++ #parent; ++ #parentIndex; ++ #negs; ++ #filledNegs = false; ++ #options; ++ #toString; ++ // set to true if it's an extglob with no children ++ // (which really means one child of '') ++ #emptyExt = false; ++ constructor(type, parent, options = {}) { ++ this.type = type; ++ // extglobs are inherently magical ++ if (type) ++ this.#hasMagic = true; ++ this.#parent = parent; ++ this.#root = this.#parent ? this.#parent.#root : this; ++ this.#options = this.#root === this ? options : this.#root.#options; ++ this.#negs = this.#root === this ? [] : this.#root.#negs; ++ if (type === '!' && !this.#root.#filledNegs) ++ this.#negs.push(this); ++ this.#parentIndex = this.#parent ? this.#parent.#parts.length : 0; ++ } ++ get hasMagic() { ++ /* c8 ignore start */ ++ if (this.#hasMagic !== undefined) ++ return this.#hasMagic; ++ /* c8 ignore stop */ ++ for (const p of this.#parts) { ++ if (typeof p === 'string') ++ continue; ++ if (p.type || p.hasMagic) ++ return (this.#hasMagic = true); ++ } ++ // note: will be undefined until we generate the regexp src and find out ++ return this.#hasMagic; ++ } ++ // reconstructs the pattern ++ toString() { ++ if (this.#toString !== undefined) ++ return this.#toString; ++ if (!this.type) { ++ return (this.#toString = this.#parts.map(p => String(p)).join('')); ++ } ++ else { ++ return (this.#toString = ++ this.type + '(' + this.#parts.map(p => String(p)).join('|') + ')'); ++ } ++ } ++ #fillNegs() { ++ /* c8 ignore start */ ++ if (this !== this.#root) ++ throw new Error('should only call on root'); ++ if (this.#filledNegs) ++ return this; ++ /* c8 ignore stop */ ++ // call toString() once to fill this out ++ this.toString(); ++ this.#filledNegs = true; ++ let n; ++ while ((n = this.#negs.pop())) { ++ if (n.type !== '!') ++ continue; ++ // walk up the tree, appending everthing that comes AFTER parentIndex ++ let p = n; ++ let pp = p.#parent; ++ while (pp) { ++ for (let i = p.#parentIndex + 1; !pp.type && i < pp.#parts.length; i++) { ++ for (const part of n.#parts) { ++ /* c8 ignore start */ ++ if (typeof part === 'string') { ++ throw new Error('string part in extglob AST??'); ++ } ++ /* c8 ignore stop */ ++ part.copyIn(pp.#parts[i]); ++ } ++ } ++ p = pp; ++ pp = p.#parent; ++ } ++ } ++ return this; ++ } ++ push(...parts) { ++ for (const p of parts) { ++ if (p === '') ++ continue; ++ /* c8 ignore start */ ++ if (typeof p !== 'string' && !(p instanceof AST && p.#parent === this)) { ++ throw new Error('invalid part: ' + p); ++ } ++ /* c8 ignore stop */ ++ this.#parts.push(p); ++ } ++ } ++ toJSON() { ++ const ret = this.type === null ++ ? this.#parts.slice().map(p => (typeof p === 'string' ? p : p.toJSON())) ++ : [this.type, ...this.#parts.map(p => p.toJSON())]; ++ if (this.isStart() && !this.type) ++ ret.unshift([]); ++ if (this.isEnd() && ++ (this === this.#root || ++ (this.#root.#filledNegs && this.#parent?.type === '!'))) { ++ ret.push({}); ++ } ++ return ret; ++ } ++ isStart() { ++ if (this.#root === this) ++ return true; ++ // if (this.type) return !!this.#parent?.isStart() ++ if (!this.#parent?.isStart()) ++ return false; ++ if (this.#parentIndex === 0) ++ return true; ++ // if everything AHEAD of this is a negation, then it's still the "start" ++ const p = this.#parent; ++ for (let i = 0; i < this.#parentIndex; i++) { ++ const pp = p.#parts[i]; ++ if (!(pp instanceof AST && pp.type === '!')) { ++ return false; ++ } ++ } ++ return true; ++ } ++ isEnd() { ++ if (this.#root === this) ++ return true; ++ if (this.#parent?.type === '!') ++ return true; ++ if (!this.#parent?.isEnd()) ++ return false; ++ if (!this.type) ++ return this.#parent?.isEnd(); ++ // if not root, it'll always have a parent ++ /* c8 ignore start */ ++ const pl = this.#parent ? this.#parent.#parts.length : 0; ++ /* c8 ignore stop */ ++ return this.#parentIndex === pl - 1; ++ } ++ copyIn(part) { ++ if (typeof part === 'string') ++ this.push(part); ++ else ++ this.push(part.clone(this)); ++ } ++ clone(parent) { ++ const c = new AST(this.type, parent); ++ for (const p of this.#parts) { ++ c.copyIn(p); ++ } ++ return c; ++ } ++ static #parseAST(str, ast, pos, opt) { ++ let escaping = false; ++ let inBrace = false; ++ let braceStart = -1; ++ let braceNeg = false; ++ if (ast.type === null) { ++ // outside of a extglob, append until we find a start ++ let i = pos; ++ let acc = ''; ++ while (i < str.length) { ++ const c = str.charAt(i++); ++ // still accumulate escapes at this point, but we do ignore ++ // starts that are escaped ++ if (escaping || c === '\\') { ++ escaping = !escaping; ++ acc += c; ++ continue; ++ } ++ if (inBrace) { ++ if (i === braceStart + 1) { ++ if (c === '^' || c === '!') { ++ braceNeg = true; ++ } ++ } ++ else if (c === ']' && !(i === braceStart + 2 && braceNeg)) { ++ inBrace = false; ++ } ++ acc += c; ++ continue; ++ } ++ else if (c === '[') { ++ inBrace = true; ++ braceStart = i; ++ braceNeg = false; ++ acc += c; ++ continue; ++ } ++ if (!opt.noext && isExtglobType(c) && str.charAt(i) === '(') { ++ ast.push(acc); ++ acc = ''; ++ const ext = new AST(c, ast); ++ i = AST.#parseAST(str, ext, i, opt); ++ ast.push(ext); ++ continue; ++ } ++ acc += c; ++ } ++ ast.push(acc); ++ return i; ++ } ++ // some kind of extglob, pos is at the ( ++ // find the next | or ) ++ let i = pos + 1; ++ let part = new AST(null, ast); ++ const parts = []; ++ let acc = ''; ++ while (i < str.length) { ++ const c = str.charAt(i++); ++ // still accumulate escapes at this point, but we do ignore ++ // starts that are escaped ++ if (escaping || c === '\\') { ++ escaping = !escaping; ++ acc += c; ++ continue; ++ } ++ if (inBrace) { ++ if (i === braceStart + 1) { ++ if (c === '^' || c === '!') { ++ braceNeg = true; ++ } ++ } ++ else if (c === ']' && !(i === braceStart + 2 && braceNeg)) { ++ inBrace = false; ++ } ++ acc += c; ++ continue; ++ } ++ else if (c === '[') { ++ inBrace = true; ++ braceStart = i; ++ braceNeg = false; ++ acc += c; ++ continue; ++ } ++ if (isExtglobType(c) && str.charAt(i) === '(') { ++ part.push(acc); ++ acc = ''; ++ const ext = new AST(c, part); ++ part.push(ext); ++ i = AST.#parseAST(str, ext, i, opt); ++ continue; ++ } ++ if (c === '|') { ++ part.push(acc); ++ acc = ''; ++ parts.push(part); ++ part = new AST(null, ast); ++ continue; ++ } ++ if (c === ')') { ++ if (acc === '' && ast.#parts.length === 0) { ++ ast.#emptyExt = true; ++ } ++ part.push(acc); ++ acc = ''; ++ ast.push(...parts, part); ++ return i; ++ } ++ acc += c; ++ } ++ // unfinished extglob ++ // if we got here, it was a malformed extglob! not an extglob, but ++ // maybe something else in there. ++ ast.type = null; ++ ast.#hasMagic = undefined; ++ ast.#parts = [str.substring(pos - 1)]; ++ return i; ++ } ++ static fromGlob(pattern, options = {}) { ++ const ast = new AST(null, undefined, options); ++ AST.#parseAST(pattern, ast, 0, options); ++ return ast; ++ } ++ // returns the regular expression if there's magic, or the unescaped ++ // string if not. ++ toMMPattern() { ++ // should only be called on root ++ /* c8 ignore start */ ++ if (this !== this.#root) ++ return this.#root.toMMPattern(); ++ /* c8 ignore stop */ ++ const glob = this.toString(); ++ const [re, body, hasMagic, uflag] = this.toRegExpSource(); ++ // if we're in nocase mode, and not nocaseMagicOnly, then we do ++ // still need a regular expression if we have to case-insensitively ++ // match capital/lowercase characters. ++ const anyMagic = hasMagic || ++ this.#hasMagic || ++ (this.#options.nocase && ++ !this.#options.nocaseMagicOnly && ++ glob.toUpperCase() !== glob.toLowerCase()); ++ if (!anyMagic) { ++ return body; ++ } ++ const flags = (this.#options.nocase ? 'i' : '') + (uflag ? 'u' : ''); ++ return Object.assign(new RegExp(`^${re}$`, flags), { ++ _src: re, ++ _glob: glob, ++ }); ++ } ++ get options() { ++ return this.#options; ++ } ++ // returns the string match, the regexp source, whether there's magic ++ // in the regexp (so a regular expression is required) and whether or ++ // not the uflag is needed for the regular expression (for posix classes) ++ // TODO: instead of injecting the start/end at this point, just return ++ // the BODY of the regexp, along with the start/end portions suitable ++ // for binding the start/end in either a joined full-path makeRe context ++ // (where we bind to (^|/), or a standalone matchPart context (where ++ // we bind to ^, and not /). Otherwise slashes get duped! ++ // ++ // In part-matching mode, the start is: ++ // - if not isStart: nothing ++ // - if traversal possible, but not allowed: ^(?!\.\.?$) ++ // - if dots allowed or not possible: ^ ++ // - if dots possible and not allowed: ^(?!\.) ++ // end is: ++ // - if not isEnd(): nothing ++ // - else: $ ++ // ++ // In full-path matching mode, we put the slash at the START of the ++ // pattern, so start is: ++ // - if first pattern: same as part-matching mode ++ // - if not isStart(): nothing ++ // - if traversal possible, but not allowed: /(?!\.\.?(?:$|/)) ++ // - if dots allowed or not possible: / ++ // - if dots possible and not allowed: /(?!\.) ++ // end is: ++ // - if last pattern, same as part-matching mode ++ // - else nothing ++ // ++ // Always put the (?:$|/) on negated tails, though, because that has to be ++ // there to bind the end of the negated pattern portion, and it's easier to ++ // just stick it in now rather than try to inject it later in the middle of ++ // the pattern. ++ // ++ // We can just always return the same end, and leave it up to the caller ++ // to know whether it's going to be used joined or in parts. ++ // And, if the start is adjusted slightly, can do the same there: ++ // - if not isStart: nothing ++ // - if traversal possible, but not allowed: (?:/|^)(?!\.\.?$) ++ // - if dots allowed or not possible: (?:/|^) ++ // - if dots possible and not allowed: (?:/|^)(?!\.) ++ // ++ // But it's better to have a simpler binding without a conditional, for ++ // performance, so probably better to return both start options. ++ // ++ // Then the caller just ignores the end if it's not the first pattern, ++ // and the start always gets applied. ++ // ++ // But that's always going to be $ if it's the ending pattern, or nothing, ++ // so the caller can just attach $ at the end of the pattern when building. ++ // ++ // So the todo is: ++ // - better detect what kind of start is needed ++ // - return both flavors of starting pattern ++ // - attach $ at the end of the pattern when creating the actual RegExp ++ // ++ // Ah, but wait, no, that all only applies to the root when the first pattern ++ // is not an extglob. If the first pattern IS an extglob, then we need all ++ // that dot prevention biz to live in the extglob portions, because eg ++ // +(*|.x*) can match .xy but not .yx. ++ // ++ // So, return the two flavors if it's #root and the first child is not an ++ // AST, otherwise leave it to the child AST to handle it, and there, ++ // use the (?:^|/) style of start binding. ++ // ++ // Even simplified further: ++ // - Since the start for a join is eg /(?!\.) and the start for a part ++ // is ^(?!\.), we can just prepend (?!\.) to the pattern (either root ++ // or start or whatever) and prepend ^ or / at the Regexp construction. ++ toRegExpSource(allowDot) { ++ const dot = allowDot ?? !!this.#options.dot; ++ if (this.#root === this) ++ this.#fillNegs(); ++ if (!this.type) { ++ const noEmpty = this.isStart() && this.isEnd(); ++ const src = this.#parts ++ .map(p => { ++ const [re, _, hasMagic, uflag] = typeof p === 'string' ++ ? AST.#parseGlob(p, this.#hasMagic, noEmpty) ++ : p.toRegExpSource(allowDot); ++ this.#hasMagic = this.#hasMagic || hasMagic; ++ this.#uflag = this.#uflag || uflag; ++ return re; ++ }) ++ .join(''); ++ let start = ''; ++ if (this.isStart()) { ++ if (typeof this.#parts[0] === 'string') { ++ // this is the string that will match the start of the pattern, ++ // so we need to protect against dots and such. ++ // '.' and '..' cannot match unless the pattern is that exactly, ++ // even if it starts with . or dot:true is set. ++ const dotTravAllowed = this.#parts.length === 1 && justDots.has(this.#parts[0]); ++ if (!dotTravAllowed) { ++ const aps = addPatternStart; ++ // check if we have a possibility of matching . or .., ++ // and prevent that. ++ const needNoTrav = ++ // dots are allowed, and the pattern starts with [ or . ++ (dot && aps.has(src.charAt(0))) || ++ // the pattern starts with \., and then [ or . ++ (src.startsWith('\\.') && aps.has(src.charAt(2))) || ++ // the pattern starts with \.\., and then [ or . ++ (src.startsWith('\\.\\.') && aps.has(src.charAt(4))); ++ // no need to prevent dots if it can't match a dot, or if a ++ // sub-pattern will be preventing it anyway. ++ const needNoDot = !dot && !allowDot && aps.has(src.charAt(0)); ++ start = needNoTrav ? startNoTraversal : needNoDot ? startNoDot : ''; ++ } ++ } ++ } ++ // append the "end of path portion" pattern to negation tails ++ let end = ''; ++ if (this.isEnd() && ++ this.#root.#filledNegs && ++ this.#parent?.type === '!') { ++ end = '(?:$|\\/)'; ++ } ++ const final = start + src + end; ++ return [ ++ final, ++ (0, unescape_js_1.unescape)(src), ++ (this.#hasMagic = !!this.#hasMagic), ++ this.#uflag, ++ ]; ++ } ++ // We need to calculate the body *twice* if it's a repeat pattern ++ // at the start, once in nodot mode, then again in dot mode, so a ++ // pattern like *(?) can match 'x.y' ++ const repeated = this.type === '*' || this.type === '+'; ++ // some kind of extglob ++ const start = this.type === '!' ? '(?:(?!(?:' : '(?:'; ++ let body = this.#partsToRegExp(dot); ++ if (this.isStart() && this.isEnd() && !body && this.type !== '!') { ++ // invalid extglob, has to at least be *something* present, if it's ++ // the entire path portion. ++ const s = this.toString(); ++ this.#parts = [s]; ++ this.type = null; ++ this.#hasMagic = undefined; ++ return [s, (0, unescape_js_1.unescape)(this.toString()), false, false]; ++ } ++ // XXX abstract out this map method ++ let bodyDotAllowed = !repeated || allowDot || dot || !startNoDot ++ ? '' ++ : this.#partsToRegExp(true); ++ if (bodyDotAllowed === body) { ++ bodyDotAllowed = ''; ++ } ++ if (bodyDotAllowed) { ++ body = `(?:${body})(?:${bodyDotAllowed})*?`; ++ } ++ // an empty !() is exactly equivalent to a starNoEmpty ++ let final = ''; ++ if (this.type === '!' && this.#emptyExt) { ++ final = (this.isStart() && !dot ? startNoDot : '') + starNoEmpty; ++ } ++ else { ++ const close = this.type === '!' ++ ? // !() must match something,but !(x) can match '' ++ '))' + ++ (this.isStart() && !dot && !allowDot ? startNoDot : '') + ++ star + ++ ')' ++ : this.type === '@' ++ ? ')' ++ : this.type === '?' ++ ? ')?' ++ : this.type === '+' && bodyDotAllowed ++ ? ')' ++ : this.type === '*' && bodyDotAllowed ++ ? `)?` ++ : `)${this.type}`; ++ final = start + body + close; ++ } ++ return [ ++ final, ++ (0, unescape_js_1.unescape)(body), ++ (this.#hasMagic = !!this.#hasMagic), ++ this.#uflag, ++ ]; ++ } ++ #partsToRegExp(dot) { ++ return this.#parts ++ .map(p => { ++ // extglob ASTs should only contain parent ASTs ++ /* c8 ignore start */ ++ if (typeof p === 'string') { ++ throw new Error('string type in extglob ast??'); ++ } ++ /* c8 ignore stop */ ++ // can ignore hasMagic, because extglobs are already always magic ++ const [re, _, _hasMagic, uflag] = p.toRegExpSource(dot); ++ this.#uflag = this.#uflag || uflag; ++ return re; ++ }) ++ .filter(p => !(this.isStart() && this.isEnd()) || !!p) ++ .join('|'); ++ } ++ static #parseGlob(glob, hasMagic, noEmpty = false) { ++ let escaping = false; ++ let re = ''; ++ let uflag = false; ++ for (let i = 0; i < glob.length; i++) { ++ const c = glob.charAt(i); ++ if (escaping) { ++ escaping = false; ++ re += (reSpecials.has(c) ? '\\' : '') + c; ++ continue; ++ } ++ if (c === '\\') { ++ if (i === glob.length - 1) { ++ re += '\\\\'; ++ } ++ else { ++ escaping = true; ++ } ++ continue; ++ } ++ if (c === '[') { ++ const [src, needUflag, consumed, magic] = (0, brace_expressions_js_1.parseClass)(glob, i); ++ if (consumed) { ++ re += src; ++ uflag = uflag || needUflag; ++ i += consumed - 1; ++ hasMagic = hasMagic || magic; ++ continue; ++ } ++ } ++ if (c === '*') { ++ if (noEmpty && glob === '*') ++ re += starNoEmpty; ++ else ++ re += star; ++ hasMagic = true; ++ continue; ++ } ++ if (c === '?') { ++ re += qmark; ++ hasMagic = true; ++ continue; ++ } ++ re += regExpEscape(c); ++ } ++ return [re, (0, unescape_js_1.unescape)(glob), !!hasMagic, uflag]; ++ } ++} ++ast.AST = AST; ++ ++var _escape = {}; ++ ++Object.defineProperty(_escape, "__esModule", { value: true }); ++_escape.escape = void 0; ++/** ++ * Escape all magic characters in a glob pattern. ++ * ++ * If the {@link windowsPathsNoEscape | GlobOptions.windowsPathsNoEscape} ++ * option is used, then characters are escaped by wrapping in `[]`, because ++ * a magic character wrapped in a character class can only be satisfied by ++ * that exact character. In this mode, `\` is _not_ escaped, because it is ++ * not interpreted as a magic character, but instead as a path separator. ++ */ ++const escape = (s, { windowsPathsNoEscape = false, } = {}) => { ++ // don't need to escape +@! because we escape the parens ++ // that make those magic, and escaping ! as [!] isn't valid, ++ // because [!]] is a valid glob class meaning not ']'. ++ return windowsPathsNoEscape ++ ? s.replace(/[?*()[\]]/g, '[$&]') ++ : s.replace(/[?*()[\]\\]/g, '\\$&'); ++}; ++_escape.escape = escape; ++ ++(function (exports) { ++ var __importDefault = (commonjsGlobal && commonjsGlobal.__importDefault) || function (mod) { ++ return (mod && mod.__esModule) ? mod : { "default": mod }; ++ }; ++ Object.defineProperty(exports, "__esModule", { value: true }); ++ exports.unescape = exports.escape = exports.AST = exports.Minimatch = exports.match = exports.makeRe = exports.braceExpand = exports.defaults = exports.filter = exports.GLOBSTAR = exports.sep = exports.minimatch = void 0; ++ const brace_expansion_1 = __importDefault(braceExpansion); ++ const assert_valid_pattern_js_1 = assertValidPattern$1; ++ const ast_js_1 = ast; ++ const escape_js_1 = _escape; ++ const unescape_js_1 = _unescape; ++ const minimatch = (p, pattern, options = {}) => { ++ (0, assert_valid_pattern_js_1.assertValidPattern)(pattern); ++ // shortcut: comments match nothing. ++ if (!options.nocomment && pattern.charAt(0) === '#') { ++ return false; ++ } ++ return new Minimatch(pattern, options).match(p); ++ }; ++ exports.minimatch = minimatch; ++ // Optimized checking for the most common glob patterns. ++ const starDotExtRE = /^\*+([^+@!?\*\[\(]*)$/; ++ const starDotExtTest = (ext) => (f) => !f.startsWith('.') && f.endsWith(ext); ++ const starDotExtTestDot = (ext) => (f) => f.endsWith(ext); ++ const starDotExtTestNocase = (ext) => { ++ ext = ext.toLowerCase(); ++ return (f) => !f.startsWith('.') && f.toLowerCase().endsWith(ext); ++ }; ++ const starDotExtTestNocaseDot = (ext) => { ++ ext = ext.toLowerCase(); ++ return (f) => f.toLowerCase().endsWith(ext); ++ }; ++ const starDotStarRE = /^\*+\.\*+$/; ++ const starDotStarTest = (f) => !f.startsWith('.') && f.includes('.'); ++ const starDotStarTestDot = (f) => f !== '.' && f !== '..' && f.includes('.'); ++ const dotStarRE = /^\.\*+$/; ++ const dotStarTest = (f) => f !== '.' && f !== '..' && f.startsWith('.'); ++ const starRE = /^\*+$/; ++ const starTest = (f) => f.length !== 0 && !f.startsWith('.'); ++ const starTestDot = (f) => f.length !== 0 && f !== '.' && f !== '..'; ++ const qmarksRE = /^\?+([^+@!?\*\[\(]*)?$/; ++ const qmarksTestNocase = ([$0, ext = '']) => { ++ const noext = qmarksTestNoExt([$0]); ++ if (!ext) ++ return noext; ++ ext = ext.toLowerCase(); ++ return (f) => noext(f) && f.toLowerCase().endsWith(ext); ++ }; ++ const qmarksTestNocaseDot = ([$0, ext = '']) => { ++ const noext = qmarksTestNoExtDot([$0]); ++ if (!ext) ++ return noext; ++ ext = ext.toLowerCase(); ++ return (f) => noext(f) && f.toLowerCase().endsWith(ext); ++ }; ++ const qmarksTestDot = ([$0, ext = '']) => { ++ const noext = qmarksTestNoExtDot([$0]); ++ return !ext ? noext : (f) => noext(f) && f.endsWith(ext); ++ }; ++ const qmarksTest = ([$0, ext = '']) => { ++ const noext = qmarksTestNoExt([$0]); ++ return !ext ? noext : (f) => noext(f) && f.endsWith(ext); ++ }; ++ const qmarksTestNoExt = ([$0]) => { ++ const len = $0.length; ++ return (f) => f.length === len && !f.startsWith('.'); ++ }; ++ const qmarksTestNoExtDot = ([$0]) => { ++ const len = $0.length; ++ return (f) => f.length === len && f !== '.' && f !== '..'; ++ }; ++ /* c8 ignore start */ ++ const defaultPlatform = (typeof process === 'object' && process ++ ? (typeof process.env === 'object' && ++ process.env && ++ process.env.__MINIMATCH_TESTING_PLATFORM__) || ++ process.platform ++ : 'posix'); ++ const path = { ++ win32: { sep: '\\' }, ++ posix: { sep: '/' }, ++ }; ++ /* c8 ignore stop */ ++ exports.sep = defaultPlatform === 'win32' ? path.win32.sep : path.posix.sep; ++ exports.minimatch.sep = exports.sep; ++ exports.GLOBSTAR = Symbol('globstar **'); ++ exports.minimatch.GLOBSTAR = exports.GLOBSTAR; ++ // any single thing other than / ++ // don't need to escape / when using new RegExp() ++ const qmark = '[^/]'; ++ // * => any number of characters ++ const star = qmark + '*?'; ++ // ** when dots are allowed. Anything goes, except .. and . ++ // not (^ or / followed by one or two dots followed by $ or /), ++ // followed by anything, any number of times. ++ const twoStarDot = '(?:(?!(?:\\/|^)(?:\\.{1,2})($|\\/)).)*?'; ++ // not a ^ or / followed by a dot, ++ // followed by anything, any number of times. ++ const twoStarNoDot = '(?:(?!(?:\\/|^)\\.).)*?'; ++ const filter = (pattern, options = {}) => (p) => (0, exports.minimatch)(p, pattern, options); ++ exports.filter = filter; ++ exports.minimatch.filter = exports.filter; ++ const ext = (a, b = {}) => Object.assign({}, a, b); ++ const defaults = (def) => { ++ if (!def || typeof def !== 'object' || !Object.keys(def).length) { ++ return exports.minimatch; ++ } ++ const orig = exports.minimatch; ++ const m = (p, pattern, options = {}) => orig(p, pattern, ext(def, options)); ++ return Object.assign(m, { ++ Minimatch: class Minimatch extends orig.Minimatch { ++ constructor(pattern, options = {}) { ++ super(pattern, ext(def, options)); ++ } ++ static defaults(options) { ++ return orig.defaults(ext(def, options)).Minimatch; ++ } ++ }, ++ AST: class AST extends orig.AST { ++ /* c8 ignore start */ ++ constructor(type, parent, options = {}) { ++ super(type, parent, ext(def, options)); ++ } ++ /* c8 ignore stop */ ++ static fromGlob(pattern, options = {}) { ++ return orig.AST.fromGlob(pattern, ext(def, options)); ++ } ++ }, ++ unescape: (s, options = {}) => orig.unescape(s, ext(def, options)), ++ escape: (s, options = {}) => orig.escape(s, ext(def, options)), ++ filter: (pattern, options = {}) => orig.filter(pattern, ext(def, options)), ++ defaults: (options) => orig.defaults(ext(def, options)), ++ makeRe: (pattern, options = {}) => orig.makeRe(pattern, ext(def, options)), ++ braceExpand: (pattern, options = {}) => orig.braceExpand(pattern, ext(def, options)), ++ match: (list, pattern, options = {}) => orig.match(list, pattern, ext(def, options)), ++ sep: orig.sep, ++ GLOBSTAR: exports.GLOBSTAR, ++ }); ++ }; ++ exports.defaults = defaults; ++ exports.minimatch.defaults = exports.defaults; ++ // Brace expansion: ++ // a{b,c}d -> abd acd ++ // a{b,}c -> abc ac ++ // a{0..3}d -> a0d a1d a2d a3d ++ // a{b,c{d,e}f}g -> abg acdfg acefg ++ // a{b,c}d{e,f}g -> abdeg acdeg abdeg abdfg ++ // ++ // Invalid sets are not expanded. ++ // a{2..}b -> a{2..}b ++ // a{b}c -> a{b}c ++ const braceExpand = (pattern, options = {}) => { ++ (0, assert_valid_pattern_js_1.assertValidPattern)(pattern); ++ // Thanks to Yeting Li for ++ // improving this regexp to avoid a ReDOS vulnerability. ++ if (options.nobrace || !/\{(?:(?!\{).)*\}/.test(pattern)) { ++ // shortcut. no need to expand. ++ return [pattern]; ++ } ++ return (0, brace_expansion_1.default)(pattern); ++ }; ++ exports.braceExpand = braceExpand; ++ exports.minimatch.braceExpand = exports.braceExpand; ++ // parse a component of the expanded set. ++ // At this point, no pattern may contain "/" in it ++ // so we're going to return a 2d array, where each entry is the full ++ // pattern, split on '/', and then turned into a regular expression. ++ // A regexp is made at the end which joins each array with an ++ // escaped /, and another full one which joins each regexp with |. ++ // ++ // Following the lead of Bash 4.1, note that "**" only has special meaning ++ // when it is the *only* thing in a path portion. Otherwise, any series ++ // of * is equivalent to a single *. Globstar behavior is enabled by ++ // default, and can be disabled by setting options.noglobstar. ++ const makeRe = (pattern, options = {}) => new Minimatch(pattern, options).makeRe(); ++ exports.makeRe = makeRe; ++ exports.minimatch.makeRe = exports.makeRe; ++ const match = (list, pattern, options = {}) => { ++ const mm = new Minimatch(pattern, options); ++ list = list.filter(f => mm.match(f)); ++ if (mm.options.nonull && !list.length) { ++ list.push(pattern); ++ } ++ return list; ++ }; ++ exports.match = match; ++ exports.minimatch.match = exports.match; ++ // replace stuff like \* with * ++ const globMagic = /[?*]|[+@!]\(.*?\)|\[|\]/; ++ const regExpEscape = (s) => s.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); ++ class Minimatch { ++ options; ++ set; ++ pattern; ++ windowsPathsNoEscape; ++ nonegate; ++ negate; ++ comment; ++ empty; ++ preserveMultipleSlashes; ++ partial; ++ globSet; ++ globParts; ++ nocase; ++ isWindows; ++ platform; ++ windowsNoMagicRoot; ++ regexp; ++ constructor(pattern, options = {}) { ++ (0, assert_valid_pattern_js_1.assertValidPattern)(pattern); ++ options = options || {}; ++ this.options = options; ++ this.pattern = pattern; ++ this.platform = options.platform || defaultPlatform; ++ this.isWindows = this.platform === 'win32'; ++ this.windowsPathsNoEscape = ++ !!options.windowsPathsNoEscape || options.allowWindowsEscape === false; ++ if (this.windowsPathsNoEscape) { ++ this.pattern = this.pattern.replace(/\\/g, '/'); ++ } ++ this.preserveMultipleSlashes = !!options.preserveMultipleSlashes; ++ this.regexp = null; ++ this.negate = false; ++ this.nonegate = !!options.nonegate; ++ this.comment = false; ++ this.empty = false; ++ this.partial = !!options.partial; ++ this.nocase = !!this.options.nocase; ++ this.windowsNoMagicRoot = ++ options.windowsNoMagicRoot !== undefined ++ ? options.windowsNoMagicRoot ++ : !!(this.isWindows && this.nocase); ++ this.globSet = []; ++ this.globParts = []; ++ this.set = []; ++ // make the set of regexps etc. ++ this.make(); ++ } ++ hasMagic() { ++ if (this.options.magicalBraces && this.set.length > 1) { ++ return true; ++ } ++ for (const pattern of this.set) { ++ for (const part of pattern) { ++ if (typeof part !== 'string') ++ return true; ++ } ++ } ++ return false; ++ } ++ debug(..._) { } ++ make() { ++ const pattern = this.pattern; ++ const options = this.options; ++ // empty patterns and comments match nothing. ++ if (!options.nocomment && pattern.charAt(0) === '#') { ++ this.comment = true; ++ return; ++ } ++ if (!pattern) { ++ this.empty = true; ++ return; ++ } ++ // step 1: figure out negation, etc. ++ this.parseNegate(); ++ // step 2: expand braces ++ this.globSet = [...new Set(this.braceExpand())]; ++ if (options.debug) { ++ this.debug = (...args) => console.error(...args); ++ } ++ this.debug(this.pattern, this.globSet); ++ // step 3: now we have a set, so turn each one into a series of ++ // path-portion matching patterns. ++ // These will be regexps, except in the case of "**", which is ++ // set to the GLOBSTAR object for globstar behavior, ++ // and will not contain any / characters ++ // ++ // First, we preprocess to make the glob pattern sets a bit simpler ++ // and deduped. There are some perf-killing patterns that can cause ++ // problems with a glob walk, but we can simplify them down a bit. ++ const rawGlobParts = this.globSet.map(s => this.slashSplit(s)); ++ this.globParts = this.preprocess(rawGlobParts); ++ this.debug(this.pattern, this.globParts); ++ // glob --> regexps ++ let set = this.globParts.map((s, _, __) => { ++ if (this.isWindows && this.windowsNoMagicRoot) { ++ // check if it's a drive or unc path. ++ const isUNC = s[0] === '' && ++ s[1] === '' && ++ (s[2] === '?' || !globMagic.test(s[2])) && ++ !globMagic.test(s[3]); ++ const isDrive = /^[a-z]:/i.test(s[0]); ++ if (isUNC) { ++ return [...s.slice(0, 4), ...s.slice(4).map(ss => this.parse(ss))]; ++ } ++ else if (isDrive) { ++ return [s[0], ...s.slice(1).map(ss => this.parse(ss))]; ++ } ++ } ++ return s.map(ss => this.parse(ss)); ++ }); ++ this.debug(this.pattern, set); ++ // filter out everything that didn't compile properly. ++ this.set = set.filter(s => s.indexOf(false) === -1); ++ // do not treat the ? in UNC paths as magic ++ if (this.isWindows) { ++ for (let i = 0; i < this.set.length; i++) { ++ const p = this.set[i]; ++ if (p[0] === '' && ++ p[1] === '' && ++ this.globParts[i][2] === '?' && ++ typeof p[3] === 'string' && ++ /^[a-z]:$/i.test(p[3])) { ++ p[2] = '?'; ++ } ++ } ++ } ++ this.debug(this.pattern, this.set); ++ } ++ // various transforms to equivalent pattern sets that are ++ // faster to process in a filesystem walk. The goal is to ++ // eliminate what we can, and push all ** patterns as far ++ // to the right as possible, even if it increases the number ++ // of patterns that we have to process. ++ preprocess(globParts) { ++ // if we're not in globstar mode, then turn all ** into * ++ if (this.options.noglobstar) { ++ for (let i = 0; i < globParts.length; i++) { ++ for (let j = 0; j < globParts[i].length; j++) { ++ if (globParts[i][j] === '**') { ++ globParts[i][j] = '*'; ++ } ++ } ++ } ++ } ++ const { optimizationLevel = 1 } = this.options; ++ if (optimizationLevel >= 2) { ++ // aggressive optimization for the purpose of fs walking ++ globParts = this.firstPhasePreProcess(globParts); ++ globParts = this.secondPhasePreProcess(globParts); ++ } ++ else if (optimizationLevel >= 1) { ++ // just basic optimizations to remove some .. parts ++ globParts = this.levelOneOptimize(globParts); ++ } ++ else { ++ // just collapse multiple ** portions into one ++ globParts = this.adjascentGlobstarOptimize(globParts); ++ } ++ return globParts; ++ } ++ // just get rid of adjascent ** portions ++ adjascentGlobstarOptimize(globParts) { ++ return globParts.map(parts => { ++ let gs = -1; ++ while (-1 !== (gs = parts.indexOf('**', gs + 1))) { ++ let i = gs; ++ while (parts[i + 1] === '**') { ++ i++; ++ } ++ if (i !== gs) { ++ parts.splice(gs, i - gs); ++ } ++ } ++ return parts; ++ }); ++ } ++ // get rid of adjascent ** and resolve .. portions ++ levelOneOptimize(globParts) { ++ return globParts.map(parts => { ++ parts = parts.reduce((set, part) => { ++ const prev = set[set.length - 1]; ++ if (part === '**' && prev === '**') { ++ return set; ++ } ++ if (part === '..') { ++ if (prev && prev !== '..' && prev !== '.' && prev !== '**') { ++ set.pop(); ++ return set; ++ } ++ } ++ set.push(part); ++ return set; ++ }, []); ++ return parts.length === 0 ? [''] : parts; ++ }); ++ } ++ levelTwoFileOptimize(parts) { ++ if (!Array.isArray(parts)) { ++ parts = this.slashSplit(parts); ++ } ++ let didSomething = false; ++ do { ++ didSomething = false; ++ //

// -> 
/
++	            if (!this.preserveMultipleSlashes) {
++	                for (let i = 1; i < parts.length - 1; i++) {
++	                    const p = parts[i];
++	                    // don't squeeze out UNC patterns
++	                    if (i === 1 && p === '' && parts[0] === '')
++	                        continue;
++	                    if (p === '.' || p === '') {
++	                        didSomething = true;
++	                        parts.splice(i, 1);
++	                        i--;
++	                    }
++	                }
++	                if (parts[0] === '.' &&
++	                    parts.length === 2 &&
++	                    (parts[1] === '.' || parts[1] === '')) {
++	                    didSomething = true;
++	                    parts.pop();
++	                }
++	            }
++	            // 
/

/../ ->

/
++	            let dd = 0;
++	            while (-1 !== (dd = parts.indexOf('..', dd + 1))) {
++	                const p = parts[dd - 1];
++	                if (p && p !== '.' && p !== '..' && p !== '**') {
++	                    didSomething = true;
++	                    parts.splice(dd - 1, 2);
++	                    dd -= 2;
++	                }
++	            }
++	        } while (didSomething);
++	        return parts.length === 0 ? [''] : parts;
++	    }
++	    // First phase: single-pattern processing
++	    // 
 is 1 or more portions
++	    //  is 1 or more portions
++	    // 

is any portion other than ., .., '', or ** ++ // is . or '' ++ // ++ // **/.. is *brutal* for filesystem walking performance, because ++ // it effectively resets the recursive walk each time it occurs, ++ // and ** cannot be reduced out by a .. pattern part like a regexp ++ // or most strings (other than .., ., and '') can be. ++ // ++ //

/**/../

/

/ -> {

/../

/

/,

/**/

/

/} ++ //

// -> 
/
++	    // 
/

/../ ->

/
++	    // **/**/ -> **/
++	    //
++	    // **/*/ -> */**/ <== not valid because ** doesn't follow
++	    // this WOULD be allowed if ** did follow symlinks, or * didn't
++	    firstPhasePreProcess(globParts) {
++	        let didSomething = false;
++	        do {
++	            didSomething = false;
++	            // 
/**/../

/

/ -> {

/../

/

/,

/**/

/

/} ++ for (let parts of globParts) { ++ let gs = -1; ++ while (-1 !== (gs = parts.indexOf('**', gs + 1))) { ++ let gss = gs; ++ while (parts[gss + 1] === '**') { ++ //

/**/**/ -> 
/**/
++	                        gss++;
++	                    }
++	                    // eg, if gs is 2 and gss is 4, that means we have 3 **
++	                    // parts, and can remove 2 of them.
++	                    if (gss > gs) {
++	                        parts.splice(gs + 1, gss - gs);
++	                    }
++	                    let next = parts[gs + 1];
++	                    const p = parts[gs + 2];
++	                    const p2 = parts[gs + 3];
++	                    if (next !== '..')
++	                        continue;
++	                    if (!p ||
++	                        p === '.' ||
++	                        p === '..' ||
++	                        !p2 ||
++	                        p2 === '.' ||
++	                        p2 === '..') {
++	                        continue;
++	                    }
++	                    didSomething = true;
++	                    // edit parts in place, and push the new one
++	                    parts.splice(gs, 1);
++	                    const other = parts.slice(0);
++	                    other[gs] = '**';
++	                    globParts.push(other);
++	                    gs--;
++	                }
++	                // 
// -> 
/
++	                if (!this.preserveMultipleSlashes) {
++	                    for (let i = 1; i < parts.length - 1; i++) {
++	                        const p = parts[i];
++	                        // don't squeeze out UNC patterns
++	                        if (i === 1 && p === '' && parts[0] === '')
++	                            continue;
++	                        if (p === '.' || p === '') {
++	                            didSomething = true;
++	                            parts.splice(i, 1);
++	                            i--;
++	                        }
++	                    }
++	                    if (parts[0] === '.' &&
++	                        parts.length === 2 &&
++	                        (parts[1] === '.' || parts[1] === '')) {
++	                        didSomething = true;
++	                        parts.pop();
++	                    }
++	                }
++	                // 
/

/../ ->

/
++	                let dd = 0;
++	                while (-1 !== (dd = parts.indexOf('..', dd + 1))) {
++	                    const p = parts[dd - 1];
++	                    if (p && p !== '.' && p !== '..' && p !== '**') {
++	                        didSomething = true;
++	                        const needDot = dd === 1 && parts[dd + 1] === '**';
++	                        const splin = needDot ? ['.'] : [];
++	                        parts.splice(dd - 1, 2, ...splin);
++	                        if (parts.length === 0)
++	                            parts.push('');
++	                        dd -= 2;
++	                    }
++	                }
++	            }
++	        } while (didSomething);
++	        return globParts;
++	    }
++	    // second phase: multi-pattern dedupes
++	    // {
/*/,
/

/} ->

/*/
++	    // {
/,
/} -> 
/
++	    // {
/**/,
/} -> 
/**/
++	    //
++	    // {
/**/,
/**/

/} ->

/**/
++	    // ^-- not valid because ** doens't follow symlinks
++	    secondPhasePreProcess(globParts) {
++	        for (let i = 0; i < globParts.length - 1; i++) {
++	            for (let j = i + 1; j < globParts.length; j++) {
++	                const matched = this.partsMatch(globParts[i], globParts[j], !this.preserveMultipleSlashes);
++	                if (matched) {
++	                    globParts[i] = [];
++	                    globParts[j] = matched;
++	                    break;
++	                }
++	            }
++	        }
++	        return globParts.filter(gs => gs.length);
++	    }
++	    partsMatch(a, b, emptyGSMatch = false) {
++	        let ai = 0;
++	        let bi = 0;
++	        let result = [];
++	        let which = '';
++	        while (ai < a.length && bi < b.length) {
++	            if (a[ai] === b[bi]) {
++	                result.push(which === 'b' ? b[bi] : a[ai]);
++	                ai++;
++	                bi++;
++	            }
++	            else if (emptyGSMatch && a[ai] === '**' && b[bi] === a[ai + 1]) {
++	                result.push(a[ai]);
++	                ai++;
++	            }
++	            else if (emptyGSMatch && b[bi] === '**' && a[ai] === b[bi + 1]) {
++	                result.push(b[bi]);
++	                bi++;
++	            }
++	            else if (a[ai] === '*' &&
++	                b[bi] &&
++	                (this.options.dot || !b[bi].startsWith('.')) &&
++	                b[bi] !== '**') {
++	                if (which === 'b')
++	                    return false;
++	                which = 'a';
++	                result.push(a[ai]);
++	                ai++;
++	                bi++;
++	            }
++	            else if (b[bi] === '*' &&
++	                a[ai] &&
++	                (this.options.dot || !a[ai].startsWith('.')) &&
++	                a[ai] !== '**') {
++	                if (which === 'a')
++	                    return false;
++	                which = 'b';
++	                result.push(b[bi]);
++	                ai++;
++	                bi++;
++	            }
++	            else {
++	                return false;
++	            }
++	        }
++	        // if we fall out of the loop, it means they two are identical
++	        // as long as their lengths match
++	        return a.length === b.length && result;
++	    }
++	    parseNegate() {
++	        if (this.nonegate)
++	            return;
++	        const pattern = this.pattern;
++	        let negate = false;
++	        let negateOffset = 0;
++	        for (let i = 0; i < pattern.length && pattern.charAt(i) === '!'; i++) {
++	            negate = !negate;
++	            negateOffset++;
++	        }
++	        if (negateOffset)
++	            this.pattern = pattern.slice(negateOffset);
++	        this.negate = negate;
++	    }
++	    // set partial to true to test if, for example,
++	    // "/a/b" matches the start of "/*/b/*/d"
++	    // Partial means, if you run out of file before you run
++	    // out of pattern, then that's fine, as long as all
++	    // the parts match.
++	    matchOne(file, pattern, partial = false) {
++	        const options = this.options;
++	        // UNC paths like //?/X:/... can match X:/... and vice versa
++	        // Drive letters in absolute drive or unc paths are always compared
++	        // case-insensitively.
++	        if (this.isWindows) {
++	            const fileDrive = typeof file[0] === 'string' && /^[a-z]:$/i.test(file[0]);
++	            const fileUNC = !fileDrive &&
++	                file[0] === '' &&
++	                file[1] === '' &&
++	                file[2] === '?' &&
++	                /^[a-z]:$/i.test(file[3]);
++	            const patternDrive = typeof pattern[0] === 'string' && /^[a-z]:$/i.test(pattern[0]);
++	            const patternUNC = !patternDrive &&
++	                pattern[0] === '' &&
++	                pattern[1] === '' &&
++	                pattern[2] === '?' &&
++	                typeof pattern[3] === 'string' &&
++	                /^[a-z]:$/i.test(pattern[3]);
++	            const fdi = fileUNC ? 3 : fileDrive ? 0 : undefined;
++	            const pdi = patternUNC ? 3 : patternDrive ? 0 : undefined;
++	            if (typeof fdi === 'number' && typeof pdi === 'number') {
++	                const [fd, pd] = [file[fdi], pattern[pdi]];
++	                if (fd.toLowerCase() === pd.toLowerCase()) {
++	                    pattern[pdi] = fd;
++	                    if (pdi > fdi) {
++	                        pattern = pattern.slice(pdi);
++	                    }
++	                    else if (fdi > pdi) {
++	                        file = file.slice(fdi);
++	                    }
++	                }
++	            }
++	        }
++	        // resolve and reduce . and .. portions in the file as well.
++	        // dont' need to do the second phase, because it's only one string[]
++	        const { optimizationLevel = 1 } = this.options;
++	        if (optimizationLevel >= 2) {
++	            file = this.levelTwoFileOptimize(file);
++	        }
++	        this.debug('matchOne', this, { file, pattern });
++	        this.debug('matchOne', file.length, pattern.length);
++	        for (var fi = 0, pi = 0, fl = file.length, pl = pattern.length; fi < fl && pi < pl; fi++, pi++) {
++	            this.debug('matchOne loop');
++	            var p = pattern[pi];
++	            var f = file[fi];
++	            this.debug(pattern, p, f);
++	            // should be impossible.
++	            // some invalid regexp stuff in the set.
++	            /* c8 ignore start */
++	            if (p === false) {
++	                return false;
++	            }
++	            /* c8 ignore stop */
++	            if (p === exports.GLOBSTAR) {
++	                this.debug('GLOBSTAR', [pattern, p, f]);
++	                // "**"
++	                // a/**/b/**/c would match the following:
++	                // a/b/x/y/z/c
++	                // a/x/y/z/b/c
++	                // a/b/x/b/x/c
++	                // a/b/c
++	                // To do this, take the rest of the pattern after
++	                // the **, and see if it would match the file remainder.
++	                // If so, return success.
++	                // If not, the ** "swallows" a segment, and try again.
++	                // This is recursively awful.
++	                //
++	                // a/**/b/**/c matching a/b/x/y/z/c
++	                // - a matches a
++	                // - doublestar
++	                //   - matchOne(b/x/y/z/c, b/**/c)
++	                //     - b matches b
++	                //     - doublestar
++	                //       - matchOne(x/y/z/c, c) -> no
++	                //       - matchOne(y/z/c, c) -> no
++	                //       - matchOne(z/c, c) -> no
++	                //       - matchOne(c, c) yes, hit
++	                var fr = fi;
++	                var pr = pi + 1;
++	                if (pr === pl) {
++	                    this.debug('** at the end');
++	                    // a ** at the end will just swallow the rest.
++	                    // We have found a match.
++	                    // however, it will not swallow /.x, unless
++	                    // options.dot is set.
++	                    // . and .. are *never* matched by **, for explosively
++	                    // exponential reasons.
++	                    for (; fi < fl; fi++) {
++	                        if (file[fi] === '.' ||
++	                            file[fi] === '..' ||
++	                            (!options.dot && file[fi].charAt(0) === '.'))
++	                            return false;
++	                    }
++	                    return true;
++	                }
++	                // ok, let's see if we can swallow whatever we can.
++	                while (fr < fl) {
++	                    var swallowee = file[fr];
++	                    this.debug('\nglobstar while', file, fr, pattern, pr, swallowee);
++	                    // XXX remove this slice.  Just pass the start index.
++	                    if (this.matchOne(file.slice(fr), pattern.slice(pr), partial)) {
++	                        this.debug('globstar found match!', fr, fl, swallowee);
++	                        // found a match.
++	                        return true;
++	                    }
++	                    else {
++	                        // can't swallow "." or ".." ever.
++	                        // can only swallow ".foo" when explicitly asked.
++	                        if (swallowee === '.' ||
++	                            swallowee === '..' ||
++	                            (!options.dot && swallowee.charAt(0) === '.')) {
++	                            this.debug('dot detected!', file, fr, pattern, pr);
++	                            break;
++	                        }
++	                        // ** swallows a segment, and continue.
++	                        this.debug('globstar swallow a segment, and continue');
++	                        fr++;
++	                    }
++	                }
++	                // no match was found.
++	                // However, in partial mode, we can't say this is necessarily over.
++	                /* c8 ignore start */
++	                if (partial) {
++	                    // ran out of file
++	                    this.debug('\n>>> no match, partial?', file, fr, pattern, pr);
++	                    if (fr === fl) {
++	                        return true;
++	                    }
++	                }
++	                /* c8 ignore stop */
++	                return false;
++	            }
++	            // something other than **
++	            // non-magic patterns just have to match exactly
++	            // patterns with magic have been turned into regexps.
++	            let hit;
++	            if (typeof p === 'string') {
++	                hit = f === p;
++	                this.debug('string match', p, f, hit);
++	            }
++	            else {
++	                hit = p.test(f);
++	                this.debug('pattern match', p, f, hit);
++	            }
++	            if (!hit)
++	                return false;
++	        }
++	        // Note: ending in / means that we'll get a final ""
++	        // at the end of the pattern.  This can only match a
++	        // corresponding "" at the end of the file.
++	        // If the file ends in /, then it can only match a
++	        // a pattern that ends in /, unless the pattern just
++	        // doesn't have any more for it. But, a/b/ should *not*
++	        // match "a/b/*", even though "" matches against the
++	        // [^/]*? pattern, except in partial mode, where it might
++	        // simply not be reached yet.
++	        // However, a/b/ should still satisfy a/*
++	        // now either we fell off the end of the pattern, or we're done.
++	        if (fi === fl && pi === pl) {
++	            // ran out of pattern and filename at the same time.
++	            // an exact hit!
++	            return true;
++	        }
++	        else if (fi === fl) {
++	            // ran out of file, but still had pattern left.
++	            // this is ok if we're doing the match as part of
++	            // a glob fs traversal.
++	            return partial;
++	        }
++	        else if (pi === pl) {
++	            // ran out of pattern, still have file left.
++	            // this is only acceptable if we're on the very last
++	            // empty segment of a file with a trailing slash.
++	            // a/* should match a/b/
++	            return fi === fl - 1 && file[fi] === '';
++	            /* c8 ignore start */
++	        }
++	        else {
++	            // should be unreachable.
++	            throw new Error('wtf?');
++	        }
++	        /* c8 ignore stop */
++	    }
++	    braceExpand() {
++	        return (0, exports.braceExpand)(this.pattern, this.options);
++	    }
++	    parse(pattern) {
++	        (0, assert_valid_pattern_js_1.assertValidPattern)(pattern);
++	        const options = this.options;
++	        // shortcuts
++	        if (pattern === '**')
++	            return exports.GLOBSTAR;
++	        if (pattern === '')
++	            return '';
++	        // far and away, the most common glob pattern parts are
++	        // *, *.*, and *.  Add a fast check method for those.
++	        let m;
++	        let fastTest = null;
++	        if ((m = pattern.match(starRE))) {
++	            fastTest = options.dot ? starTestDot : starTest;
++	        }
++	        else if ((m = pattern.match(starDotExtRE))) {
++	            fastTest = (options.nocase
++	                ? options.dot
++	                    ? starDotExtTestNocaseDot
++	                    : starDotExtTestNocase
++	                : options.dot
++	                    ? starDotExtTestDot
++	                    : starDotExtTest)(m[1]);
++	        }
++	        else if ((m = pattern.match(qmarksRE))) {
++	            fastTest = (options.nocase
++	                ? options.dot
++	                    ? qmarksTestNocaseDot
++	                    : qmarksTestNocase
++	                : options.dot
++	                    ? qmarksTestDot
++	                    : qmarksTest)(m);
++	        }
++	        else if ((m = pattern.match(starDotStarRE))) {
++	            fastTest = options.dot ? starDotStarTestDot : starDotStarTest;
++	        }
++	        else if ((m = pattern.match(dotStarRE))) {
++	            fastTest = dotStarTest;
++	        }
++	        const re = ast_js_1.AST.fromGlob(pattern, this.options).toMMPattern();
++	        if (fastTest && typeof re === 'object') {
++	            // Avoids overriding in frozen environments
++	            Reflect.defineProperty(re, 'test', { value: fastTest });
++	        }
++	        return re;
++	    }
++	    makeRe() {
++	        if (this.regexp || this.regexp === false)
++	            return this.regexp;
++	        // at this point, this.set is a 2d array of partial
++	        // pattern strings, or "**".
++	        //
++	        // It's better to use .match().  This function shouldn't
++	        // be used, really, but it's pretty convenient sometimes,
++	        // when you just want to work with a regex.
++	        const set = this.set;
++	        if (!set.length) {
++	            this.regexp = false;
++	            return this.regexp;
++	        }
++	        const options = this.options;
++	        const twoStar = options.noglobstar
++	            ? star
++	            : options.dot
++	                ? twoStarDot
++	                : twoStarNoDot;
++	        const flags = new Set(options.nocase ? ['i'] : []);
++	        // regexpify non-globstar patterns
++	        // if ** is only item, then we just do one twoStar
++	        // if ** is first, and there are more, prepend (\/|twoStar\/)? to next
++	        // if ** is last, append (\/twoStar|) to previous
++	        // if ** is in the middle, append (\/|\/twoStar\/) to previous
++	        // then filter out GLOBSTAR symbols
++	        let re = set
++	            .map(pattern => {
++	            const pp = pattern.map(p => {
++	                if (p instanceof RegExp) {
++	                    for (const f of p.flags.split(''))
++	                        flags.add(f);
++	                }
++	                return typeof p === 'string'
++	                    ? regExpEscape(p)
++	                    : p === exports.GLOBSTAR
++	                        ? exports.GLOBSTAR
++	                        : p._src;
++	            });
++	            pp.forEach((p, i) => {
++	                const next = pp[i + 1];
++	                const prev = pp[i - 1];
++	                if (p !== exports.GLOBSTAR || prev === exports.GLOBSTAR) {
++	                    return;
++	                }
++	                if (prev === undefined) {
++	                    if (next !== undefined && next !== exports.GLOBSTAR) {
++	                        pp[i + 1] = '(?:\\/|' + twoStar + '\\/)?' + next;
++	                    }
++	                    else {
++	                        pp[i] = twoStar;
++	                    }
++	                }
++	                else if (next === undefined) {
++	                    pp[i - 1] = prev + '(?:\\/|' + twoStar + ')?';
++	                }
++	                else if (next !== exports.GLOBSTAR) {
++	                    pp[i - 1] = prev + '(?:\\/|\\/' + twoStar + '\\/)' + next;
++	                    pp[i + 1] = exports.GLOBSTAR;
++	                }
++	            });
++	            return pp.filter(p => p !== exports.GLOBSTAR).join('/');
++	        })
++	            .join('|');
++	        // need to wrap in parens if we had more than one thing with |,
++	        // otherwise only the first will be anchored to ^ and the last to $
++	        const [open, close] = set.length > 1 ? ['(?:', ')'] : ['', ''];
++	        // must match entire pattern
++	        // ending in a * or ** will make it less strict.
++	        re = '^' + open + re + close + '$';
++	        // can match anything, as long as it's not this.
++	        if (this.negate)
++	            re = '^(?!' + re + ').+$';
++	        try {
++	            this.regexp = new RegExp(re, [...flags].join(''));
++	            /* c8 ignore start */
++	        }
++	        catch (ex) {
++	            // should be impossible
++	            this.regexp = false;
++	        }
++	        /* c8 ignore stop */
++	        return this.regexp;
++	    }
++	    slashSplit(p) {
++	        // if p starts with // on windows, we preserve that
++	        // so that UNC paths aren't broken.  Otherwise, any number of
++	        // / characters are coalesced into one, unless
++	        // preserveMultipleSlashes is set to true.
++	        if (this.preserveMultipleSlashes) {
++	            return p.split('/');
++	        }
++	        else if (this.isWindows && /^\/\/[^\/]+/.test(p)) {
++	            // add an extra '' for the one we lose
++	            return ['', ...p.split(/\/+/)];
++	        }
++	        else {
++	            return p.split(/\/+/);
++	        }
++	    }
++	    match(f, partial = this.partial) {
++	        this.debug('match', f, this.pattern);
++	        // short-circuit in the case of busted things.
++	        // comments, etc.
++	        if (this.comment) {
++	            return false;
++	        }
++	        if (this.empty) {
++	            return f === '';
++	        }
++	        if (f === '/' && partial) {
++	            return true;
++	        }
++	        const options = this.options;
++	        // windows: need to use /, not \
++	        if (this.isWindows) {
++	            f = f.split('\\').join('/');
++	        }
++	        // treat the test path as a set of pathparts.
++	        const ff = this.slashSplit(f);
++	        this.debug(this.pattern, 'split', ff);
++	        // just ONE of the pattern sets in this.set needs to match
++	        // in order for it to be valid.  If negating, then just one
++	        // match means that we have failed.
++	        // Either way, return on the first hit.
++	        const set = this.set;
++	        this.debug(this.pattern, 'set', set);
++	        // Find the basename of the path by looking for the last non-empty segment
++	        let filename = ff[ff.length - 1];
++	        if (!filename) {
++	            for (let i = ff.length - 2; !filename && i >= 0; i--) {
++	                filename = ff[i];
++	            }
++	        }
++	        for (let i = 0; i < set.length; i++) {
++	            const pattern = set[i];
++	            let file = ff;
++	            if (options.matchBase && pattern.length === 1) {
++	                file = [filename];
++	            }
++	            const hit = this.matchOne(file, pattern, partial);
++	            if (hit) {
++	                if (options.flipNegate) {
++	                    return true;
++	                }
++	                return !this.negate;
++	            }
++	        }
++	        // didn't get any hits.  this is success if it's a negative
++	        // pattern, failure otherwise.
++	        if (options.flipNegate) {
++	            return false;
++	        }
++	        return this.negate;
++	    }
++	    static defaults(def) {
++	        return exports.minimatch.defaults(def).Minimatch;
++	    }
++	}
++	exports.Minimatch = Minimatch;
++	/* c8 ignore start */
++	var ast_js_2 = ast;
++	Object.defineProperty(exports, "AST", { enumerable: true, get: function () { return ast_js_2.AST; } });
++	var escape_js_2 = _escape;
++	Object.defineProperty(exports, "escape", { enumerable: true, get: function () { return escape_js_2.escape; } });
++	var unescape_js_2 = _unescape;
++	Object.defineProperty(exports, "unescape", { enumerable: true, get: function () { return unescape_js_2.unescape; } });
++	/* c8 ignore stop */
++	exports.minimatch.AST = ast_js_1.AST;
++	exports.minimatch.Minimatch = Minimatch;
++	exports.minimatch.escape = escape_js_1.escape;
++	exports.minimatch.unescape = unescape_js_1.unescape;
++	
++} (commonjs));
++
++const fs = require$$0;
++const path = path$1;
++const EE = require$$2.EventEmitter;
++const Minimatch = commonjs.Minimatch;
++
++class Walker extends EE {
++  constructor (opts) {
++    opts = opts || {};
++    super(opts);
++    // set to true if this.path is a symlink, whether follow is true or not
++    this.isSymbolicLink = opts.isSymbolicLink;
++    this.path = opts.path || process.cwd();
++    this.basename = path.basename(this.path);
++    this.ignoreFiles = opts.ignoreFiles || ['.ignore'];
++    this.ignoreRules = {};
++    this.parent = opts.parent || null;
++    this.includeEmpty = !!opts.includeEmpty;
++    this.root = this.parent ? this.parent.root : this.path;
++    this.follow = !!opts.follow;
++    this.result = this.parent ? this.parent.result : new Set();
++    this.entries = null;
++    this.sawError = false;
++    this.exact = opts.exact;
++  }
++
++  sort (a, b) {
++    return a.localeCompare(b, 'en')
++  }
++
++  emit (ev, data) {
++    let ret = false;
++    if (!(this.sawError && ev === 'error')) {
++      if (ev === 'error') {
++        this.sawError = true;
++      } else if (ev === 'done' && !this.parent) {
++        data = Array.from(data)
++          .map(e => /^@/.test(e) ? `./${e}` : e).sort(this.sort);
++        this.result = data;
++      }
++
++      if (ev === 'error' && this.parent) {
++        ret = this.parent.emit('error', data);
++      } else {
++        ret = super.emit(ev, data);
++      }
++    }
++    return ret
++  }
++
++  start () {
++    fs.readdir(this.path, (er, entries) =>
++      er ? this.emit('error', er) : this.onReaddir(entries));
++    return this
++  }
++
++  isIgnoreFile (e) {
++    return e !== '.' &&
++      e !== '..' &&
++      this.ignoreFiles.indexOf(e) !== -1
++  }
++
++  onReaddir (entries) {
++    this.entries = entries;
++    if (entries.length === 0) {
++      if (this.includeEmpty) {
++        this.result.add(this.path.slice(this.root.length + 1));
++      }
++      this.emit('done', this.result);
++    } else {
++      const hasIg = this.entries.some(e =>
++        this.isIgnoreFile(e));
++
++      if (hasIg) {
++        this.addIgnoreFiles();
++      } else {
++        this.filterEntries();
++      }
++    }
++  }
++
++  addIgnoreFiles () {
++    const newIg = this.entries
++      .filter(e => this.isIgnoreFile(e));
++
++    let igCount = newIg.length;
++    const then = () => {
++      if (--igCount === 0) {
++        this.filterEntries();
++      }
++    };
++
++    newIg.forEach(e => this.addIgnoreFile(e, then));
++  }
++
++  addIgnoreFile (file, then) {
++    const ig = path.resolve(this.path, file);
++    fs.readFile(ig, 'utf8', (er, data) =>
++      er ? this.emit('error', er) : this.onReadIgnoreFile(file, data, then));
++  }
++
++  onReadIgnoreFile (file, data, then) {
++    const mmopt = {
++      matchBase: true,
++      dot: true,
++      flipNegate: true,
++      nocase: true,
++    };
++    const rules = data.split(/\r?\n/)
++      .filter(line => !/^#|^$/.test(line.trim()))
++      .map(rule => {
++        return new Minimatch(rule.trim(), mmopt)
++      });
++
++    this.ignoreRules[file] = rules;
++
++    then();
++  }
++
++  filterEntries () {
++    // at this point we either have ignore rules, or just inheriting
++    // this exclusion is at the point where we know the list of
++    // entries in the dir, but don't know what they are.  since
++    // some of them *might* be directories, we have to run the
++    // match in dir-mode as well, so that we'll pick up partials
++    // of files that will be included later.  Anything included
++    // at this point will be checked again later once we know
++    // what it is.
++    const filtered = this.entries.map(entry => {
++      // at this point, we don't know if it's a dir or not.
++      const passFile = this.filterEntry(entry);
++      const passDir = this.filterEntry(entry, true);
++      return (passFile || passDir) ? [entry, passFile, passDir] : false
++    }).filter(e => e);
++
++    // now we stat them all
++    // if it's a dir, and passes as a dir, then recurse
++    // if it's not a dir, but passes as a file, add to set
++    let entryCount = filtered.length;
++    if (entryCount === 0) {
++      this.emit('done', this.result);
++    } else {
++      const then = () => {
++        if (--entryCount === 0) {
++          this.emit('done', this.result);
++        }
++      };
++      filtered.forEach(filt => {
++        const entry = filt[0];
++        const file = filt[1];
++        const dir = filt[2];
++        this.stat({ entry, file, dir }, then);
++      });
++    }
++  }
++
++  onstat ({ st, entry, file, dir, isSymbolicLink }, then) {
++    const abs = this.path + '/' + entry;
++    if (!st.isDirectory()) {
++      if (file) {
++        this.result.add(abs.slice(this.root.length + 1));
++      }
++      then();
++    } else {
++      // is a directory
++      if (dir) {
++        this.walker(entry, { isSymbolicLink, exact: file || this.filterEntry(entry + '/') }, then);
++      } else {
++        then();
++      }
++    }
++  }
++
++  stat ({ entry, file, dir }, then) {
++    const abs = this.path + '/' + entry;
++    fs.lstat(abs, (lstatErr, lstatResult) => {
++      if (lstatErr) {
++        this.emit('error', lstatErr);
++      } else {
++        const isSymbolicLink = lstatResult.isSymbolicLink();
++        if (this.follow && isSymbolicLink) {
++          fs.stat(abs, (statErr, statResult) => {
++            if (statErr) {
++              this.emit('error', statErr);
++            } else {
++              this.onstat({ st: statResult, entry, file, dir, isSymbolicLink }, then);
++            }
++          });
++        } else {
++          this.onstat({ st: lstatResult, entry, file, dir, isSymbolicLink }, then);
++        }
++      }
++    });
++  }
++
++  walkerOpt (entry, opts) {
++    return {
++      path: this.path + '/' + entry,
++      parent: this,
++      ignoreFiles: this.ignoreFiles,
++      follow: this.follow,
++      includeEmpty: this.includeEmpty,
++      ...opts,
++    }
++  }
++
++  walker (entry, opts, then) {
++    new Walker(this.walkerOpt(entry, opts)).on('done', then).start();
++  }
++
++  filterEntry (entry, partial, entryBasename) {
++    let included = true;
++
++    // this = /a/b/c
++    // entry = d
++    // parent /a/b sees c/d
++    if (this.parent && this.parent.filterEntry) {
++      const parentEntry = this.basename + '/' + entry;
++      const parentBasename = entryBasename || entry;
++      included = this.parent.filterEntry(parentEntry, partial, parentBasename);
++      if (!included && !this.exact) {
++        return false
++      }
++    }
++
++    this.ignoreFiles.forEach(f => {
++      if (this.ignoreRules[f]) {
++        this.ignoreRules[f].forEach(rule => {
++          // negation means inclusion
++          // so if it's negated, and already included, no need to check
++          // likewise if it's neither negated nor included
++          if (rule.negate !== included) {
++            const isRelativeRule = entryBasename && rule.globParts.some(part =>
++              part.length <= (part.slice(-1)[0] ? 1 : 2)
++            );
++
++            // first, match against /foo/bar
++            // then, against foo/bar
++            // then, in the case of partials, match with a /
++            //   then, if also the rule is relative, match against basename
++            const match = rule.match('/' + entry) ||
++              rule.match(entry) ||
++              !!partial && (
++                rule.match('/' + entry + '/') ||
++                rule.match(entry + '/') ||
++                rule.negate && (
++                  rule.match('/' + entry, true) ||
++                  rule.match(entry, true)) ||
++                isRelativeRule && (
++                  rule.match('/' + entryBasename + '/') ||
++                  rule.match(entryBasename + '/') ||
++                  rule.negate && (
++                    rule.match('/' + entryBasename, true) ||
++                    rule.match(entryBasename, true))));
++
++            if (match) {
++              included = rule.negate;
++            }
++          }
++        });
++      }
++    });
++
++    return included
++  }
++}
++
++class WalkerSync extends Walker {
++  start () {
++    this.onReaddir(fs.readdirSync(this.path));
++    return this
++  }
++
++  addIgnoreFile (file, then) {
++    const ig = path.resolve(this.path, file);
++    this.onReadIgnoreFile(file, fs.readFileSync(ig, 'utf8'), then);
++  }
++
++  stat ({ entry, file, dir }, then) {
++    const abs = this.path + '/' + entry;
++    let st = fs.lstatSync(abs);
++    const isSymbolicLink = st.isSymbolicLink();
++    if (this.follow && isSymbolicLink) {
++      st = fs.statSync(abs);
++    }
++
++    // console.error('STAT SYNC', {st, entry, file, dir, isSymbolicLink, then})
++    this.onstat({ st, entry, file, dir, isSymbolicLink }, then);
++  }
++
++  walker (entry, opts, then) {
++    new WalkerSync(this.walkerOpt(entry, opts)).start();
++    then();
++  }
++}
++
++const walk$1 = (opts, callback) => {
++  const p = new Promise((resolve, reject) => {
++    new Walker(opts).on('done', resolve).on('error', reject).start();
++  });
++  return callback ? p.then(res => callback(null, res), callback) : p
++};
++
++const walkSync = opts => new WalkerSync(opts).start().result;
++
++var lib$1 = walk$1;
++walk$1.sync = walkSync;
++walk$1.Walker = Walker;
++walk$1.WalkerSync = WalkerSync;
++
++const { Walker: IgnoreWalker } = lib$1;
++const { lstatSync: lstat, readFileSync: readFile } = require$$0;
++const { basename, dirname, extname, join, relative, resolve, sep } = path$1;
++
++// symbols used to represent synthetic rule sets
++const defaultRules = Symbol('npm-packlist.rules.default');
++const strictRules = Symbol('npm-packlist.rules.strict');
++
++// There may be others, but :?|<> are handled by node-tar
++const nameIsBadForWindows = file => /\*/.test(file);
++
++// these are the default rules that are applied to everything except for non-link bundled deps
++const defaults = [
++  '.npmignore',
++  '.gitignore',
++  '**/.git',
++  '**/.svn',
++  '**/.hg',
++  '**/CVS',
++  '**/.git/**',
++  '**/.svn/**',
++  '**/.hg/**',
++  '**/CVS/**',
++  '/.lock-wscript',
++  '/.wafpickle-*',
++  '/build/config.gypi',
++  'npm-debug.log',
++  '**/.npmrc',
++  '.*.swp',
++  '.DS_Store',
++  '**/.DS_Store/**',
++  '._*',
++  '**/._*/**',
++  '*.orig',
++  '/archived-packages/**',
++];
++
++const strictDefaults = [
++  // these are forcibly excluded
++  '/.git',
++];
++
++const normalizePath = (path) => path.split('\\').join('/');
++
++const readOutOfTreeIgnoreFiles = (root, rel, result = []) => {
++  for (const file of ['.npmignore', '.gitignore']) {
++    try {
++      const ignoreContent = readFile(join(root, file), { encoding: 'utf8' });
++      result.push(ignoreContent);
++      // break the loop immediately after reading, this allows us to prioritize
++      // the .npmignore and discard the .gitignore if one is present
++      break
++    } catch (err) {
++      // we ignore ENOENT errors completely because we don't care if the file doesn't exist
++      // but we throw everything else because failing to read a file that does exist is
++      // something that the user likely wants to know about
++      // istanbul ignore next -- we do not need to test a thrown error
++      if (err.code !== 'ENOENT') {
++        throw err
++      }
++    }
++  }
++
++  if (!rel) {
++    return result
++  }
++
++  const firstRel = rel.split(sep, 1)[0];
++  const newRoot = join(root, firstRel);
++  const newRel = relative(newRoot, join(root, rel));
++
++  return readOutOfTreeIgnoreFiles(newRoot, newRel, result)
++};
++
++class PackWalker extends IgnoreWalker {
++  constructor (tree, opts) {
++    const options = {
++      ...opts,
++      includeEmpty: false,
++      follow: false,
++      // we path.resolve() here because ignore-walk doesn't do it and we want full paths
++      path: resolve(opts?.path || tree.path).replace(/\\/g, '/'),
++      ignoreFiles: opts?.ignoreFiles || [
++        defaultRules,
++        'package.json',
++        '.npmignore',
++        '.gitignore',
++        strictRules,
++      ],
++    };
++
++    super(options);
++    this.isPackage = options.isPackage;
++    this.seen = options.seen || new Set();
++    this.tree = tree;
++    this.requiredFiles = options.requiredFiles || [];
++
++    const additionalDefaults = [];
++    if (options.prefix && options.workspaces) {
++      const path = normalizePath(options.path);
++      const prefix = normalizePath(options.prefix);
++      const workspaces = options.workspaces.map((ws) => normalizePath(ws));
++
++      // istanbul ignore else - this does nothing unless we need it to
++      if (path !== prefix && workspaces.includes(path)) {
++        // if path and prefix are not the same directory, and workspaces has path in it
++        // then we know path is a workspace directory. in order to not drop ignore rules
++        // from directories between the workspaces root (prefix) and the workspace itself
++        // (path) we need to find and read those now
++        const relpath = relative(options.prefix, dirname(options.path));
++        additionalDefaults.push(...readOutOfTreeIgnoreFiles(options.prefix, relpath));
++      } else if (path === prefix) {
++        // on the other hand, if the path and prefix are the same, then we ignore workspaces
++        // so that we don't pack a workspace as part of the root project. append them as
++        // normalized relative paths from the root
++        additionalDefaults.push(...workspaces.map((w) => normalizePath(relative(options.path, w))));
++      }
++    }
++
++    // go ahead and inject the default rules now
++    this.injectRules(defaultRules, [...defaults, ...additionalDefaults]);
++
++    if (!this.isPackage) {
++      // if this instance is not a package, then place some strict default rules, and append
++      // known required files for this directory
++      this.injectRules(strictRules, [
++        ...strictDefaults,
++        ...this.requiredFiles.map((file) => `!${file}`),
++      ]);
++    }
++  }
++
++  // overridden method: we intercept the reading of the package.json file here so that we can
++  // process it into both the package.json file rules as well as the strictRules synthetic rule set
++  addIgnoreFile (file, callback) {
++    // if we're adding anything other than package.json, then let ignore-walk handle it
++    if (file !== 'package.json' || !this.isPackage) {
++      return super.addIgnoreFile(file, callback)
++    }
++
++    return this.processPackage(callback)
++  }
++
++  // overridden method: if we're done, but we're a package, then we also need to evaluate bundles
++  // before we actually emit our done event
++  emit (ev, data) {
++    if (ev !== 'done' || !this.isPackage) {
++      return super.emit(ev, data)
++    }
++
++    // we intentionally delay the done event while keeping the function sync here
++    // eslint-disable-next-line promise/catch-or-return, promise/always-return
++    this.gatherBundles().then(() => {
++      super.emit('done', this.result);
++    });
++    return true
++  }
++
++  // overridden method: before actually filtering, we make sure that we've removed the rules for
++  // files that should no longer take effect due to our order of precedence
++  filterEntries () {
++    if (this.ignoreRules['package.json']) {
++      // package.json means no .npmignore or .gitignore
++      this.ignoreRules['.npmignore'] = null;
++      this.ignoreRules['.gitignore'] = null;
++    } else if (this.ignoreRules['.npmignore']) {
++      // .npmignore means no .gitignore
++      this.ignoreRules['.gitignore'] = null;
++    }
++
++    return super.filterEntries()
++  }
++
++  // overridden method: we never want to include anything that isn't a file or directory
++  onstat (opts, callback) {
++    if (!opts.st.isFile() && !opts.st.isDirectory()) {
++      return callback()
++    }
++
++    return super.onstat(opts, callback)
++  }
++
++  // overridden method: we want to refuse to pack files that are invalid, node-tar protects us from
++  // a lot of them but not all
++  stat (opts, callback) {
++    if (nameIsBadForWindows(opts.entry)) {
++      return callback()
++    }
++
++    return super.stat(opts, callback)
++  }
++
++  // overridden method: this is called to create options for a child walker when we step
++  // in to a normal child directory (this will never be a bundle). the default method here
++  // copies the root's `ignoreFiles` value, but we don't want to respect package.json for
++  // subdirectories, so we override it with a list that intentionally omits package.json
++  walkerOpt (entry, opts) {
++    let ignoreFiles = null;
++
++    // however, if we have a tree, and we have workspaces, and the directory we're about
++    // to step into is a workspace, then we _do_ want to respect its package.json
++    if (this.tree.workspaces) {
++      const workspaceDirs = [...this.tree.workspaces.values()]
++        .map((dir) => dir.replace(/\\/g, '/'));
++
++      const entryPath = join(this.path, entry).replace(/\\/g, '/');
++      if (workspaceDirs.includes(entryPath)) {
++        ignoreFiles = [
++          defaultRules,
++          'package.json',
++          '.npmignore',
++          '.gitignore',
++          strictRules,
++        ];
++      }
++    } else {
++      ignoreFiles = [
++        defaultRules,
++        '.npmignore',
++        '.gitignore',
++        strictRules,
++      ];
++    }
++
++    return {
++      ...super.walkerOpt(entry, opts),
++      ignoreFiles,
++      // we map over our own requiredFiles and pass ones that are within this entry
++      requiredFiles: this.requiredFiles
++        .map((file) => {
++          if (relative(file, entry) === '..') {
++            return relative(entry, file).replace(/\\/g, '/')
++          }
++          return false
++        })
++        .filter(Boolean),
++    }
++  }
++
++  // overridden method: we want child walkers to be instances of this class, not ignore-walk
++  walker (entry, opts, callback) {
++    new PackWalker(this.tree, this.walkerOpt(entry, opts)).on('done', callback).start();
++  }
++
++  // overridden method: we use a custom sort method to help compressibility
++  sort (a, b) {
++    // optimize for compressibility
++    // extname, then basename, then locale alphabetically
++    // https://twitter.com/isntitvacant/status/1131094910923231232
++    const exta = extname(a).toLowerCase();
++    const extb = extname(b).toLowerCase();
++    const basea = basename(a).toLowerCase();
++    const baseb = basename(b).toLowerCase();
++
++    return exta.localeCompare(extb, 'en') ||
++      basea.localeCompare(baseb, 'en') ||
++      a.localeCompare(b, 'en')
++  }
++
++  // convenience method: this joins the given rules with newlines, appends a trailing newline,
++  // and calls the internal onReadIgnoreFile method
++  injectRules (filename, rules, callback = () => {}) {
++    this.onReadIgnoreFile(filename, `${rules.join('\n')}\n`, callback);
++  }
++
++  // custom method: this is called by addIgnoreFile when we find a package.json, it uses the
++  // arborist tree to pull both default rules and strict rules for the package
++  processPackage (callback) {
++    const {
++      bin,
++      browser,
++      files,
++      main,
++    } = this.tree.package;
++
++    // rules in these arrays are inverted since they are patterns we want to _not_ ignore
++    const ignores = [];
++    const strict = [
++      ...strictDefaults,
++      '!/package.json',
++      '!/readme{,.*[^~$]}',
++      '!/copying{,.*[^~$]}',
++      '!/license{,.*[^~$]}',
++      '!/licence{,.*[^~$]}',
++      '/.git',
++      '/node_modules',
++      '.npmrc',
++      '/package-lock.json',
++      '/yarn.lock',
++      '/pnpm-lock.yaml',
++    ];
++
++    // if we have a files array in our package, we need to pull rules from it
++    if (files) {
++      for (let file of files) {
++        // invert the rule because these are things we want to include
++        if (file.startsWith('./')) {
++          file = file.slice(1);
++        }
++        if (file.endsWith('/*')) {
++          file += '*';
++        }
++        const inverse = `!${file}`;
++        try {
++          // if an entry in the files array is a specific file, then we need to include it as a
++          // strict requirement for this package. if it's a directory or a pattern, it's a default
++          // pattern instead. this is ugly, but we have to stat to find out if it's a file
++          const stat = lstat(join(this.path, file.replace(/^!+/, '')).replace(/\\/g, '/'));
++          // if we have a file and we know that, it's strictly required
++          if (stat.isFile()) {
++            strict.unshift(inverse);
++            this.requiredFiles.push(file.startsWith('/') ? file.slice(1) : file);
++          } else if (stat.isDirectory()) {
++            // otherwise, it's a default ignore, and since we got here we know it's not a pattern
++            // so we include the directory contents
++            ignores.push(inverse);
++            ignores.push(`${inverse}/**`);
++          }
++          // if the thing exists, but is neither a file or a directory, we don't want it at all
++        } catch (err) {
++          // if lstat throws, then we assume we're looking at a pattern and treat it as a default
++          ignores.push(inverse);
++        }
++      }
++
++      // we prepend a '*' to exclude everything, followed by our inverted file rules
++      // which now mean to include those
++      this.injectRules('package.json', ['*', ...ignores]);
++    }
++
++    // browser is required
++    if (browser) {
++      strict.push(`!/${browser}`);
++    }
++
++    // main is required
++    if (main) {
++      strict.push(`!/${main}`);
++    }
++
++    // each bin is required
++    if (bin) {
++      for (const key in bin) {
++        strict.push(`!/${bin[key]}`);
++      }
++    }
++
++    // and now we add all of the strict rules to our synthetic file
++    this.injectRules(strictRules, strict, callback);
++  }
++
++  // custom method: after we've finished gathering the files for the root package, we call this
++  // before emitting the 'done' event in order to gather all of the files for bundled deps
++  async gatherBundles () {
++    if (this.seen.has(this.tree)) {
++      return
++    }
++
++    // add this node to our seen tracker
++    this.seen.add(this.tree);
++
++    // if we're the project root, then we look at our bundleDependencies, otherwise we got here
++    // because we're a bundled dependency of the root, which means we need to include all prod
++    // and optional dependencies in the bundle
++    let toBundle;
++    if (this.tree.isProjectRoot) {
++      const { bundleDependencies } = this.tree.package;
++      toBundle = bundleDependencies || [];
++    } else {
++      const { dependencies, optionalDependencies } = this.tree.package;
++      toBundle = Object.keys(dependencies || {}).concat(Object.keys(optionalDependencies || {}));
++    }
++
++    for (const dep of toBundle) {
++      const edge = this.tree.edgesOut.get(dep);
++      // no edgeOut = missing node, so skip it. we can't pack it if it's not here
++      // we also refuse to pack peer dependencies and dev dependencies
++      if (!edge || edge.peer || edge.dev) {
++        continue
++      }
++
++      // get a reference to the node we're bundling
++      const node = this.tree.edgesOut.get(dep).to;
++      // if there's no node, this is most likely an optional dependency that hasn't been
++      // installed. just skip it.
++      if (!node) {
++        continue
++      }
++      // we use node.path for the path because we want the location the node was linked to,
++      // not where it actually lives on disk
++      const path = node.path;
++      // but link nodes don't have edgesOut, so we need to pass in the target of the node
++      // in order to make sure we correctly traverse its dependencies
++      const tree = node.target;
++
++      // and start building options to be passed to the walker for this package
++      const walkerOpts = {
++        path,
++        isPackage: true,
++        ignoreFiles: [],
++        seen: this.seen, // pass through seen so we can prevent infinite circular loops
++      };
++
++      // if our node is a link, we apply defaultRules. we don't do this for regular bundled
++      // deps because their .npmignore and .gitignore files are excluded by default and may
++      // override defaults
++      if (node.isLink) {
++        walkerOpts.ignoreFiles.push(defaultRules);
++      }
++
++      // _all_ nodes will follow package.json rules from their package root
++      walkerOpts.ignoreFiles.push('package.json');
++
++      // only link nodes will obey .npmignore or .gitignore
++      if (node.isLink) {
++        walkerOpts.ignoreFiles.push('.npmignore');
++        walkerOpts.ignoreFiles.push('.gitignore');
++      }
++
++      // _all_ nodes follow strict rules
++      walkerOpts.ignoreFiles.push(strictRules);
++
++      // create a walker for this dependency and gather its results
++      const walker = new PackWalker(tree, walkerOpts);
++      const bundled = await new Promise((pResolve, pReject) => {
++        walker.on('error', pReject);
++        walker.on('done', pResolve);
++        walker.start();
++      });
++
++      // now we make sure we have our paths correct from the root, and accumulate everything into
++      // our own result set to deduplicate
++      const relativeFrom = relative(this.root, walker.path);
++      for (const file of bundled) {
++        this.result.add(join(relativeFrom, file).replace(/\\/g, '/'));
++      }
++    }
++  }
++}
++
++const walk = (tree, options, callback) => {
++  if (typeof options === 'function') {
++    callback = options;
++    options = {};
++  }
++  const p = new Promise((pResolve, pReject) => {
++    new PackWalker(tree, { ...options, isPackage: true })
++      .on('done', pResolve).on('error', pReject).start();
++  });
++  return callback ? p.then(res => callback(null, res), callback) : p
++};
++
++var lib = walk;
++walk.Walker = PackWalker;
++
++var packlist = /*@__PURE__*/getDefaultExportFromCjs(lib);
++
++const edgesOut = /* @__PURE__ */ new Map();
++const getNpmPacklist = (absoluteLinkPackagePath, packageJson) => packlist({
++  path: absoluteLinkPackagePath,
++  package: packageJson,
++  // @ts-expect-error outdated types
++  edgesOut
++});
++
++const cwd = process.cwd();
++const cwdPath = (filePath) => path$2.relative(cwd, filePath);
++
++const getPrettyTime = () => (/* @__PURE__ */ new Date()).toLocaleTimeString(
++  void 0,
++  {
++    hour: "numeric",
++    minute: "numeric",
++    second: "numeric",
++    hour12: true
++  }
++);
++
++const waitFor = (test, interval, maxTimeout, errorMessage) => new Promise(async (resolve, reject) => {
++  const startTime = Date.now();
++  let attempts = 0;
++  const maxAttempts = Math.floor(maxTimeout / interval);
++  const attempt = async () => {
++    attempts++;
++    try {
++      const result = await test();
++      if (result) {
++        return resolve();
++      }
++      throw new Error();
++    } catch (error) {
++      const numAttemptsRemaining = maxAttempts - attempts;
++      console.error(red(`  \u{1F615} Error: ${errorMessage}`), " retrying in", yellow(`${interval}ms`), ".", yellow(`${numAttemptsRemaining} attempts remaining`));
++    }
++    if (Date.now() - startTime >= maxTimeout) {
++      console.error(red(`  \u{1F635} Error: ${errorMessage}. Giving up after ${maxAttempts} attempts`));
++      return reject();
++    }
++    setTimeout(attempt, interval);
++  };
++  await attempt();
++});
++
++const hardlinkPackage = async (linkPath, absoluteLinkPackagePath, packageJson, publishFilesPromise = getNpmPacklist(
++  absoluteLinkPackagePath,
++  packageJson
++), interval = 500, maxBuildTime = 3e4) => {
++  const [oldPublishFiles, publishFiles] = await Promise.all([
++    getNpmPacklist(
++      linkPath,
++      /**
++       * This is evaluated in the context of the new package.json since that
++       * defines which files belong to the package.
++       */
++      packageJson
++    ),
++    publishFilesPromise
++  ]);
++  console.log(`Linking ${magenta(packageJson.name)} in publish mode:`);
++  await Promise.all(publishFiles.map(async (file) => {
++    const sourcePath = path$2.join(absoluteLinkPackagePath, file);
++    await waitFor(
++      async () => await fsExists(sourcePath),
++      interval,
++      maxBuildTime,
++      ""
++    );
++  }));
++  await Promise.all(
++    publishFiles.map(async (file) => {
++      const sourcePath = path$2.join(absoluteLinkPackagePath, file);
++      const targetPath = path$2.join(linkPath, file);
++      await fs$2.mkdir(
++        path$2.dirname(targetPath),
++        { recursive: true }
++      );
++      try {
++        await hardlink(sourcePath, targetPath);
++      } catch (error) {
++        console.warn(
++          `  ${red("\u2716 Failed to link")}`,
++          cyan(cwdPath(targetPath)),
++          "\u2192",
++          cyan(cwdPath(sourcePath)),
++          error.message ?? error
++        );
++        return;
++      }
++      const fileIndex = oldPublishFiles.indexOf(file);
++      if (fileIndex > -1) {
++        oldPublishFiles.splice(fileIndex, 1);
++      }
++      console.log(
++        `  ${green("\u2714")}`,
++        cyan(cwdPath(targetPath)),
++        "\u2192",
++        cyan(cwdPath(sourcePath))
++      );
++    })
++  );
++  await Promise.all(
++    oldPublishFiles.map(async (file) => {
++      console.log(cyan(`  \u{1F6AE} ${file} no longer in publish list, deleting it. If you did not intend to do this, something probably went wrong. See https://github.com/privatenumber/link?tab=readme-ov-file#publish-mode`));
++      await fs$2.rm(path$2.join(linkPath, file), {
++        force: true
++      });
++    })
++  );
++};
++
++const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
++
++const isValidSetup = async (linkPath, expectedPrefix) => {
++  const linkPathStat = await fs$2.stat(linkPath).catch(() => null);
++  if (!linkPathStat?.isDirectory()) {
++    return false;
++  }
++  const linkPathReal = await fs$2.realpath(linkPath);
++  return linkPathReal.startsWith(expectedPrefix);
++};
++const linkPublishMode = async (basePackagePath, linkPackagePath, watchMode, litmus, delay = 2e3, interval = 500, maxBuildTime = 3e4) => {
++  const absoluteLinkPackagePath = path$2.resolve(basePackagePath, linkPackagePath);
++  const packageJson = await readPackageJson(absoluteLinkPackagePath);
++  const expectedPrefix = path$2.join(basePackagePath, "node_modules/");
++  const linkPath = path$2.join(expectedPrefix, packageJson.name);
++  if (!await isValidSetup(linkPath, expectedPrefix)) {
++    console.error(
++      defaultOutdent`
++			Error: Package ${magenta(packageJson.name)} is not set up
++	
++			${bold("Setup instructions")}
++			1. In the Dependency package, create a tarball:
++			  ${dim("$ npm pack")}
++	
++			2. In the Consuming package, install the tarball and link the Dependency:
++			  ${dim("$ npm install --no-save ")}
++			  ${dim("$ npx link publish ")}
++	
++			3. Start developing!
++	
++			Learn more: https://npmjs.com/link
++			`
++    );
++    return;
++  }
++  const debouncedSleepForDelay = pDebounce(sleep, delay);
++  const debouncedHardlinkPackage = debounce$1(hardlinkPackage, delay);
++  await hardlinkPackage(
++    linkPath,
++    absoluteLinkPackagePath,
++    packageJson
++  );
++  if (watchMode) {
++    const globOptions = {
++      globstar: true,
++      extended: true
++    };
++    const ignoreFiles = [
++      // Files
++      "**/{npm-debug.log,*.orig,package-lock.json,yarn.lock,pnpm-lock.yaml}",
++      // Folders
++      "**/node_modules/**",
++      // Hidden files
++      "**/.{_*,*.swp,DS_Store,gitignore,npmrc,npmignore,lock-wscript,.wafpickle-*}",
++      // Hidden folders
++      "**/.{_*,git,svn,hg,CVS}/**"
++    ].map((glob) => globToRegexp$1(glob, globOptions));
++    const watcher = fs$2.watch(
++      absoluteLinkPackagePath,
++      { recursive: true }
++    );
++    for await (const { eventType, filename } of watcher) {
++      if (!filename) {
++        continue;
++      }
++      const shouldIgnore = ignoreFiles.some((ignoreFile) => ignoreFile.test(filename));
++      if (shouldIgnore) {
++        continue;
++      }
++      await debouncedSleepForDelay(delay);
++      if (litmus) {
++        await waitFor(
++          async () => fsExists(path$2.join(absoluteLinkPackagePath, litmus)),
++          interval,
++          maxBuildTime,
++          ""
++        );
++      }
++      const publishFiles = await getNpmPacklist(
++        absoluteLinkPackagePath,
++        packageJson
++      );
++      if (!publishFiles.includes(filename)) {
++        continue;
++      }
++      console.log(`
++${dim(getPrettyTime())}`, "Detected", yellow(eventType), "in", `${cyan(cwdPath(path$2.join(absoluteLinkPackagePath, filename)))}
++`);
++      await debouncedHardlinkPackage(
++        linkPath,
++        absoluteLinkPackagePath,
++        packageJson,
++        publishFiles
++      );
++    }
++  }
++};
++
++const publishCommand = G({
++  name: "publish",
++  parameters: [""],
++  flags: {
++    watch: {
++      type: Boolean,
++      alias: "w",
++      description: "Watch for changes in the package and automatically relink"
++    },
++    litmus: {
++      type: String,
++      alias: "l",
++      description: "If using the --watch flag, look for this file in the linked package to see if it's ready to re-link"
++    },
++    delay: {
++      type: Number,
++      alias: "d",
++      description: "If using the --watch flag without the litmus flag, wait this amount of time (in ms) after detecting changes before refreshing the packlist and re-linking",
++      default: 2e3
++    },
++    interval: {
++      type: Number,
++      alias: "i",
++      description: "If using the --watch flag, poll for completed builds at this frequency (in ms)",
++      default: 500
++    },
++    maxBuildTime: {
++      type: Number,
++      alias: "m",
++      description: "If using the --watch flag, the maximum amount of time to wait for all expected files to appear before re-linking",
++      default: 3e4
++    }
++  },
++  help: {
++    description: "Link a package to simulate an environment similar to `npm install`"
++  }
++});
++const publishHandler = async (cwdProjectPath, packagePaths, flags) => {
++  if (packagePaths.length > 0) {
++    await Promise.all(
++      packagePaths.map(
++        (linkPackagePath) => linkPublishMode(
++          cwdProjectPath,
++          linkPackagePath,
++          flags.watch,
++          flags.litmus,
++          flags.delay,
++          flags.interval,
++          flags.maxBuildTime
++        )
++      )
++    );
++  }
++};
++
++(async () => {
++  const argv = Z({
++    name: "link",
++    parameters: ["[package paths...]"],
++    flags: {
++      deep: {
++        type: Boolean,
++        alias: "d",
++        description: "Run `npx link` on dependencies if they have a link.config.json"
++      }
++    },
++    help: {
++      description: "A better `npm link` -- symlink local dependencies to the current project",
++      render: (nodes, renderers) => {
++        nodes[0].data = "npx link\n";
++        nodes.splice(2, 0, {
++          type: "section",
++          data: {
++            title: "Website",
++            body: "https://www.npmjs.com/package/link"
++          }
++        });
++        return renderers.render(nodes);
++      }
++    },
++    commands: [
++      publishCommand
++    ]
++  });
++  const cwdProjectPath = await fs$1.realpath(process.cwd());
++  if (!argv.command) {
++    const { packagePaths } = argv._;
++    if (packagePaths.length > 0) {
++      await Promise.all(
++        packagePaths.map(
++          (linkPackagePath) => linkPackage(
++            cwdProjectPath,
++            linkPackagePath,
++            argv.flags
++          )
++        )
++      );
++      return;
++    }
++    const config = await loadConfig(cwdProjectPath);
++    if (!config) {
++      console.warn(
++        defaultOutdent`
+ 				Warning: Config file "link.config.json" not found in current directory.
+ 							Read the documentation to learn more: https://www.npmjs.com/package/link
+-				`),u.showHelp();return}await Vu(e,D,{deep:u.flags.deep})}})().catch(u=>{console.error("Error:",u.message),process.exit(1)});
++				`
++      );
++      argv.showHelp();
++      return;
++    }
++    await linkFromConfig(
++      cwdProjectPath,
++      config,
++      {
++        deep: argv.flags.deep
++      }
++    );
++  } else if (argv.command === "publish") {
++    await publishHandler(
++      cwdProjectPath,
++      argv._,
++      argv.flags
++    );
++  }
++})().catch((error) => {
++  console.error("Error:", error.message);
++  process.exit(1);
++});
+diff --git a/node_modules/link/dist/libs/fsevents.node b/node_modules/link/dist/libs/fsevents.node
+new file mode 100755
+index 0000000..1cc3345
+Binary files /dev/null and b/node_modules/link/dist/libs/fsevents.node differ
diff --git a/scripts/checkUnusedGradleDependencies.ts b/scripts/checkUnusedGradleDependencies.ts
deleted file mode 100644
index 526f0dd809f7..000000000000
--- a/scripts/checkUnusedGradleDependencies.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-#!/usr/bin/env bun
-/**
- * Fails when a dependency declared in android/app/build.gradle is no longer
- * reached from the project.
- *
- * Android has no committed lockfile and no build failure to warn us: a Gradle
- * declaration whose last consumer was deleted keeps being resolved, compiled and
- * packaged. Knip cannot help either — it reads JavaScript and TypeScript. This
- * check is what notices.
- *
- * Every declared coordinate needs an entry in android/app/dependency-usage.json.
- * `packages` entries are verified against Java and Kotlin imports, `resources`
- * entries against the XML — a view class in a layout, a style parent, an attr.
- * `exempt` is for the artifacts that are genuinely needed with nothing in the
- * project to prove it — a version pin, a BOM, a library React Native reaches
- * rather than our own code — and each one carries a reason and the date it was
- * last checked. That date is validated as a real, non-future date and reported
- * as a warning once it is over a year old; it is not a failure, so an exemption
- * never breaks the build on a date nobody chose.
- *
- * Usage: npx bun scripts/checkUnusedGradleDependencies.ts
- */
-
-import path from 'path';
-
-import type {GradleUsageEntry} from './gradleUsageShared';
-
-import {MANIFEST_PATH, RELATIVE_BUILD_GRADLE, countReferences, findUnparsedDeclarations, readDeclaredDependencies, readManifest, readResources, readSources} from './gradleUsageShared';
-import {findBlankEntry, validateReviewedDate} from './nativeUsageManifest';
-
-const RELATIVE_MANIFEST = path.relative(path.resolve(__dirname, '..'), MANIFEST_PATH);
-const USAGES = new Set(['packages', 'resources', 'exempt']);
-const MINIMUM_RESOURCE_NAME_LENGTH = 8;
-
-function validateEntry(coordinate: string, entry: GradleUsageEntry): {error?: string; warning?: string} {
-    if (!USAGES.has(entry.usage)) {
-        return {error: `${coordinate}: "usage" must be one of ${[...USAGES].join(', ')}.`};
-    }
-    if (entry.usage === 'packages') {
-        if (!entry.packages.length) {
-            return {error: `${coordinate}: a "packages" entry needs at least one Java package prefix.`};
-        }
-        const blank = findBlankEntry(entry.packages, coordinate, 'packages');
-        if (blank) {
-            return {error: blank};
-        }
-    }
-    if (entry.usage === 'resources') {
-        if (!entry.resources.length) {
-            return {error: `${coordinate}: a "resources" entry needs at least one name to look for in the XML.`};
-        }
-        const blank = findBlankEntry(entry.resources, coordinate, 'resources');
-        if (blank) {
-            return {error: blank};
-        }
-        // An unqualified or short name matches too much of the XML to prove
-        // anything: `name` alone occurs in hundreds of places, so a dead
-        // coordinate could be made permanently green with it.
-        const vague = entry.resources.find((name) => name.trim().length < MINIMUM_RESOURCE_NAME_LENGTH || !name.includes('.'));
-        if (vague) {
-            return {
-                error: `${coordinate}: the "resources" name ${vague} is not specific enough to identify the dependency. Use the fully qualified class or attr name, at least ${MINIMUM_RESOURCE_NAME_LENGTH} characters and containing a dot.`,
-            };
-        }
-    }
-    if (entry.usage === 'exempt') {
-        if (!entry.reason.trim()) {
-            return {error: `${coordinate}: an "exempt" entry needs a "reason" saying what keeps the dependency alive.`};
-        }
-        return validateReviewedDate(entry.reviewed, coordinate);
-    }
-    return {};
-}
-
-/**
- * Two coordinates whose package prefixes overlap cannot be told apart: whichever
- * one still has a consumer keeps the other green. That is how a group-wide prefix
- * such as `com.facebook.react` would "verify" `hermes-android` off imports that
- * belong to `react-android`. A hand-written prefix this wide is legitimate only
- * while it is the sole claim on that namespace.
- */
-function findOverlappingPackages(manifest: Record): string[] {
-    const claims = Object.entries(manifest).flatMap(([coordinate, entry]) => (entry.usage === 'packages' ? entry.packages.map((prefix) => ({coordinate, prefix})) : []));
-    const problems: string[] = [];
-    for (const [index, claim] of claims.entries()) {
-        for (const other of claims.slice(index + 1)) {
-            if (other.coordinate === claim.coordinate) {
-                continue;
-            }
-            const overlaps = claim.prefix === other.prefix || claim.prefix.startsWith(`${other.prefix}.`) || other.prefix.startsWith(`${claim.prefix}.`);
-            if (overlaps) {
-                problems.push(
-                    `${claim.coordinate} and ${other.coordinate} both claim overlapping packages (${claim.prefix} and ${other.prefix}), so an import cannot tell them apart. Narrow one of them, or mark the artifact that does not own the package "exempt" with a reason.`,
-                );
-            }
-        }
-    }
-    return problems;
-}
-
-function main() {
-    let manifest;
-    try {
-        manifest = readManifest();
-    } catch (error) {
-        console.error(`Unused Gradle dependency check failed:\n\n  - ${error instanceof Error ? error.message : String(error)}`);
-        process.exit(1);
-    }
-    const declared = readDeclaredDependencies();
-    const problems: string[] = [];
-    const warnings: string[] = [];
-
-    problems.push(...findOverlappingPackages(manifest));
-
-    for (const unparsed of findUnparsedDeclarations()) {
-        problems.push(`${unparsed}\n    This declaration is in a form the check cannot read, so it would be verified by nothing. Teach scripts/gradleUsageShared.ts to read it.`);
-    }
-
-    // A parser that matches nothing would otherwise report success over an empty
-    // set, which is the one failure mode this check cannot afford.
-    if (!declared.length) {
-        problems.push(`No dependencies were parsed out of ${RELATIVE_BUILD_GRADLE}. Either it declares none, or the parser in scripts/gradleUsageShared.ts no longer understands it.`);
-    }
-
-    for (const coordinate of declared) {
-        if (!(coordinate in manifest)) {
-            problems.push(`${coordinate} is declared in android/app/build.gradle but missing from ${RELATIVE_MANIFEST}. Run \`npx bun scripts/generateGradleDependencyUsage.ts\`.`);
-        }
-    }
-
-    for (const coordinate of Object.keys(manifest)) {
-        if (!declared.includes(coordinate)) {
-            problems.push(`${coordinate} is in ${RELATIVE_MANIFEST} but no longer declared in android/app/build.gradle. Delete the entry.`);
-        }
-    }
-
-    const context = {sources: readSources(), resources: readResources()};
-    for (const coordinate of declared) {
-        const entry = manifest[coordinate];
-        if (!entry) {
-            continue;
-        }
-        const {error, warning} = validateEntry(coordinate, entry);
-        if (warning) {
-            warnings.push(warning);
-        }
-        if (error) {
-            problems.push(error);
-            continue;
-        }
-        if (entry.usage === 'exempt') {
-            continue;
-        }
-        if (countReferences(context, entry) === 0) {
-            const route = entry.usage === 'packages' ? `imports of ${entry.packages.join(', ')}` : `${entry.resources.join(', ')} in the XML`;
-            problems.push(`${coordinate} is declared in android/app/build.gradle but nothing reaches it (looked for ${route}). Delete the dependency, or mark it "exempt" with a reason.`);
-        }
-    }
-
-    if (warnings.length) {
-        console.warn('Unused Gradle dependency check warnings:\n');
-        for (const warning of warnings) {
-            console.warn(`  - ${warning}`);
-        }
-        console.warn('');
-    }
-
-    if (problems.length) {
-        console.error('Unused Gradle dependency check failed:\n');
-        for (const problem of problems) {
-            console.error(`  - ${problem}`);
-        }
-        process.exit(1);
-    }
-
-    console.log(`Unused Gradle dependency check passed: ${declared.length} dependencies declared, all reached or exempt with a reason.`);
-}
-
-main();
diff --git a/scripts/checkUnusedPods.ts b/scripts/checkUnusedPods.ts
deleted file mode 100644
index 49b64ceaf49f..000000000000
--- a/scripts/checkUnusedPods.ts
+++ /dev/null
@@ -1,175 +0,0 @@
-#!/usr/bin/env bun
-/**
- * Fails when a pod declared in ios/Podfile is no longer reached from the sources.
- *
- * Removing the last call site of a pod does not fail the build — the pod keeps
- * being fetched, compiled and linked — so nothing tells us a declaration has
- * outlived its reason. Knip cannot help here either: it reads JavaScript and
- * TypeScript, and a Podfile is neither.
- *
- * Every declared pod needs an entry in ios/pod-usage.json saying how the sources
- * reach it. `module` and `headers` entries are verified against the sources on
- * every run. `exempt` entries are for pods that are alive by a route the sources
- * cannot show — a build phase, a version pin, a linker flag — and they have to
- * carry a reason and the date it was last checked. That date is validated as a
- * real, non-future date and reported as a warning once it is over a year old; it
- * is not a failure, so an exemption never breaks the build on a date nobody
- * chose.
- *
- * The manifest is generated by scripts/generatePodUsage.ts after `pod install`.
- *
- * Usage: npx bun scripts/checkUnusedPods.ts
- */
-
-import path from 'path';
-
-import type {PodUsageEntry} from './podUsageShared';
-
-import {findBlankEntry, validateReviewedDate} from './nativeUsageManifest';
-import {MANIFEST_PATH, countReferences, findUnparsedPodDeclarations, readDeclaredPods, readManifest, readSources} from './podUsageShared';
-
-const RELATIVE_MANIFEST = path.relative(path.resolve(__dirname, '..'), MANIFEST_PATH);
-const USAGES = new Set(['module', 'headers', 'exempt']);
-
-/**
- * Modules every iOS source imports anyway, so claiming one proves nothing about
- * a pod.
- */
-const SYSTEM_MODULES = new Set(['Foundation', 'UIKit', 'SwiftUI', 'Combine', 'CoreGraphics', 'CoreFoundation', 'Swift', 'React', 'ObjectiveC']);
-
-function validateEntry(pod: string, entry: PodUsageEntry): {error?: string; warning?: string} {
-    if (!USAGES.has(entry.usage)) {
-        return {error: `${pod}: "usage" must be one of ${[...USAGES].join(', ')}.`};
-    }
-    if (entry.usage === 'module') {
-        if (!entry.module.trim()) {
-            return {error: `${pod}: a "module" entry needs the module name the pod is imported under.`};
-        }
-        if (SYSTEM_MODULES.has(entry.module.trim())) {
-            return {
-                error: `${pod}: "${entry.module}" is a system module that the sources import regardless of this pod, so it cannot verify it. Use the pod's own module, or mark it "exempt" with a reason.`,
-            };
-        }
-    }
-    if (entry.usage === 'headers') {
-        if (!entry.headers.length) {
-            return {error: `${pod}: a "headers" entry needs at least one public header name.`};
-        }
-        const blank = findBlankEntry(entry.headers, pod, 'headers');
-        if (blank) {
-            return {error: blank};
-        }
-    }
-    if (entry.usage === 'exempt') {
-        if (!entry.reason.trim()) {
-            return {error: `${pod}: an "exempt" entry needs a "reason" saying what keeps the pod alive.`};
-        }
-        return validateReviewedDate(entry.reviewed, pod);
-    }
-    return {};
-}
-
-/**
- * Two pods claiming the same module or header cannot be told apart: whichever
- * one still has a call site keeps the other green.
- */
-function findOverlappingRoutes(manifest: Record): string[] {
-    const claims = Object.entries(manifest).flatMap(([pod, entry]) => {
-        if (entry.usage === 'module') {
-            return [{pod, route: `module ${entry.module}`}];
-        }
-        if (entry.usage === 'headers') {
-            return entry.headers.map((header) => ({pod, route: `header ${header}`}));
-        }
-        return [];
-    });
-    const problems: string[] = [];
-    for (const [index, claim] of claims.entries()) {
-        for (const other of claims.slice(index + 1)) {
-            if (other.pod !== claim.pod && other.route === claim.route) {
-                problems.push(`${claim.pod} and ${other.pod} both claim ${claim.route}, so a reference cannot tell them apart. Mark the one that does not own it "exempt" with a reason.`);
-            }
-        }
-    }
-    return problems;
-}
-
-function main() {
-    let manifest;
-    try {
-        manifest = readManifest();
-    } catch (error) {
-        console.error(`Unused pod check failed:\n\n  - ${error instanceof Error ? error.message : String(error)}`);
-        process.exit(1);
-    }
-    const declared = readDeclaredPods();
-    const problems: string[] = [];
-    const warnings: string[] = [];
-
-    problems.push(...findOverlappingRoutes(manifest));
-
-    for (const unparsed of findUnparsedPodDeclarations()) {
-        problems.push(`${unparsed}\n    This pod declaration is in a form the check cannot read, so it would be verified by nothing. Teach scripts/podUsageShared.ts to read it.`);
-    }
-
-    // A parser that matches nothing would otherwise report success over an empty
-    // set, which is the one failure mode this check cannot afford.
-    if (!declared.length) {
-        problems.push('No pods were parsed out of ios/Podfile. Either the Podfile declares none, or the parser in scripts/podUsageShared.ts no longer understands it.');
-    }
-
-    for (const pod of declared) {
-        if (!(pod in manifest)) {
-            problems.push(`${pod} is declared in ios/Podfile but missing from ${RELATIVE_MANIFEST}. Run \`npx bun scripts/generatePodUsage.ts\` after \`pod install\`.`);
-        }
-    }
-
-    for (const pod of Object.keys(manifest)) {
-        if (!declared.includes(pod)) {
-            problems.push(`${pod} is in ${RELATIVE_MANIFEST} but no longer declared in ios/Podfile. Delete the entry.`);
-        }
-    }
-
-    const sources = readSources();
-    for (const pod of declared) {
-        const entry = manifest[pod];
-        if (!entry) {
-            continue;
-        }
-        const {error, warning} = validateEntry(pod, entry);
-        if (warning) {
-            warnings.push(warning);
-        }
-        if (error) {
-            problems.push(error);
-            continue;
-        }
-        if (entry.usage === 'exempt') {
-            continue;
-        }
-        if (countReferences(sources, entry, pod) === 0) {
-            const route = entry.usage === 'module' ? `import ${entry.module}` : entry.headers.map((header) => `#import "${header}"`).join(' / ');
-            problems.push(`${pod} is declared in ios/Podfile but nothing in ios/ reaches it (looked for ${route}). Delete the pod, or mark it "exempt" with a reason.`);
-        }
-    }
-
-    if (warnings.length) {
-        console.warn('Unused pod check warnings:\n');
-        for (const warning of warnings) {
-            console.warn(`  - ${warning}`);
-        }
-        console.warn('');
-    }
-
-    if (problems.length) {
-        console.error('Unused pod check failed:\n');
-        for (const problem of problems) {
-            console.error(`  - ${problem}`);
-        }
-        process.exit(1);
-    }
-
-    console.log(`Unused pod check passed: ${declared.length} pods declared, all reached or exempt with a reason.`);
-}
-
-main();
diff --git a/scripts/generateGradleDependencyUsage.ts b/scripts/generateGradleDependencyUsage.ts
deleted file mode 100644
index a741ad0eb4f8..000000000000
--- a/scripts/generateGradleDependencyUsage.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-#!/usr/bin/env bun
-/**
- * Proposes android/app/dependency-usage.json entries for the dependencies
- * declared in android/app/build.gradle.
- *
- * A Maven coordinate does not tell you the Java package its classes live in —
- * `com.github.bumptech.glide:glide` ships `com.bumptech.glide` — so this script
- * guesses candidate prefixes from the coordinate and keeps the ones the sources
- * actually import. Anything it cannot resolve is written out as `exempt` with an
- * empty reason, and the check rejects that until a human either fills in the
- * reason or replaces the entry with the right package.
- *
- * A group-wide prefix is only ever proposed when no other declared coordinate
- * shares that group. Several artifacts usually do share one, and then the prefix
- * is satisfied by any sibling's imports: `com.facebook.react:hermes-android`
- * would be "verified" by `import com.facebook.react.*`, which belongs to
- * `react-android`, and a BOM would be verified by the artifacts it only aligns
- * versions for. An entry like that can never fail, so writing one is worse than
- * writing none.
- *
- * Existing entries are preserved: run it after adding a dependency, then review.
- *
- * Usage: npx bun scripts/generateGradleDependencyUsage.ts
- */
-
-import fs from 'fs';
-import path from 'path';
-
-import type {GradleUsageManifest} from './gradleUsageShared';
-
-import {MANIFEST_PATH, countReferences, readDeclaredDependencies, readManifest, readResources, readSources, writeManifest} from './gradleUsageShared';
-
-/**
- * Package prefixes worth trying for a coordinate, most specific first. A
- * group-wide prefix is last because it is the weakest: it is right for a group
- * with one artifact in it, and a free ride for a group with several, which is
- * what `findAmbiguousPrefixes` then strips out.
- */
-function candidatePackages(coordinate: string): string[] {
-    const [group, artifact] = coordinate.split(':');
-    const candidates = new Set();
-    const add = (value: string) => {
-        if (!value) {
-            return;
-        }
-        candidates.add(value);
-    };
-
-    add(`${group}.${artifact.replaceAll('-', '.')}`);
-    // JitPack rewrites the group as com.github., while the classes keep
-    // their original package.
-    if (group.startsWith('com.github.')) {
-        const tail = group.slice('com.github.'.length);
-        add(`com.${tail}.${artifact.replaceAll('-', '.')}`);
-        add(`com.${tail}`);
-        add(tail);
-    }
-    add(group);
-    return [...candidates];
-}
-
-/**
- * Prefixes that more than one declared coordinate would claim. Such a prefix
- * cannot tell the two apart, so it proves nothing about either.
- */
-function findAmbiguousPrefixes(coordinates: string[]): Set {
-    const owners = new Map();
-    for (const coordinate of coordinates) {
-        for (const prefix of candidatePackages(coordinate)) {
-            owners.set(prefix, (owners.get(prefix) ?? 0) + 1);
-        }
-    }
-    return new Set([...owners.entries()].filter(([, count]) => count > 1).map(([prefix]) => prefix));
-}
-
-function main() {
-    const context = {sources: readSources(), resources: readResources()};
-    const previous: GradleUsageManifest = fs.existsSync(MANIFEST_PATH) ? readManifest() : {};
-    const manifest: GradleUsageManifest = {};
-    const declared = readDeclaredDependencies();
-    const ambiguous = findAmbiguousPrefixes(declared);
-
-    for (const coordinate of declared) {
-        const existing = previous[coordinate];
-        if (existing && (existing.usage !== 'exempt' || existing.reason)) {
-            manifest[coordinate] = existing;
-            continue;
-        }
-
-        const packages = candidatePackages(coordinate)
-            .filter((prefix) => !ambiguous.has(prefix))
-            .filter((prefix) => countReferences(context, {usage: 'packages', packages: [prefix]}) > 0);
-        manifest[coordinate] = packages.length ? {usage: 'packages', packages} : {usage: 'exempt', reason: '', reviewed: ''};
-    }
-
-    writeManifest(manifest);
-
-    const blank = Object.entries(manifest).filter(([, entry]) => entry.usage === 'exempt' && !entry.reason);
-    console.log(`Wrote ${Object.keys(manifest).length} dependencies to ${path.relative(process.cwd(), MANIFEST_PATH)}.`);
-    if (blank.length) {
-        console.log(`\n${blank.length} dependency/dependencies need a package prefix or a reason:`);
-        for (const [coordinate] of blank) {
-            console.log(`  ${coordinate}`);
-        }
-    }
-}
-
-main();
diff --git a/scripts/generatePodUsage.ts b/scripts/generatePodUsage.ts
deleted file mode 100644
index a470a01f8f32..000000000000
--- a/scripts/generatePodUsage.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-#!/usr/bin/env bun
-/**
- * Regenerates ios/pod-usage.json from the installed Pods directory.
- *
- * checkUnusedPods.ts cannot resolve a pod's module name on its own: the name a
- * pod is imported under is decided by its podspec, not by its Podfile entry
- * (GzipSwift exports a module called Gzip), and Objective-C pods that ship no
- * module are reachable only through their public headers. Both live under
- * ios/Pods, which is gitignored, so they are resolved here once and committed to
- * the manifest.
- *
- * Run this after `pod install` whenever the Podfile changes, then review the
- * diff — a pod this script cannot find any reference for is written out as
- * `exempt` with an empty reason, which the check rejects until you fill it in.
- *
- * Usage: npx bun scripts/generatePodUsage.ts
- */
-
-import fs from 'fs';
-import path from 'path';
-
-import type {PodUsageManifest} from './podUsageShared';
-
-import {IOS_DIR, MANIFEST_PATH, countReferences, readDeclaredPods, readManifest, readSources, writeManifest} from './podUsageShared';
-
-const TARGET_SUPPORT_FILES = path.join(IOS_DIR, 'Pods', 'Target Support Files');
-const PUBLIC_HEADERS = path.join(IOS_DIR, 'Pods', 'Headers', 'Public');
-
-function readModuleName(pod: string): string | undefined {
-    const dir = path.join(TARGET_SUPPORT_FILES, pod);
-    if (!fs.existsSync(dir)) {
-        return undefined;
-    }
-    for (const entry of fs.readdirSync(dir)) {
-        if (!entry.endsWith('.modulemap')) {
-            continue;
-        }
-        const match = /^\s*(?:framework\s+)?module\s+([A-Za-z0-9_]+)/m.exec(fs.readFileSync(path.join(dir, entry), 'utf8'));
-        if (match) {
-            return match[1];
-        }
-    }
-    return undefined;
-}
-
-function readPublicHeaders(pod: string): string[] {
-    const dir = path.join(PUBLIC_HEADERS, pod);
-    if (!fs.existsSync(dir)) {
-        return [];
-    }
-    const out: string[] = [];
-    const walk = (current: string) => {
-        for (const entry of fs.readdirSync(current, {withFileTypes: true})) {
-            const full = path.join(current, entry.name);
-            if (entry.isDirectory()) {
-                walk(full);
-            } else if (entry.name.endsWith('.h')) {
-                out.push(entry.name);
-            }
-        }
-    };
-    walk(dir);
-    return out.sort();
-}
-
-function main() {
-    if (!fs.existsSync(TARGET_SUPPORT_FILES)) {
-        console.error(`${TARGET_SUPPORT_FILES} is missing. Run \`bundle exec pod install\` in ios/ first.`);
-        process.exit(1);
-    }
-
-    const sources = readSources();
-    const previous: PodUsageManifest = fs.existsSync(MANIFEST_PATH) ? readManifest() : {};
-    const manifest: PodUsageManifest = {};
-
-    for (const pod of readDeclaredPods()) {
-        const moduleName = readModuleName(pod) ?? pod.replaceAll(/[^A-Za-z0-9_]/g, '_');
-        if (countReferences(sources, {usage: 'module', module: moduleName}) > 0) {
-            manifest[pod] = {usage: 'module', module: moduleName};
-            continue;
-        }
-
-        const headers = readPublicHeaders(pod);
-        if (headers.length && countReferences(sources, {usage: 'headers', headers}, pod) > 0) {
-            manifest[pod] = {usage: 'headers', headers};
-            continue;
-        }
-
-        // Nothing in the sources reaches this pod. It is either dead or alive by a
-        // route the sources do not show — a build phase, a linker flag, another pod
-        // depending on it. Keep whatever reason was already recorded so a
-        // regeneration does not wipe it.
-        const existing = previous[pod];
-        manifest[pod] = existing?.usage === 'exempt' ? existing : {usage: 'exempt', reason: '', reviewed: ''};
-    }
-
-    writeManifest(manifest);
-
-    const blank = Object.entries(manifest).filter(([, entry]) => entry.usage === 'exempt' && !entry.reason);
-    console.log(`Wrote ${Object.keys(manifest).length} pods to ${path.relative(process.cwd(), MANIFEST_PATH)}.`);
-    if (blank.length) {
-        console.log(`\n${blank.length} pod(s) need a reason before the check will pass:`);
-        for (const [pod] of blank) {
-            console.log(`  ${pod}`);
-        }
-    }
-}
-
-main();
diff --git a/scripts/gradleUsageShared.ts b/scripts/gradleUsageShared.ts
deleted file mode 100644
index 12307d251d3b..000000000000
--- a/scripts/gradleUsageShared.ts
+++ /dev/null
@@ -1,302 +0,0 @@
-/**
- * Shared between generateGradleDependencyUsage.ts and
- * checkUnusedGradleDependencies.ts.
- *
- * NewDot declares its own Gradle dependencies in android/app/build.gradle,
- * separately from the ones Mobile-Expensify declares for HybridApp, so both
- * repositories need this check.
- *
- * Two limitations, both deliberate:
- *
- * - `buildscript { classpath ... }` coordinates are out of scope. They are Gradle
- *   plugins, reached through `apply plugin:` and the `plugins` block rather than
- *   through an import, so neither route this check knows about can verify them.
- * - The manifest is keyed by `group:artifact` with no configuration or source-set
- *   dimension, and the sources are read as one corpus. The same coordinate
- *   declared on two configurations collapses to one entry, and a coordinate is
- *   proved by an import from any source set.
- */
-
-import fs from 'fs';
-import path from 'path';
-
-import {flavorForExtension, stripComments} from './nativeSourceComments';
-
-type PackagesEntry = {usage: 'packages'; packages: string[]};
-type ResourcesEntry = {usage: 'resources'; resources: string[]};
-type ExemptEntry = {usage: 'exempt'; reason: string; reviewed: string};
-type GradleUsageEntry = PackagesEntry | ResourcesEntry | ExemptEntry;
-type GradleUsageManifest = Record;
-type SearchContext = {sources: string; resources: string};
-
-const ANDROID_APP_DIR = path.resolve(__dirname, '..', 'android', 'app');
-const MANIFEST_PATH = path.join(ANDROID_APP_DIR, 'dependency-usage.json');
-const BUILD_GRADLE_PATH = path.join(ANDROID_APP_DIR, 'build.gradle');
-const RELATIVE_BUILD_GRADLE = 'android/app/build.gradle';
-
-const SOURCE_EXTENSIONS = new Set(['.java', '.kt']);
-const SKIPPED_DIRECTORIES = new Set(['build', '.gradle', '.git', '.cxx', 'node_modules']);
-
-/**
- * Gradle names a configuration either on its own (`implementation`), prefixed by
- * a source set or variant (`testImplementation`, `debugCompileOnly`), or suffixed
- * for the annotation processors (`kaptTest`). All three forms declare a
- * dependency that can go stale, so all three have to be read.
- */
-const BASE_CONFIGURATIONS = [
-    'implementation',
-    'api',
-    'compileOnlyApi',
-    'compileOnly',
-    'runtimeOnly',
-    'annotationProcessor',
-    'kapt',
-    'ksp',
-    'detektPlugins',
-    'coreLibraryDesugaring',
-    'lintChecks',
-    'lintPublish',
-];
-const SUFFIX_CONFIGURATIONS = [
-    'Implementation',
-    'Api',
-    'CompileOnlyApi',
-    'CompileOnly',
-    'RuntimeOnly',
-    'AnnotationProcessor',
-    'CoreLibraryDesugaring',
-    'LintChecks',
-    'LintPublish',
-    'Kapt',
-    'Ksp',
-    'DetektPlugins',
-    'Util',
-];
-const CONFIGURATION = `(?:${BASE_CONFIGURATIONS.join('|')}|[a-zA-Z0-9]+(?:${SUFFIX_CONFIGURATIONS.join('|')})|(?:kapt|ksp)[A-Z][a-zA-Z0-9]*)`;
-const CONFIGURATION_NAME = new RegExp(`^${CONFIGURATION}$`);
-
-/**
- * Configurations that name something other than an external module dependency,
- * and so are neither parsed nor reported as unreadable.
- */
-const IGNORED_CONFIGURATIONS = new Set(['classpath']);
-
-/**
- * `implementation project(':x')`, `implementation files(...)`: built from this
- * repository or checked in, so there is no external declaration to go stale.
- */
-const LOCAL_ARTIFACT = /^\(?\s*(?:project|files|fileTree|gradleApi|localGroovy)\s*\(/;
-
-/**
- * `implementation jscFlavor`: a Gradle variable resolved elsewhere in the build,
- * whose value the manifest cannot key on.
- */
-const GRADLE_VARIABLE = /^\(?\s*[A-Za-z_][A-Za-z0-9_]*\s*\)?$/;
-
-/**
- * `kapt { ... }`: a configuration block that happens to be named after a
- * configuration, not a dependency declaration.
- */
-const isDslBlock = (argument: string) => argument.startsWith('{');
-
-/**
- * `implementation "group:artifact:version"`, with the optional wrappers Gradle
- * accepts around it. `\s*` spans newlines, so the coordinate may sit on its own
- * line below the configuration.
- */
-const STRING_DECLARATION = new RegExp(`^[ \\t]*${CONFIGURATION}\\s*\\(?\\s*(?:(?:platform|enforcedPlatform)\\s*\\()?\\s*['"]([a-zA-Z0-9_.-]+):([a-zA-Z0-9_.-]+)`, 'gm');
-
-/**
- * `implementation group: 'g', name: 'a', version: 'v'`.
- */
-const MAP_DECLARATION = new RegExp(`^[ \\t]*${CONFIGURATION}\\s*\\(?\\s*group\\s*:\\s*['"]([a-zA-Z0-9_.-]+)['"]\\s*,\\s*name\\s*:\\s*['"]([a-zA-Z0-9_.-]+)['"]`, 'gm');
-
-/**
- * `implementation okhttp`, where `okhttp` was assigned a coordinate literal
- * earlier in the file. Without resolving these, hoisting a coordinate into a
- * variable while tidying up versions would take it out of the check's sight.
- */
-const VARIABLE_DECLARATION = new RegExp(`^[ \\t]*${CONFIGURATION}\\s*\\(?\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*\\)?[ \\t]*$`, 'gm');
-const STRING_CONSTANT = /^[ \t]*(?:def|final\s+String|String)?\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*['"]([a-zA-Z0-9_.:+-]+)['"]/gm;
-const COORDINATE = /^([a-zA-Z0-9_.-]+):([a-zA-Z0-9_.-]+)/;
-
-const readBuildGradle = () => stripComments(fs.readFileSync(BUILD_GRADLE_PATH, 'utf8'), 'c');
-
-/**
- * Character offsets in `text` where a declaration pattern started matching,
- * used to tell a line the parser read from one it skipped.
- */
-function collectDeclarations(text: string): {coordinates: Set; matchedOffsets: Set} {
-    const coordinates = new Set();
-    const matchedOffsets = new Set();
-    for (const pattern of [STRING_DECLARATION, MAP_DECLARATION]) {
-        for (const match of text.matchAll(pattern)) {
-            coordinates.add(`${match.at(1)}:${match.at(2)}`);
-            matchedOffsets.add(match.index ?? 0);
-        }
-    }
-
-    const constants = new Map();
-    for (const match of text.matchAll(STRING_CONSTANT)) {
-        constants.set(match.at(1) ?? '', match.at(2) ?? '');
-    }
-    for (const match of text.matchAll(VARIABLE_DECLARATION)) {
-        const coordinate = COORDINATE.exec(constants.get(match.at(1) ?? '') ?? '');
-        if (coordinate) {
-            coordinates.add(`${coordinate.at(1)}:${coordinate.at(2)}`);
-            matchedOffsets.add(match.index ?? 0);
-        }
-    }
-
-    return {coordinates, matchedOffsets};
-}
-
-/**
- * Every `group:artifact` coordinate declared in android/app/build.gradle on a
- * dependency configuration, including the ones named through a variable holding
- * a coordinate literal. Local artifacts (`files(...)`, `project(...)`) are
- * skipped: there is no external declaration to go stale.
- */
-function readDeclaredDependencies(): string[] {
-    return [...collectDeclarations(readBuildGradle()).coordinates].sort();
-}
-
-/**
- * Lines that declare a dependency in a form this parser cannot read: a version
- * catalog accessor, an interpolated coordinate, anything new. Without this the
- * parser would drop them silently and the check would report success over
- * whatever subset it still understood, which is how a version-catalog migration
- * turns the whole gate into a no-op.
- */
-function findUnparsedDeclarations(): string[] {
-    const text = readBuildGradle();
-    const {matchedOffsets} = collectDeclarations(text);
-
-    const matchesPerLine = new Map();
-    for (const offset of matchedOffsets) {
-        const line = text.slice(0, offset).split('\n').length - 1;
-        matchesPerLine.set(line, (matchesPerLine.get(line) ?? 0) + 1);
-    }
-
-    const unparsed: string[] = [];
-    for (const [index, line] of text.split('\n').entries()) {
-        // The separator after the name has to be whitespace or an opening paren.
-        // `implementation.exclude group: ...` and `implementation.transitive =
-        // false` configure a configuration rather than declaring anything.
-        const match = /^[ \t]*([A-Za-z][A-Za-z0-9]*)(?=[\s(]|$)(.*)$/.exec(line);
-        const configuration = match?.at(1);
-        const argument = (match?.at(2) ?? '').trim();
-        // Every coordinate-shaped literal on the line has to have been parsed,
-        // not just the first: the patterns are anchored to the line start, so a
-        // second declaration after a semicolon or a comma would otherwise be
-        // covered by the first one having matched.
-        const literals = (line.match(/['"][a-zA-Z0-9_.-]+:[a-zA-Z0-9_.-]+/g) ?? []).length;
-        const parsed = matchesPerLine.get(index) ?? 0;
-        const readable = (parsed > 0 || LOCAL_ARTIFACT.test(argument) || GRADLE_VARIABLE.test(argument) || isDslBlock(argument)) && literals <= parsed;
-        if (configuration && !IGNORED_CONFIGURATIONS.has(configuration) && CONFIGURATION_NAME.test(configuration) && !readable) {
-            unparsed.push(`${RELATIVE_BUILD_GRADLE}:${index + 1}: ${line.trim()}`);
-        }
-    }
-    return unparsed;
-}
-
-function readFilesWithExtensions(extensions: Set): string {
-    const contents: string[] = [];
-    const walk = (dir: string) => {
-        for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
-            if (entry.isDirectory()) {
-                if (!SKIPPED_DIRECTORIES.has(entry.name)) {
-                    walk(path.join(dir, entry.name));
-                }
-            } else if (extensions.has(path.extname(entry.name))) {
-                const extension = path.extname(entry.name);
-                contents.push(stripComments(fs.readFileSync(path.join(dir, entry.name), 'utf8'), flavorForExtension(extension)));
-            }
-        }
-    };
-    walk(ANDROID_APP_DIR);
-    return contents.join('\n');
-}
-
-const readSources = () => readFilesWithExtensions(SOURCE_EXTENSIONS);
-const readResources = () => readFilesWithExtensions(new Set(['.xml']));
-
-const escapeForRegExp = (value: string) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
-
-/**
- * How many times the project reaches a dependency, by the route its manifest
- * entry declares: `packages` through Java and Kotlin imports, `resources`
- * through anything in the XML — a view class name in a layout, a style parent,
- * an attr. The `resources` match is bounded on both sides so that a name cannot
- * be satisfied by being the tail of a longer, unrelated one.
- */
-function countReferences({sources, resources}: SearchContext, entry: GradleUsageEntry): number {
-    if (entry.usage === 'packages') {
-        return entry.packages.reduce((total, prefix) => total + (sources.match(new RegExp(`^import\\s+(?:static\\s+)?${escapeForRegExp(prefix)}[.;\\s]`, 'gm'))?.length ?? 0), 0);
-    }
-    if (entry.usage === 'resources') {
-        return entry.resources.reduce((total, name) => total + (resources.match(new RegExp(`(? => typeof value === 'object' && value !== null;
-
-/**
- * A manifest entry is trusted only once its shape is verified; the check reports
- * the human-facing problems (a missing reason, an unusable date) separately.
- */
-function isGradleUsageEntry(value: unknown): value is GradleUsageEntry {
-    if (!isRecord(value)) {
-        return false;
-    }
-    if (value.usage === 'packages') {
-        return Array.isArray(value.packages) && value.packages.every((prefix) => typeof prefix === 'string');
-    }
-    if (value.usage === 'resources') {
-        return Array.isArray(value.resources) && value.resources.every((name) => typeof name === 'string');
-    }
-    if (value.usage === 'exempt') {
-        return typeof value.reason === 'string' && typeof value.reviewed === 'string';
-    }
-    return false;
-}
-
-function readManifest(): GradleUsageManifest {
-    const parsed: unknown = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
-    if (!isRecord(parsed)) {
-        throw new Error(`${MANIFEST_PATH} must contain a JSON object.`);
-    }
-    const manifest: GradleUsageManifest = {};
-    for (const [coordinate, entry] of Object.entries(parsed)) {
-        if (!isGradleUsageEntry(entry)) {
-            throw new Error(`${coordinate} in ${MANIFEST_PATH} must be a packages, resources or exempt entry.`);
-        }
-        manifest[coordinate] = entry;
-    }
-    return manifest;
-}
-
-function writeManifest(manifest: GradleUsageManifest) {
-    const sorted = Object.fromEntries(
-        Object.keys(manifest)
-            .sort()
-            .map((key) => [key, manifest[key]]),
-    );
-    fs.writeFileSync(MANIFEST_PATH, `${JSON.stringify(sorted, null, 4)}\n`);
-}
-
-export type {GradleUsageEntry, GradleUsageManifest, SearchContext};
-export {
-    ANDROID_APP_DIR,
-    MANIFEST_PATH,
-    BUILD_GRADLE_PATH,
-    RELATIVE_BUILD_GRADLE,
-    readDeclaredDependencies,
-    findUnparsedDeclarations,
-    readSources,
-    readResources,
-    countReferences,
-    readManifest,
-    writeManifest,
-};
diff --git a/scripts/nativeSourceComments.ts b/scripts/nativeSourceComments.ts
deleted file mode 100644
index 957a146b8464..000000000000
--- a/scripts/nativeSourceComments.ts
+++ /dev/null
@@ -1,224 +0,0 @@
-/**
- * Comment stripping for the native dependency checks.
- *
- * Both checks decide whether a declaration is alive by matching patterns against
- * source text. Matching raw text counts a commented-out import as a live
- * reference, and reads a commented-out declaration as a real one, so every
- * declaration a developer parks behind a comment while debugging either keeps a
- * dead dependency green or fails the check for something Gradle never sees.
- * Stripping comments first is what makes both answers honest.
- *
- * Newlines inside a stripped comment are preserved so that line-anchored
- * patterns keep matching the lines they were written for, and string bodies are
- * emitted verbatim so that this can never delete code.
- */
-
-/**
- * Swift and Kotlin nest block comments; C, C++, Objective-C, Java and Groovy do
- * not. Treating a nested opener as nesting in the languages that do not would
- * swallow every line between the comment's real end and the next closer, which
- * silently shrinks the set of declarations and imports the checks can see.
- */
-type CommentFlavor = 'c' | 'swift' | 'kotlin' | 'ruby' | 'xml';
-
-const NESTING_FLAVORS = new Set(['swift', 'kotlin']);
-
-/**
- * Swift has no char literal, so a lone apostrophe there is punctuation rather
- * than the start of one. Everywhere else `'...'` is a char literal, or in Groovy
- * a string, and has to be consumed as one.
- */
-const CHAR_LITERAL_FLAVORS = new Set(['c', 'kotlin']);
-
-/**
- * The comment flavor a file extension implies. Anything unrecognized is treated
- * as the non-nesting C family, which is the safe default: it can leave a comment
- * standing, but it can never discard code.
- */
-function flavorForExtension(extension: string): CommentFlavor {
-    if (extension === '.swift') {
-        return 'swift';
-    }
-    if (extension === '.kt' || extension === '.kts') {
-        return 'kotlin';
-    }
-    if (extension === '.xml') {
-        return 'xml';
-    }
-    return 'c';
-}
-
-/**
- * The delimiter that closes a string starting at `index`, and its length, or
- * undefined when no string starts there. Handles `"`, triple-quoted blocks, and
- * Swift raw strings such as `#"..."#` and `##"..."##`.
- */
-function stringOpener(source: string, index: number, flavor: CommentFlavor): {open: string; close: string} | undefined {
-    if (flavor === 'swift') {
-        const raw = /^(#+)"(#*)/.exec(source.slice(index, index + 16));
-        const hashes = raw?.at(1);
-        if (hashes) {
-            const isTriple = source.startsWith(`${hashes}"""`, index);
-            return {open: isTriple ? `${hashes}"""` : `${hashes}"`, close: isTriple ? `"""${hashes}` : `"${hashes}`};
-        }
-    }
-    if (source.startsWith('"""', index)) {
-        return {open: '"""', close: '"""'};
-    }
-    if (source[index] === '"') {
-        return {open: '"', close: '"'};
-    }
-    if (source[index] === "'" && CHAR_LITERAL_FLAVORS.has(flavor)) {
-        return {open: "'", close: "'"};
-    }
-    return undefined;
-}
-
-function stripCFamilyComments(source: string, flavor: CommentFlavor): string {
-    const nested = NESTING_FLAVORS.has(flavor);
-    let output = '';
-    let index = 0;
-    let blockDepth = 0;
-
-    while (index < source.length) {
-        if (blockDepth > 0) {
-            if (nested && source.startsWith('/*', index)) {
-                blockDepth++;
-                index += 2;
-            } else if (source.startsWith('*/', index)) {
-                blockDepth--;
-                index += 2;
-            } else {
-                if (source[index] === '\n') {
-                    output += '\n';
-                }
-                index++;
-            }
-            continue;
-        }
-
-        if (source.startsWith('//', index)) {
-            while (index < source.length && source[index] !== '\n') {
-                index++;
-            }
-            continue;
-        }
-
-        if (source.startsWith('/*', index)) {
-            blockDepth = 1;
-            index += 2;
-            continue;
-        }
-
-        const delimiter = stringOpener(source, index, flavor);
-        if (delimiter) {
-            output += delimiter.open;
-            index += delimiter.open.length;
-            while (index < source.length) {
-                if (source[index] === '\\') {
-                    output += source.slice(index, index + 2);
-                    index += 2;
-                    continue;
-                }
-                if (source.startsWith(delimiter.close, index)) {
-                    // A triple-quoted body ending in a quote puts four or more
-                    // quotes in a row; the closer is the last three of the run.
-                    let run = index;
-                    while (delimiter.close.startsWith('"""') && source[run + delimiter.close.length] === '"') {
-                        output += source[run];
-                        run++;
-                    }
-                    output += source.slice(run, run + delimiter.close.length);
-                    index = run + delimiter.close.length;
-                    break;
-                }
-                // An unterminated char literal is far more likely to be an
-                // apostrophe than a literal, so do not let it swallow the file.
-                if (source[index] === '\n' && delimiter.close === "'") {
-                    break;
-                }
-                output += source[index];
-                index++;
-            }
-            continue;
-        }
-
-        output += source[index];
-        index++;
-    }
-
-    return output;
-}
-
-/**
- * `#` line comments and `=begin` / `=end` blocks, leaving `#` inside strings and
- * `#{}` interpolation alone.
- */
-function stripRubyComments(source: string): string {
-    const lines = source.split('\n');
-    const output: string[] = [];
-    let inBlock = false;
-
-    for (const line of lines) {
-        if (inBlock) {
-            output.push('');
-            if (/^=end\b/.test(line)) {
-                inBlock = false;
-            }
-            continue;
-        }
-        if (/^=begin\b/.test(line)) {
-            inBlock = true;
-            output.push('');
-            continue;
-        }
-
-        let stripped = '';
-        let quote: string | undefined;
-        for (let index = 0; index < line.length; index++) {
-            const character = line[index];
-            if (quote) {
-                stripped += character;
-                if (character === '\\') {
-                    stripped += line[index + 1] ?? '';
-                    index++;
-                } else if (character === quote) {
-                    quote = undefined;
-                }
-                continue;
-            }
-            if (character === '"' || character === "'") {
-                quote = character;
-                stripped += character;
-                continue;
-            }
-            if (character === '#') {
-                break;
-            }
-            stripped += character;
-        }
-        output.push(stripped);
-    }
-
-    return output.join('\n');
-}
-
-function stripXmlComments(source: string): string {
-    // An unterminated opener runs to the end of the file; leaving it in place
-    // would keep whatever was commented out at the end of a layout counting as a
-    // live resource reference.
-    return source.replaceAll(/|[\s\S]*$)/g, (comment) => comment.replaceAll(/[^\n]/g, ''));
-}
-
-function stripComments(source: string, flavor: CommentFlavor): string {
-    if (flavor === 'ruby') {
-        return stripRubyComments(source);
-    }
-    if (flavor === 'xml') {
-        return stripXmlComments(source);
-    }
-    return stripCFamilyComments(source, flavor);
-}
-
-export type {CommentFlavor};
-export {stripComments, flavorForExtension};
diff --git a/scripts/nativeUsageManifest.ts b/scripts/nativeUsageManifest.ts
deleted file mode 100644
index 9e96f7071868..000000000000
--- a/scripts/nativeUsageManifest.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-/**
- * Validation shared by the pod and Gradle dependency checks.
- *
- * An `exempt` entry is the one route that skips verification entirely, so the
- * only thing standing behind it is the reason a human wrote and the date they
- * wrote it. That makes the date worth validating as a date: `9999-99-99` and
- * `2026-02-30` both pass a `\d{4}-\d{2}-\d{2}` shape test, and a future date is
- * either a typo or a way to park a review forever.
- *
- * Age is reported as a warning rather than a failure on purpose. Failing on it
- * would turn every exemption into a build that breaks on a date nobody chose,
- * with no code change to explain it.
- */
-
-const STALE_AFTER_DAYS = 365;
-const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
-
-type ReviewedDateResult = {error?: string; warning?: string};
-
-/**
- * Checks `reviewed` is a real calendar date, is not in the future, and says how
- * old it is.
- */
-function validateReviewedDate(reviewed: string | undefined, label: string): ReviewedDateResult {
-    const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(reviewed ?? '');
-    if (!match) {
-        return {error: `${label}: "reviewed" must be a date in the YYYY-MM-DD format.`};
-    }
-
-    const [year, month, day] = match.slice(1).map(Number);
-    const parsed = new Date(Date.UTC(year, month - 1, day));
-    if (parsed.getUTCFullYear() !== year || parsed.getUTCMonth() !== month - 1 || parsed.getUTCDate() !== day) {
-        return {error: `${label}: "reviewed" is ${reviewed ?? ''}, which is not a real date.`};
-    }
-
-    // The date is compared against UTC midnight while a developer writes their
-    // own local date, so a day of slack keeps every timezone from UTC-12 to
-    // UTC+14 out of the future.
-    const ageInDays = Math.floor((Date.now() - parsed.getTime()) / MILLISECONDS_PER_DAY);
-    if (ageInDays < -1) {
-        return {error: `${label}: "reviewed" is ${reviewed ?? ''}, which is in the future.`};
-    }
-    if (ageInDays > STALE_AFTER_DAYS) {
-        return {warning: `${label}: last reviewed ${ageInDays} days ago (${reviewed ?? ''}). Confirm the reason still holds and update the date.`};
-    }
-    return {};
-}
-
-/**
- * Rejects the empty strings a hand-edited manifest can pick up. An empty pattern
- * matches at every position, so one empty entry would make a dependency
- * unconditionally reachable.
- */
-function findBlankEntry(values: string[], label: string, field: string): string | undefined {
-    if (values.some((value) => !value.trim())) {
-        return `${label}: "${field}" must not contain an empty string.`;
-    }
-    return undefined;
-}
-
-export type {ReviewedDateResult};
-export {STALE_AFTER_DAYS, validateReviewedDate, findBlankEntry};
diff --git a/scripts/podUsageShared.ts b/scripts/podUsageShared.ts
deleted file mode 100644
index c00138ab4333..000000000000
--- a/scripts/podUsageShared.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-/**
- * Shared between generatePodUsage.ts and checkUnusedPods.ts.
- *
- * NewDot declares its own pods in ios/Podfile, separately from the ones
- * Mobile-Expensify declares for HybridApp, so both repositories need this check.
- *
- * Known limitation, deliberately not covered: the manifest is keyed by pod name
- * alone, with no target dimension, and the sources are read as one corpus. A pod
- * declared for one target is therefore proved by an import from another. Fixing
- * that means resolving Podfile targets against Xcode target membership, which
- * this check does not attempt.
- */
-
-import fs from 'fs';
-import path from 'path';
-
-import {flavorForExtension, stripComments} from './nativeSourceComments';
-
-type ModuleEntry = {usage: 'module'; module: string};
-type HeadersEntry = {usage: 'headers'; headers: string[]};
-type ExemptEntry = {usage: 'exempt'; reason: string; reviewed: string};
-type PodUsageEntry = ModuleEntry | HeadersEntry | ExemptEntry;
-type PodUsageManifest = Record;
-
-const IOS_DIR = path.resolve(__dirname, '..', 'ios');
-const MANIFEST_PATH = path.join(IOS_DIR, 'pod-usage.json');
-const PODFILE_PATH = path.join(IOS_DIR, 'Podfile');
-
-const SOURCE_EXTENSIONS = new Set(['.swift', '.m', '.mm', '.h', '.pch', '.c', '.cpp', '.cc', '.hpp']);
-const SKIPPED_DIRECTORIES = new Set(['Pods', 'build', 'DerivedData', '.git', 'node_modules']);
-
-const POD_DECLARATION = /^\s*pod\s+['"]([^'"]+)['"]/;
-
-const readPodfile = () => stripComments(fs.readFileSync(PODFILE_PATH, 'utf8'), 'ruby');
-
-/**
- * Pod names as declared in the Podfile, deduplicated to the pod itself:
- * `pod 'MaterialComponents/Tabs'` is one declaration of MaterialComponents.
- */
-function readDeclaredPods(): string[] {
-    const pods = new Set();
-    for (const line of readPodfile().split('\n')) {
-        const match = POD_DECLARATION.exec(line);
-        if (match) {
-            const [pod] = (match.at(1) ?? '').split('/');
-            pods.add(pod);
-        }
-    }
-    return [...pods].sort();
-}
-
-/**
- * Lines that declare a pod in a form this parser cannot read, so that a Podfile
- * refactor fails the check loudly instead of quietly shrinking the set of pods
- * it verifies.
- */
-function findUnparsedPodDeclarations(): string[] {
-    const unparsed: string[] = [];
-    for (const [index, line] of readPodfile().split('\n').entries()) {
-        if (/^\s*pod\b/.test(line) && !POD_DECLARATION.test(line)) {
-            unparsed.push(`ios/Podfile:${index + 1}: ${line.trim()}`);
-        }
-    }
-    return unparsed;
-}
-
-function readSources(): string {
-    const contents: string[] = [];
-    const walk = (dir: string) => {
-        for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
-            if (entry.isDirectory()) {
-                if (!SKIPPED_DIRECTORIES.has(entry.name)) {
-                    walk(path.join(dir, entry.name));
-                }
-            } else if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
-                const extension = path.extname(entry.name);
-                contents.push(stripComments(fs.readFileSync(path.join(dir, entry.name), 'utf8'), flavorForExtension(extension)));
-            }
-        }
-    };
-    walk(IOS_DIR);
-    return contents.join('\n');
-}
-
-const escapeForRegExp = (value: string) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
-
-/**
- * How many times the sources reach a pod, by the route its manifest entry
- * declares. Every pattern is anchored to the start of a line: an import is the
- * first thing on its line, and anchoring is what stops a mention inside a string
- * or a trailing note from counting as a reference.
- */
-function countReferences(sources: string, entry: PodUsageEntry, pod?: string): number {
-    const patterns: RegExp[] = [];
-    if (entry.usage === 'module') {
-        const module = escapeForRegExp(entry.module);
-        patterns.push(new RegExp(`^\\s*import\\s+${module}\\b`, 'gm'), new RegExp(`^\\s*@import\\s+${module}\\b`, 'gm'), new RegExp(`^\\s*#import\\s*<${module}/`, 'gm'));
-    } else if (entry.usage === 'headers') {
-        // Both `#import "Header.h"` and the canonical framework form
-        // `#import ` reach the same header, but the framework
-        // prefix has to be this pod: a generically named header such as
-        // `Version.h` would otherwise be satisfied by any other pod shipping one.
-        const framework = pod ? `(?:${escapeForRegExp(pod)}/)?` : '';
-        for (const header of entry.headers) {
-            patterns.push(new RegExp(`^\\s*#import\\s*["<]${framework}${escapeForRegExp(header)}[">]`, 'gm'));
-        }
-    }
-    return patterns.reduce((total, pattern) => total + (sources.match(pattern)?.length ?? 0), 0);
-}
-
-const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null;
-
-/**
- * A manifest entry is trusted only once its shape is verified; the check reports
- * the human-facing problems (a missing reason, an unusable date) separately.
- */
-function isPodUsageEntry(value: unknown): value is PodUsageEntry {
-    if (!isRecord(value)) {
-        return false;
-    }
-    if (value.usage === 'module') {
-        return typeof value.module === 'string';
-    }
-    if (value.usage === 'headers') {
-        return Array.isArray(value.headers) && value.headers.every((header) => typeof header === 'string');
-    }
-    if (value.usage === 'exempt') {
-        return typeof value.reason === 'string' && typeof value.reviewed === 'string';
-    }
-    return false;
-}
-
-function readManifest(): PodUsageManifest {
-    const parsed: unknown = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
-    if (!isRecord(parsed)) {
-        throw new Error(`${MANIFEST_PATH} must contain a JSON object.`);
-    }
-    const manifest: PodUsageManifest = {};
-    for (const [pod, entry] of Object.entries(parsed)) {
-        if (!isPodUsageEntry(entry)) {
-            throw new Error(`${pod} in ${MANIFEST_PATH} must be a module, headers or exempt entry.`);
-        }
-        manifest[pod] = entry;
-    }
-    return manifest;
-}
-
-function writeManifest(manifest: PodUsageManifest) {
-    const sorted = Object.fromEntries(
-        Object.keys(manifest)
-            .sort()
-            .map((key) => [key, manifest[key]]),
-    );
-    fs.writeFileSync(MANIFEST_PATH, `${JSON.stringify(sorted, null, 4)}\n`);
-}
-
-export type {PodUsageEntry, PodUsageManifest};
-export {IOS_DIR, MANIFEST_PATH, PODFILE_PATH, readDeclaredPods, findUnparsedPodDeclarations, readSources, countReferences, readManifest, writeManifest};
diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index 73e9fa505fec..6e277c646bd5 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -428,9 +428,6 @@ const CONST = {
     RECEIPT_CAMERA: {
         PHOTO_WIDTH: 2880,
         PHOTO_HEIGHT: 2160,
-        // iPhones offer no 4:3 photo size between 3 MP and 12 MP, so the 6 MP target above selects 2016x1512
-        IOS_STILL_WIDTH: 4032,
-        IOS_STILL_HEIGHT: 3024,
         PHOTO_ASPECT_RATIO: 4 / 3,
     },
 
@@ -478,9 +475,6 @@ const CONST = {
         FAILED: 'failed',
     },
 
-    /** Google Ads only accepts click ID conversions for 90 days, so stored marketing attribution older than this is ignored */
-    MARKETING_ATTRIBUTION_MAX_AGE_MS: 90 * 24 * 60 * 60 * 1000,
-
     AUTH_TOKEN_TYPES: {
         ANONYMOUS: 'anonymousAccount',
         SUPPORT: 'support',
@@ -1170,7 +1164,6 @@ const CONST = {
         PAYMENT_HISTORY: 'paymentHistory',
         SUPPORT_TICKET: 'supportTicket',
         ANCHORED_FIELD_DROPDOWNS: 'anchoredFieldDropdowns',
-        RESIZABLE_TABLE_COLUMNS: 'resizableTableColumns',
     },
     BUTTON_STATES: {
         DEFAULT: 'default',
@@ -2434,7 +2427,6 @@ const CONST = {
         SPAN_SHUTTER_TO_CONFIRMATION: 'ManualShutterToConfirmation',
         SPAN_RECEIPT_CAPTURE: 'ManualReceiptCapture',
         SPAN_RECEIPT_PREPARE: 'ManualReceiptPrepare',
-        SPAN_RECEIPT_UPGRADE: 'ManualReceiptUpgrade',
         SPAN_SCAN_PROCESS_AND_NAVIGATE: 'ManualScanProcessAndNavigate',
         SPAN_CONFIRMATION_MOUNT: 'ManualConfirmationMount',
         SPAN_CONFIRMATION_LIST_READY: 'ManualConfirmationListReady',
@@ -2545,8 +2537,6 @@ const CONST = {
         ATTRIBUTE_IS_MULTI_SCAN: 'is_multi_scan',
         ATTRIBUTE_CAPTURE_METHOD: 'capture_method',
         ATTRIBUTE_FLASH_USED: 'flash_used',
-        ATTRIBUTE_UPGRADE_ATTEMPTED: 'upgrade_attempted',
-        ATTRIBUTE_UPGRADE_OUTCOME: 'upgrade_outcome',
         ATTRIBUTE_PHOTO_WIDTH: 'photo_width',
         ATTRIBUTE_PHOTO_HEIGHT: 'photo_height',
         ATTRIBUTE_SOURCE: 'source',
@@ -2562,16 +2552,6 @@ const CONST = {
             PHOTO: 'photo',
             SNAPSHOT: 'snapshot',
         },
-        UPGRADE_OUTCOME: {
-            UPGRADED: 'upgraded',
-            CAPTURE_FAILED: 'capture_failed',
-            CAPTURE_TIMED_OUT: 'capture_timed_out',
-            ROTATE_TIMED_OUT: 'rotate_timed_out',
-            ROTATE_FAILED: 'rotate_failed',
-            CLAIMED_FOR_UPLOAD: 'claimed_for_upload',
-            SWAP_FAILED: 'swap_failed',
-            STACKED_CAPTURE_SKIPPED: 'stacked_capture_skipped',
-        },
         SPAN_PLATFORM: {
             NATIVE: 'native',
             WEB: 'web',
@@ -2839,7 +2819,6 @@ const CONST = {
     },
     ERROR_TYPE: {
         SOCKET: 'Expensify\\Auth\\Error\\Socket',
-        SESSION_MISMATCH: 'Expensify\\Error\\Auth\\AccountMismatch',
     },
     ERROR_TITLE: {
         SOCKET: 'Issue connecting to database',
@@ -4430,10 +4409,6 @@ const CONST = {
         MAX_RECENT_REPORTS_TO_SHOW: 5,
         MAX_RECENT_ATTENDEES: 40,
 
-        // Lets consumers tell a payment failure apart from other errors on a report preview. Numeric so it keeps
-        // sorting below microsecond keys in getLatestErrorMessageField, which picks the highest key.
-        PAY_FAILURE_PREVIEW_ERROR_KEY: 0,
-
         // This will guranatee that the quantity input will not exceed 9,007,199,254,740,991 (Number.MAX_SAFE_INTEGER).
         QUANTITY_MAX_LENGTH: 12,
         // This is the transactionID used when going through the create expense flow so that it mimics a real transaction (like the edit flow)
@@ -5339,7 +5314,6 @@ const CONST = {
         FEED_BANK_NAME: {
             MASTER_CARD: 'cdf',
             VISA: 'vcf',
-            VCF_MOCK: 'vcfmock',
             AMEX: 'gl1025',
             AMEX_1205: 'gl1205',
             STRIPE: 'stripe',
@@ -5570,7 +5544,6 @@ const CONST = {
             AMEX: 'American Express',
             VISA: 'Visa',
             MASTERCARD: 'Mastercard',
-            MOCK_COMMERCIAL: 'Mock Commercial Feed',
             STRIPE: 'Stripe',
             CSV: 'CSV',
         },
@@ -6104,7 +6077,7 @@ const CONST = {
     MAX_MARKUP_LENGTH: 10000,
 
     // WebKit renders only the ellipsis when a single-line text with text-overflow: ellipsis is longer than 10,240 characters (https://bugs.webkit.org/show_bug.cgi?id=267226).
-    // One line never shows this many characters, so we cut single-line texts to this length on WebKit browsers.
+    // One line never shows this many characters, so we cut single-line texts to this length on mobile WebKit.
     MAX_SINGLE_LINE_TEXT_LENGTH: 1000,
 
     MAX_THREAD_REPLIES_PREVIEW: 99,
@@ -9098,7 +9071,6 @@ const CONST = {
         HAS_DEVICE_MANAGEMENT_ERROR: 'hasDeviceManagementError',
         HAS_MERGE_HR_SETUP_NEEDED: 'hasMergeHRSetupNeeded',
         HAS_HR_CONNECTION_ERROR: 'hasHRConnectionError',
-        HAS_APPROVAL_WORKFLOW_NON_MEMBER_APPROVER: 'hasApprovalWorkflowNonMemberApprover',
     },
 
     DEBUG: {
@@ -9473,25 +9445,6 @@ const CONST = {
             /** How wide a free-text column may be sized for its content once the table scrolls, so one unusually long value doesn't push every column after it out of view. A table that still fits its columns caps nothing: the spare room is there to be used. */
             MAX_FREE_TEXT_COLUMN_WIDTH: 180,
         },
-
-        /** Stored-width keys, one per column set rather than per screen, so tables with the same columns share widths. */
-        COLUMN_RESIZING_IDS: {
-            WORKSPACE_MEMBERS: 'workspaceMembers',
-            WORKSPACE_TAXES: 'workspaceTaxes',
-            WORKSPACE_EXPENSIFY_CARDS: 'workspaceExpensifyCards',
-            WORKSPACE_COMPANY_CARDS: 'workspaceCompanyCards',
-        },
-
-        COLUMN_RESIZE: {
-            /** Narrowest drag width. Low enough to hide most content, but wide enough to keep the column's edge reachable. */
-            MIN_WIDTH: 48,
-
-            /** Width of the invisible drag strip centred on a column's edge. */
-            HANDLE_HIT_WIDTH: 12,
-
-            /** Width of the line shown while a column's edge is dragged. */
-            INDICATOR_WIDTH: 2,
-        },
     },
 
     SENTRY_LABEL: {
diff --git a/src/Expensify.tsx b/src/Expensify.tsx
index ef75ca168830..41a1d47b7363 100644
--- a/src/Expensify.tsx
+++ b/src/Expensify.tsx
@@ -23,7 +23,6 @@ import useDebugShortcut from './hooks/useDebugShortcut';
 import useIsAuthenticated from './hooks/useIsAuthenticated';
 import useLocalize from './hooks/useLocalize';
 import useOnyx from './hooks/useOnyx';
-import useSaveMarketingAttribution from './hooks/useSaveMarketingAttribution';
 import {updateLastRoute} from './libs/actions/App';
 import {initReconnect} from './libs/actions/Reconnect';
 import * as ActiveClientManager from './libs/ActiveClientManager';
@@ -68,7 +67,6 @@ function Expensify() {
     const [updateRequired] = useOnyx(ONYXKEYS.RAM_ONLY_UPDATE_REQUIRED);
     const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH);
     useDebugShortcut();
-    useSaveMarketingAttribution();
 
     useEffect(() => {
         initializeTelemetryTrackers();
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 4c1b16c744b9..3c4c056e03ac 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -75,9 +75,6 @@ const ONYXKEYS = {
     CREDENTIALS: 'credentials',
     STASHED_CREDENTIALS: 'stashedCredentials',
 
-    /** Marketing attribution (UTM params and Google Ads click ID) captured from the landing URL */
-    MARKETING_ATTRIBUTION: 'marketingAttribution',
-
     /** Keeps track if there is modal currently visible or not */
     MODAL: 'modal',
 
@@ -909,9 +906,6 @@ const ONYXKEYS = {
     /** Stores the user's report details columns preference */
     NVP_REPORT_DETAILS_COLUMNS: 'nvp_reportDetailsColumns',
 
-    /** Dragged table column widths, keyed by `columnResizingID` then column key. Per device, since ideal widths depend on window size. */
-    TABLE_COLUMN_WIDTHS: 'tableColumnWidths',
-
     /** Partial transaction data used for MFA authorize transaction preview */
     TRANSACTIONS_PENDING_3DS_REVIEW: 'transactionsPending3DSReview',
 
@@ -1698,7 +1692,6 @@ type OnyxValuesMapping = {
     [ONYXKEYS.CURRENT_DATE]: string;
     [ONYXKEYS.CREDENTIALS]: OnyxTypes.Credentials;
     [ONYXKEYS.STASHED_CREDENTIALS]: OnyxTypes.Credentials;
-    [ONYXKEYS.MARKETING_ATTRIBUTION]: OnyxTypes.StoredMarketingAttribution;
     [ONYXKEYS.MODAL]: OnyxTypes.Modal;
     [ONYXKEYS.IS_OPEN_APP_FAILURE_MODAL_OPEN]: boolean;
     [ONYXKEYS.IS_GPS_IN_PROGRESS_MODAL_OPEN]: boolean;
@@ -1975,7 +1968,6 @@ type OnyxValuesMapping = {
     [ONYXKEYS.NVP_REPORT_LAYOUT_GROUP_BY]: string;
     [ONYXKEYS.NVP_REPORT_LAYOUT_OPTION]: string;
     [ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS]: string[];
-    [ONYXKEYS.TABLE_COLUMN_WIDTHS]: OnyxTypes.TableColumnWidths;
     [ONYXKEYS.HAS_DENIED_CONTACT_IMPORT_PROMPT]: boolean | undefined;
     [ONYXKEYS.PERSONAL_POLICY_ID]: string;
     [ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS]: Record;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 3f7300a0957d..48c0896e20d0 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -2077,17 +2077,8 @@ const DYNAMIC_ROUTES = {
     MONEY_REQUEST_UPGRADE: {
         path: 'money-request-upgrade',
         entryScreens: ['*'],
-        getRoute: (params: {
-            action: IOUAction;
-            iouType: IOUType;
-            transactionID: string;
-            reportID: string;
-            upgradeBackTo?: string;
-            shouldSubmitExpense?: boolean;
-            upgradePath?: string;
-            shouldReturnToConfirmation?: boolean;
-        }) => {
-            const {action, iouType, transactionID, reportID, upgradeBackTo, shouldSubmitExpense, upgradePath, shouldReturnToConfirmation} = params;
+        getRoute: (params: {action: IOUAction; iouType: IOUType; transactionID: string; reportID: string; upgradeBackTo?: string; shouldSubmitExpense?: boolean; upgradePath?: string}) => {
+            const {action, iouType, transactionID, reportID, upgradeBackTo, shouldSubmitExpense, upgradePath} = params;
             return getUrlWithParams('money-request-upgrade', {
                 action,
                 iouType,
@@ -2096,10 +2087,9 @@ const DYNAMIC_ROUTES = {
                 upgradeBackTo,
                 shouldSubmitExpense: shouldSubmitExpense ? 'true' : undefined,
                 upgradePath,
-                shouldReturnToConfirmation: shouldReturnToConfirmation ? 'true' : undefined,
             });
         },
-        queryParams: ['action', 'iouType', 'transactionID', 'reportID', 'upgradeBackTo', 'shouldSubmitExpense', 'upgradePath', 'shouldReturnToConfirmation'],
+        queryParams: ['action', 'iouType', 'transactionID', 'reportID', 'upgradeBackTo', 'shouldSubmitExpense', 'upgradePath'],
     },
 } as const satisfies DynamicRoutes;
 
diff --git a/src/components/ApprovalWorkflowSection.tsx b/src/components/ApprovalWorkflowSection.tsx
index c8e9c09800ac..942e62683cac 100644
--- a/src/components/ApprovalWorkflowSection.tsx
+++ b/src/components/ApprovalWorkflowSection.tsx
@@ -6,11 +6,10 @@ import useTheme from '@hooks/useTheme';
 import useThemeStyles from '@hooks/useThemeStyles';
 
 import sortAlphabetically from '@libs/sortAlphabetically';
-import {getApprovalLimitDescription, getNonMemberApproverError} from '@libs/WorkflowUtils';
+import {getApprovalLimitDescription} from '@libs/WorkflowUtils';
 
 import CONST from '@src/CONST';
 import type ApprovalWorkflow from '@src/types/onyx/ApprovalWorkflow';
-import type {Approver} from '@src/types/onyx/ApprovalWorkflow';
 
 import {Str} from 'expensify-common';
 import React from 'react';
@@ -112,17 +111,6 @@ function ApprovalWorkflowSection({
         email: m.email,
     }));
     const pressAction = isDisabled ? undefined : onPress;
-
-    // Only shown to someone who can fix it, since the copy asks them to pick a new approver
-    const getApproverError = (approver: Approver) => {
-        const error = isDisabled ? undefined : getNonMemberApproverError(approver, approvalWorkflow.isDefault);
-        return error ? translate(error) : undefined;
-    };
-    const nonMemberApproverError = approvalWorkflow.approvers.map(getApproverError).find(Boolean);
-    const editWorkflowText = translate('workflowsPage.editWorkflowAction');
-
-    // The approver rows aren't exposed to screen readers, so the Edit button reads the error out instead
-    const editButtonAccessibilityLabel = nonMemberApproverError ? `${editWorkflowText}, ${nonMemberApproverError}` : editWorkflowText;
     const accessibilityLabel = translate('workflowsPage.accessibilityLabel', {
         members,
         approvers: approvalWorkflow?.approvers
@@ -222,8 +210,6 @@ function ApprovalWorkflowSection({
                                 }
                                 helperText={getApprovalLimitDescription({approver, currency, translate, formatPhoneNumber, convertToDisplayString})}
                                 helperTextStyle={styles.workflowApprovalLimitText}
-                                brickRoadIndicator={getApproverError(approver) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
-                                errorText={getApproverError(approver)}
                                 sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.WORKFLOWS.APPROVAL_SECTION_APPROVER}
                             />
                         
@@ -235,11 +221,11 @@ function ApprovalWorkflowSection({
                     
                 
             )}
diff --git a/src/components/InitialURLContextProvider.tsx b/src/components/InitialURLContextProvider.tsx
index 74af48f8c47c..d4b44a9d1778 100644
--- a/src/components/InitialURLContextProvider.tsx
+++ b/src/components/InitialURLContextProvider.tsx
@@ -1,4 +1,3 @@
-import CONST from '@src/CONST';
 import type {Route} from '@src/ROUTES';
 
 import type {ReactNode} from 'react';
@@ -8,7 +7,6 @@ import {Linking} from 'react-native';
 
 type InitialUrlStateContextType = {
     initialURL: Route | null;
-    isLoadingInitialURL: boolean;
     isAuthenticatedAtStartup: boolean;
 };
 
@@ -25,7 +23,6 @@ const defaultInitialURLActionsContext: InitialUrlActionsContextType = {
 /** Initial url that will be opened when NewDot is embedded into Hybrid App. */
 const InitialURLStateContext = createContext({
     initialURL: null,
-    isLoadingInitialURL: true,
     isAuthenticatedAtStartup: false,
 });
 
@@ -38,37 +35,21 @@ type InitialURLContextProviderProps = {
 
 function InitialURLContextProvider({children}: InitialURLContextProviderProps) {
     const [initialURL, setInitialURL] = useState(null);
-    const [isLoadingInitialURL, setIsLoadingInitialURL] = useState(true);
     const [isAuthenticatedAtStartup, setIsAuthenticatedAtStartup] = useState(false);
 
     useEffect(() => {
-        let timeoutId: ReturnType;
-
-        // Race against a timeout so isLoadingInitialURL doesn't stay stuck if getInitialURL() never resolves
-        Promise.race([
-            Linking.getInitialURL(),
-            new Promise((resolve) => {
-                timeoutId = setTimeout(() => {
-                    resolve(null);
-                }, CONST.TIMING.GET_INITIAL_URL_TIMEOUT);
-            }),
-        ])
-            .then((initURL) => {
-                if (!initURL) {
-                    return;
-                }
-                setInitialURL(initURL as Route);
-            })
-            .finally(() => setIsLoadingInitialURL(false));
-
-        return () => clearTimeout(timeoutId);
+        Linking.getInitialURL().then((initURL) => {
+            if (!initURL) {
+                return;
+            }
+            setInitialURL(initURL as Route);
+        });
     }, []);
 
     // Because of the React Compiler we don't need to memoize it manually
     // eslint-disable-next-line react/jsx-no-constructed-context-values
     const stateContextValue = {
         initialURL,
-        isLoadingInitialURL,
         isAuthenticatedAtStartup,
     };
 
diff --git a/src/components/MoneyRequestConfirmationList/sections/CategoryField.tsx b/src/components/MoneyRequestConfirmationList/sections/CategoryField.tsx
index 582240122314..3b10795901c6 100644
--- a/src/components/MoneyRequestConfirmationList/sections/CategoryField.tsx
+++ b/src/components/MoneyRequestConfirmationList/sections/CategoryField.tsx
@@ -1,30 +1,28 @@
 import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
 import {useConfirmationFields} from '@components/MoneyRequestConfirmationFields/context';
-import usePolicyCategoriesForConfirmation from '@components/MoneyRequestConfirmationList/hooks/usePolicyCategoriesForConfirmation';
 
 import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
 import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
 import usePermissions from '@hooks/usePermissions';
 import useThemeStyles from '@hooks/useThemeStyles';
 
 import {getDecodedLeafCategoryName, isCategoryMissing} from '@libs/CategoryUtils';
+import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
 import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
 import Navigation from '@libs/Navigation/Navigation';
-import TransitionTracker from '@libs/Navigation/TransitionTracker';
 import {hasEnabledOptions} from '@libs/OptionsListUtils';
 
 import CONST from '@src/CONST';
 import type {IOUAction, IOUType} from '@src/CONST';
 import type {TranslationPaths} from '@src/languages/types';
+import ONYXKEYS from '@src/ONYXKEYS';
 import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES';
 import type * as OnyxTypes from '@src/types/onyx';
 
 import type {OnyxEntry} from 'react-native-onyx';
 
-import {useFocusEffect, useIsFocused} from '@react-navigation/native';
-import React, {useEffect, useRef} from 'react';
-
-import type {ExpenseFieldDropdownHandle} from './ExpenseFieldDropdown';
+import React from 'react';
 
 import CategoryFieldDropdown from './CategoryFieldDropdown';
 import ExpenseFieldDropdown from './ExpenseFieldDropdown';
@@ -32,8 +30,7 @@ import {useExpenseFormLayout} from './ExpenseFormLayoutContext';
 import {categoryStateSelector} from './selectors';
 import useTransactionSelector from './useTransactionSelector';
 
-/** idle → departing (row pressed) → away (left for the workspace step) → returned (back on the form) → idle */
-type WorkspaceStep = 'idle' | 'departing' | 'away' | 'returned';
+const hasEnabledCategoriesSelector = (policyCategories: OnyxEntry) => hasEnabledOptions(Object.values(policyCategories ?? {}));
 
 type CategoryFieldProps = {
     isCategoryRequired: boolean;
@@ -70,15 +67,9 @@ function CategoryField({
     const styles = useThemeStyles();
     const {translate} = useLocalize();
     const icons = useMemoizedLazyExpensifyIcons(['Sparkles']);
-    const dropdownRef = useRef(null);
-    const isFocused = useIsFocused();
-    // Where the user is in the workspace step the row sent them to. Scoped to that one departure and return: it
-    // disarms when they leave the form again, so a later unrelated focus cannot open the list by itself.
-    const workspaceStepRef = useRef('idle');
 
     const categoryState = useTransactionSelector(transactionID, categoryStateSelector);
-    const policyCategories = usePolicyCategoriesForConfirmation(policy?.id);
-    const hasEnabledCategories = hasEnabledOptions(Object.values(policyCategories ?? {}));
+    const [hasEnabledCategories = false] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(policy?.id)}`, {selector: hasEnabledCategoriesSelector});
 
     const shouldDisplayCategoryError = formError === 'violations.categoryOutOfPolicy';
     const iouCategory = categoryState?.category ?? '';
@@ -105,67 +96,11 @@ function CategoryField({
         return '';
     };
 
-    const canSaveFromThisForm = action !== CONST.IOU.ACTION.EDIT || isEditingSplitBill;
-    const canUseAnchoredFieldDropdowns = isBetaEnabled(CONST.BETAS.ANCHORED_FIELD_DROPDOWNS);
-    const isCreatingExpense = action === CONST.IOU.ACTION.CREATE;
-    // While creating, the list loads the categories itself and shows its own empty state, so it never needs the full page.
-    const canShowCategories = isCreatingExpense || hasEnabledCategories;
-    const shouldOpenInDropdown =
-        canUseAnchoredFieldDropdowns && !!transactionID && !!policy && !shouldNavigateToUpgradePath && !shouldSelectPolicy && canShowCategories && canSaveFromThisForm;
-    // The one condition both the row and the effect below open on, so the effect never asks the row to open a
-    // list it would refuse and fall back to the full page for.
-    const canOpenListInPlace = shouldOpenInDropdown && !isReadOnly && !didConfirm;
-    const canOpenInDropdownAfterWorkspaceStep = canUseAnchoredFieldDropdowns && shouldUseDropdownRows && isCreatingExpense;
-
-    // Blurs move the step along: the first is the departure to the workspace step, the next after returning means
-    // the user left the form for something else, and the arm must not survive that.
-    useEffect(() => {
-        if (isFocused) {
-            return;
-        }
-        if (workspaceStepRef.current === 'departing') {
-            workspaceStepRef.current = 'away';
-        } else if (workspaceStepRef.current === 'returned') {
-            workspaceStepRef.current = 'idle';
-        }
-    }, [isFocused]);
-
-    // The row sent the user to create or pick a workspace, and the upgrade step was told to only go back (`shouldReturnToConfirmation`).
-    // Once the form is focused again with a workspace, wait for the RHP to finish closing, then open the list in place.
-    useFocusEffect(() => {
-        if (workspaceStepRef.current === 'away') {
-            workspaceStepRef.current = 'returned';
-        }
-        if (workspaceStepRef.current !== 'returned') {
-            return;
-        }
-
-        // The user backed out without a workspace, so there is still nothing to list.
-        if (shouldNavigateToUpgradePath || shouldSelectPolicy) {
-            workspaceStepRef.current = 'idle';
-            return;
-        }
-
-        if (!canOpenListInPlace) {
-            return;
-        }
-
-        const handle = TransitionTracker.runAfterTransitions({
-            callback: () => {
-                workspaceStepRef.current = 'idle';
-                dropdownRef.current?.open();
-            },
-            waitForUpcomingTransition: 'navigation',
-        });
-        return () => handle.cancel();
-    });
-
     const openCategoryPage = () => {
         if (!transactionID) {
             return;
         }
 
-        workspaceStepRef.current = (shouldNavigateToUpgradePath || (!policy && shouldSelectPolicy)) && canOpenInDropdownAfterWorkspaceStep ? 'departing' : 'idle';
         if (shouldNavigateToUpgradePath) {
             Navigation.navigate(
                 createDynamicRoute(
@@ -184,16 +119,23 @@ function CategoryField({
                             }),
                         ),
                         upgradePath: CONST.UPGRADE_PATHS.CATEGORIES,
-                        shouldReturnToConfirmation: canOpenInDropdownAfterWorkspaceStep,
                     }),
                 ),
             );
         } else if (!policy && shouldSelectPolicy) {
-            // Without `navigateTo`, the page goes back to this form once a workspace is picked, and the list opens here.
-            const categoryStepRoute = canOpenInDropdownAfterWorkspaceStep
-                ? undefined
-                : createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({action, iouType, transactionID, reportID, reportActionID}));
-            Navigation.navigate(ROUTES.SET_DEFAULT_WORKSPACE.getRoute(categoryStepRoute));
+            Navigation.navigate(
+                ROUTES.SET_DEFAULT_WORKSPACE.getRoute(
+                    createDynamicRoute(
+                        DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({
+                            action,
+                            iouType,
+                            transactionID,
+                            reportID,
+                            reportActionID,
+                        }),
+                    ),
+                ),
+            );
         } else {
             Navigation.navigate(
                 createDynamicRoute(
@@ -209,10 +151,14 @@ function CategoryField({
         }
     };
 
+    const canSaveFromThisForm = action !== CONST.IOU.ACTION.EDIT || isEditingSplitBill;
+    const canUseAnchoredFieldDropdowns = isBetaEnabled(CONST.BETAS.ANCHORED_FIELD_DROPDOWNS);
+    const shouldOpenInDropdown =
+        canUseAnchoredFieldDropdowns && !!transactionID && !!policy && !shouldNavigateToUpgradePath && !shouldSelectPolicy && hasEnabledCategories && canSaveFromThisForm;
+
     if (shouldUseDropdownRows) {
         return (
             
                     !!transactionID && (
                          void;
-
-    /** Lets the field open its list without a press, e.g. once the user is back from a step the press sent them to */
-    ref?: Ref;
-};
-
-type ExpenseFieldDropdownHandle = {
-    /** Opens the list as a press would, falling back to `onPress` when it can't open in place. Does nothing if it is already open. */
-    open: () => void;
 };
 
 /**
@@ -103,7 +95,7 @@ type ExpenseFieldDropdownHandle = {
  * pop-over matches the row's width and opens below it, or above when there isn't room, capped so it is never
  * clipped. Knows nothing about any particular field: each passes its own list in through `renderDropdown`.
  */
-function ExpenseFieldDropdown({renderDropdown, shouldOpenInDropdown, onPress, ref, ...rowProps}: ExpenseFieldDropdownProps) {
+function ExpenseFieldDropdown({renderDropdown, shouldOpenInDropdown, onPress, ...rowProps}: ExpenseFieldDropdownProps) {
     const {windowHeight} = useWindowDimensions();
     // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth -- must match PopoverWithMeasuredContent's dock decision, which is on isSmallScreenWidth
     const {isSmallScreenWidth} = useResponsiveLayout();
@@ -164,18 +156,6 @@ function ExpenseFieldDropdown({renderDropdown, shouldOpenInDropdown, onPress, re
         openDropdown();
     };
 
-    useImperativeHandle(ref, () => ({
-        open: () => {
-            if (!shouldOpenInDropdown) {
-                onPress();
-                return;
-            }
-            if (!isVisible) {
-                openDropdown();
-            }
-        },
-    }));
-
     return (
         <>
             ({
-        source: undefined,
-        resolvedImage: undefined,
-        upgradeCount: 0,
-    });
-    if (isLocalFile && (initialLocalSource.source === undefined || initialLocalSource.resolvedImage !== resolvedReceiptImageStr || initialLocalSource.upgradeCount !== upgradeCount)) {
+    const [initialLocalSource, setInitialLocalSource] = useState<{source: string | undefined; resolvedImage: string | undefined}>({source: undefined, resolvedImage: undefined});
+    if (isLocalFile && (initialLocalSource.source === undefined || initialLocalSource.resolvedImage !== resolvedReceiptImageStr)) {
         // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty string fallback is intentional: we want to skip the empty-string case in the OR chain, not treat it as a valid source
-        setInitialLocalSource({source: thumbnailUri || resolvedReceiptImageStr || '', resolvedImage: resolvedReceiptImageStr, upgradeCount});
+        setInitialLocalSource({source: thumbnailUri || resolvedReceiptImageStr || '', resolvedImage: resolvedReceiptImageStr});
     }
 
     // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty string fallback is intentional: we want to skip the empty-string case in the OR chain, not treat it as a valid source
diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx
index 902069f39815..fa9f09ab9d60 100644
--- a/src/components/ReportActionItem/MoneyRequestView.tsx
+++ b/src/components/ReportActionItem/MoneyRequestView.tsx
@@ -343,7 +343,6 @@ function MoneyRequestView({
         originalCurrency: transactionOriginalCurrency,
         postedDate: transactionPostedDate,
         convertedAmount: transactionConvertedAmount,
-        convertedTaxAmount: transactionConvertedTaxAmount,
     } = getTransactionDetails(displayTransaction, undefined, undefined, allowNegativeAmount, false) ?? {};
     const transactionAttendees = useAttendees(transaction);
     const isEmptyMerchant = isInvalidMerchantValue(transactionMerchant);
@@ -377,9 +376,12 @@ function MoneyRequestView({
     const shouldShowCard = isFromCardImport && cardProgramName;
 
     const taxRates = policy?.taxRates;
-    const formattedTaxAmount = convertToDisplayString(transactionTaxAmount ?? 0, actualCurrency);
+    const formattedTaxAmount =
+        updatedTransaction?.taxAmount !== undefined
+            ? convertToDisplayString(Math.abs(updatedTransaction?.taxAmount), actualCurrency)
+            : convertToDisplayString(Math.abs(transactionTaxAmount ?? 0), actualCurrency);
     // Skip a zero converted tax (e.g. tax exempt) so we don't render a redundant "Converted 0.00".
-    const formattedConvertedTaxAmount = transactionConvertedTaxAmount ? convertToDisplayString(transactionConvertedTaxAmount, moneyRequestReport?.currency) : '';
+    const formattedConvertedTaxAmount = transaction?.convertedTaxAmount ? convertToDisplayString(Math.abs(transaction.convertedTaxAmount), moneyRequestReport?.currency) : '';
 
     const taxRatesDescription = taxRates?.name;
 
diff --git a/src/components/ReportOrNotFoundGuard.tsx b/src/components/ReportOrNotFoundGuard.tsx
deleted file mode 100644
index 94f9e9bb6d49..000000000000
--- a/src/components/ReportOrNotFoundGuard.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-import useOnyx from '@hooks/useOnyx';
-import usePermissions from '@hooks/usePermissions';
-import useReportIsArchived from '@hooks/useReportIsArchived';
-
-import {canAccessReport, hasExpensifyGuidesEmails} from '@libs/ReportUtils';
-
-import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
-import useReportDeepLinkOnOpen from '@pages/inbox/report/useReportDeepLinkOnOpen';
-
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-import type * as OnyxTypes from '@src/types/onyx';
-import {isEmptyObject} from '@src/types/utils/EmptyObject';
-
-import type {ReactElement} from 'react';
-import type {OnyxEntry} from 'react-native-onyx';
-
-import {useIsFocused} from '@react-navigation/native';
-import React, {useState} from 'react';
-
-import FullscreenLoadingIndicator from './FullscreenLoadingIndicator';
-
-type ReportOrNotFoundDecision = 'blank' | 'loading' | 'notFound' | 'content';
-
-type ReportOrNotFoundDecisionParams = {
-    shouldRequireReportID: boolean;
-    isReportIdInRoute: boolean;
-    isReportLoaded: boolean;
-    report: OnyxEntry;
-    isLoadingReportData: OnyxEntry;
-    shouldFetchReport: boolean;
-    isDefaultRoomsBetaEnabled: boolean;
-    hasGuidesEmails: boolean;
-    isReportArchived: boolean;
-    isFocused: boolean;
-    hasShownContent: boolean;
-    deleteTransactionNavigateBackUrl: OnyxEntry;
-};
-
-function getReportOrNotFoundDecision({
-    shouldRequireReportID,
-    isReportIdInRoute,
-    isReportLoaded,
-    report,
-    isLoadingReportData,
-    shouldFetchReport,
-    isDefaultRoomsBetaEnabled,
-    hasGuidesEmails,
-    isReportArchived,
-    isFocused,
-    hasShownContent,
-    deleteTransactionNavigateBackUrl,
-}: ReportOrNotFoundDecisionParams): ReportOrNotFoundDecision {
-    if (!shouldRequireReportID && !isReportIdInRoute) {
-        return 'content';
-    }
-
-    const shouldShowFullScreenLoadingIndicator = !isReportLoaded && (isLoadingReportData !== false || shouldFetchReport);
-    const shouldShowNotFoundPage = !isReportLoaded || !canAccessReport(report, isDefaultRoomsBetaEnabled, hasGuidesEmails, isReportArchived);
-
-    // If the content was shown, but it's not anymore, that means the report was deleted, and we are probably navigating out of this screen.
-    // Return null for this case to avoid rendering FullScreenLoadingIndicator or NotFoundPage when animating transition.
-    // We also suppress the NotFound page while a delete-transaction navigation is in flight (e.g. deleting an invoice
-    // navigates back to the invoice room without synchronously removing focus from this details RHP), mirroring ReportNotFoundGuard.
-    if (shouldShowNotFoundPage && hasShownContent && (!isFocused || !!deleteTransactionNavigateBackUrl)) {
-        return 'blank';
-    }
-
-    if (shouldShowFullScreenLoadingIndicator) {
-        return 'loading';
-    }
-
-    if (shouldShowNotFoundPage) {
-        return 'notFound';
-    }
-
-    return 'content';
-}
-
-type ReportOrNotFoundGuardProps = {
-    reportID: string | undefined;
-    shouldRequireReportID?: boolean;
-    children: ReactElement;
-};
-
-function ReportOrNotFoundGuard({reportID, shouldRequireReportID = true, children}: ReportOrNotFoundGuardProps) {
-    const {isBetaEnabled} = usePermissions();
-    const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
-    const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`);
-    const [isLoadingReportData] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA);
-    const [deleteTransactionNavigateBackUrl] = useOnyx(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL);
-    const [guideAccountIDs] = useOnyx(ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS);
-    const hasGuidesEmails = hasExpensifyGuidesEmails(Object.keys(report?.participants ?? {}).map(Number), guideAccountIDs);
-    const isFocused = useIsFocused();
-    const isReportIdInRoute = !!reportID?.length;
-    const isReportLoaded = !isEmptyObject(report) && !!report?.reportID;
-    const isReportArchived = useReportIsArchived(report?.reportID);
-    // The `isLoadingInitialReportActions` value will become `false` only after the first OpenReport API call is finished (either succeeded or failed)
-    const shouldFetchReport = isReportIdInRoute && reportLoadingState?.isLoadingInitialReportActions !== false;
-
-    useReportDeepLinkOnOpen({reportID, isReportLoaded, shouldFetchReport});
-
-    const [hasShownContent, setHasShownContent] = useState(false);
-    const isDefaultRoomsBetaEnabled = isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS);
-    const decision = getReportOrNotFoundDecision({
-        shouldRequireReportID,
-        isReportIdInRoute,
-        isReportLoaded,
-        report,
-        isLoadingReportData,
-        shouldFetchReport,
-        isDefaultRoomsBetaEnabled,
-        hasGuidesEmails,
-        isReportArchived,
-        isFocused,
-        hasShownContent,
-        deleteTransactionNavigateBackUrl,
-    });
-
-    if (decision === 'content' && !hasShownContent) {
-        setHasShownContent(true);
-    }
-
-    if (decision === 'blank') {
-        return null;
-    }
-
-    if (decision === 'loading') {
-        return ;
-    }
-
-    if (decision === 'notFound') {
-        return ;
-    }
-
-    return children;
-}
-
-export default ReportOrNotFoundGuard;
-export {getReportOrNotFoundDecision};
diff --git a/src/components/SignInButtons/AppleSignIn/index.tsx b/src/components/SignInButtons/AppleSignIn/index.tsx
index 6143d0d78b08..4e5b3c2e62dc 100644
--- a/src/components/SignInButtons/AppleSignIn/index.tsx
+++ b/src/components/SignInButtons/AppleSignIn/index.tsx
@@ -9,10 +9,8 @@ import CONST from '@src/CONST';
 import ONYXKEYS from '@src/ONYXKEYS';
 import type {AppleIDSignInOnFailureEvent, AppleIDSignInOnSuccessEvent} from '@src/types/modules/dom';
 import type Locale from '@src/types/onyx/Locale';
-import type {StoredMarketingAttribution} from '@src/types/onyx/MarketingAttribution';
 
 import type {NativeConfig} from 'react-native-config';
-import type {OnyxEntry} from 'react-native-onyx';
 
 import {useIsFocused} from '@react-navigation/native';
 import React, {useEffect, useState} from 'react';
@@ -55,9 +53,9 @@ const config = {
  * Apple Sign In success and failure listeners.
  */
 
-const successListener = (event: AppleIDSignInOnSuccessEvent, preferredLocale?: Locale, marketingAttribution?: OnyxEntry) => {
+const successListener = (event: AppleIDSignInOnSuccessEvent, preferredLocale?: Locale) => {
     const token = event.detail.authorization.id_token;
-    beginAppleSignIn(token, preferredLocale, marketingAttribution);
+    beginAppleSignIn(token, preferredLocale);
 };
 
 const failureListener = (event: AppleIDSignInOnFailureEvent) => {
@@ -72,7 +70,6 @@ const failureListener = (event: AppleIDSignInOnFailureEvent) => {
  */
 function AppleSignInDiv({isDesktopFlow, onPointerDown}: AppleSignInDivProps) {
     const [preferredLocale] = useOnyx(ONYXKEYS.NVP_PREFERRED_LOCALE);
-    const [marketingAttribution] = useOnyx(ONYXKEYS.MARKETING_ATTRIBUTION);
     useEffect(() => {
         // `init` renders the button, so it must be called after the div is
         // first mounted.
@@ -80,17 +77,14 @@ function AppleSignInDiv({isDesktopFlow, onPointerDown}: AppleSignInDivProps) {
     }, []);
     //  Result listeners need to live within the focused item to avoid duplicate
     //  side effects on success and failure.
-    //  The success listener is kept in a variable so the cleanup removes the same function, otherwise a re-run of this effect would
-    //  leave the previous listener attached and sign in twice.
     React.useEffect(() => {
-        const onSuccess = (event: AppleIDSignInOnSuccessEvent) => successListener(event, preferredLocale, marketingAttribution);
-        document.addEventListener('AppleIDSignInOnSuccess', onSuccess);
+        document.addEventListener('AppleIDSignInOnSuccess', (event) => successListener(event, preferredLocale));
         document.addEventListener('AppleIDSignInOnFailure', failureListener);
         return () => {
-            document.removeEventListener('AppleIDSignInOnSuccess', onSuccess);
+            document.removeEventListener('AppleIDSignInOnSuccess', (event) => successListener(event, preferredLocale));
             document.removeEventListener('AppleIDSignInOnFailure', failureListener);
         };
-    }, [preferredLocale, marketingAttribution]);
+    }, [preferredLocale]);
 
     return isDesktopFlow ? (
         
) => { - beginGoogleSignIn(response.credential, preferredLocale, marketingAttribution); +const signIn = (response: Response, preferredLocale?: Locale) => { + beginGoogleSignIn(response.credential, preferredLocale); }; /** @@ -41,14 +38,13 @@ function GoogleSignIn({isDesktopFlow = false, onPointerDown}: GoogleSignInProps) const {translate} = useLocalize(); const styles = useThemeStyles(); const [preferredLocale] = useOnyx(ONYXKEYS.NVP_PREFERRED_LOCALE); - const [marketingAttribution] = useOnyx(ONYXKEYS.MARKETING_ATTRIBUTION); const loadScript = useCallback(() => { const google = window.google; if (google) { google.accounts.id.initialize({ // eslint-disable-next-line @typescript-eslint/naming-convention client_id: CONFIG.GOOGLE_SIGN_IN.WEB_CLIENT_ID, - callback: (response) => signIn(response, preferredLocale, marketingAttribution), + callback: (response) => signIn(response, preferredLocale), }); // Apply styles for each button google.accounts.id.renderButton(document.getElementById(mainId), { @@ -65,7 +61,7 @@ function GoogleSignIn({isDesktopFlow = false, onPointerDown}: GoogleSignInProps) width: '300px', }); } - }, [preferredLocale, marketingAttribution]); + }, [preferredLocale]); React.useEffect(() => { const script = document.createElement('script'); diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 1e178fda0227..17858dc2cb5a 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -7,9 +7,7 @@ import useKeyboardState from '@hooks/useKeyboardState'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; -import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useThemeStyles from '@hooks/useThemeStyles'; import useVerticalScrollbarWidth from '@hooks/useVerticalScrollbarWidth'; import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; @@ -18,7 +16,6 @@ import {canMeasureText} from '@libs/measureTextWidth'; import {acquireBackgroundInputFocusSuppression} from '@libs/ModalFocusManager'; import CONST from '@src/CONST'; -import type {ColumnWidthOverrides} from '@src/types/onyx/TableColumnWidths'; import type {FlashListRef} from '@shopify/flash-list'; import type {ReactElement} from 'react'; @@ -33,9 +30,6 @@ import type {TableHeaderProps} from './TableHeader'; import type {TableData, TableHandle, TableMethods, TableProps, TableRow} from './types'; import {getDataVisibleIndices, getListIndex, getTableListMetadata, rendersColumnHeader} from './buildTableListData'; -import getMeasureWidthRef from './columnResize/getMeasureWidthRef'; -import useColumnResize from './columnResize/useColumnResize'; -import useStoredColumnWidths from './columnResize/useStoredColumnWidths'; import useFiltering from './middlewares/filtering'; import useHighlighting from './middlewares/highlight'; import useSearching from './middlewares/searching'; @@ -144,12 +138,127 @@ function createTableHandle; } -type TableContentProps = TableProps & { - /** Stored dragged widths for this table's `columnResizingID`. */ - columnWidthOverrides?: ColumnWidthOverrides; -}; - -function TableContent({ +/** + * A composable table component that provides filtering, search, and sorting functionality. + * + * This component uses a compositional pattern where the parent `` component manages + * all state (filtering, searching, sorting) and provides it via context. Child components + * consume this context to render different parts of the table UI. + * + * ## Compositional Pattern + * + * The Table follows a compound component pattern similar to ``, `
`, or ``. + * You compose your table UI by nesting the sub-components you need: + * + * - `
` - The parent component that manages state and provides context + * - `` - Renders sortable column headers + * - `` - Renders the data rows using FlashList + * - `` - Renders a search input that filters data + * + * ## Middleware Architecture + * + * Data processing is handled through a pipeline of middleware functions: + * 1. **Filtering** - Applies dropdown filter selections + * 2. **Searching** - Applies search string filtering + * 3. **Sorting** - Sorts data by the active column + * 4. **Selection** - Applies row selection state & provides helpers for selection + * + * Each middleware transforms the data array and passes it to the next. + * + * ## Generic Type Parameters + * + * - `DataType` - The type of items in your data array + * - `ColumnKey` - String literal union of valid column keys (e.g., `'name' | 'date'`) + * - `FilterKey` - String literal union of valid filter keys + * + * @example Basic Usage + * ```tsx + * type Item = { id: string; name: string; category: string }; + * type ColumnKey = 'name' | 'category'; + * + * const columns: Array> = [ + * { key: 'name', label: 'Name' }, + * { key: 'category', label: 'Category' }, + * ]; + * + * + * data={items} + * columns={columns} + * renderItem={({ item }) => } + * keyExtractor={(item) => item.id} + * > + * + * + *
+ * ``` + * + * @example With Search and Sorting + * ```tsx + * + * data={items} + * columns={columns} + * renderItem={renderItem} + * keyExtractor={keyExtractor} + * isItemInSearch={(item, searchString) => + * item.name.toLowerCase().includes(searchString.toLowerCase()) + * } + * compareItems={(a, b, { columnKey, order }) => { + * const multiplier = order === 'asc' ? 1 : -1; + * return a[columnKey].localeCompare(b[columnKey]) * multiplier; + * }} + * > + * + * + * + * + * ``` + * + * @example With Filters + * ```tsx + * const filterConfig: FilterConfig = { + * status: { + * filterType: 'singleSelect', + * options: [ + * { label: 'All', value: 'all' }, + * { label: 'Active', value: 'active' }, + * { label: 'Inactive', value: 'inactive' }, + * ], + * default: 'all', + * }, + * }; + * + * + * data={items} + * columns={columns} + * renderItem={renderItem} + * keyExtractor={keyExtractor} + * filters={filterConfig} + * isItemInFilter={(item, filterValues) => { + * if (filterValues.includes('all')) return true; + * return filterValues.includes(item.status); + * }} + * > + * + * + * + * ``` + * + * @example Programmatic Control via Ref + * ```tsx + * const tableRef = useRef>(null); + * + * // Programmatically update sorting + * tableRef.current?.updateSorting({ columnKey: 'name', order: 'desc' }); + * + * // Get current state + * const sorting = tableRef.current?.getActiveSorting(); + * + * + * + *
+ * ``` + */ +function Table({ ref, title, columns, @@ -169,13 +278,11 @@ function TableContent) { +}: TableProps) { const {translate} = useLocalize(); const isGlobalMobileSelectionEnabled = useMobileSelectionMode(); @@ -197,7 +304,6 @@ function TableContent({ + const {gridTemplateColumns: dynamicGridTemplateColumns, scrollWidth: dynamicScrollWidth} = useDynamicColumnWidths({ columns, data, tableWidth: contentWidth, isEnabled: isDynamicSizingEnabled, // In the wide layout the checkbox column is rendered whenever selection is enabled. hasSelectionColumn: !!selectionEnabled, - isColumnResizingEnabled, - columnWidthOverrides, - }); - - const columnResize = useColumnResize({ - columnResizingID: isColumnResizingEnabled ? columnResizingID : undefined, - resizableColumnKeys, - resolvedColumnWidths, - dragMinWidths, - columnGap: styles.gap3.gap, }); const tableMethods: TableMethods = { @@ -351,8 +436,7 @@ function TableContent 0; const isColumnHeaderHiddenInNarrowLayout = shouldUseNarrowTableLayout && !title; - // Resizable tables always get the scroller: a drag can overflow without a render, and moving the header mid-drag would remount the handle. - const hasHorizontalScrollContainer = isColumnResizingEnabled || !!dynamicScrollWidth; + const areColumnsScrollable = !!dynamicScrollWidth; const tableListMetadata = useMemo( () => @@ -362,9 +446,9 @@ function TableContent {renderedChildren} @@ -519,151 +598,4 @@ function TableContent( - props: TableProps & {columnResizingID: string}, -) { - const columnWidthOverrides = useStoredColumnWidths(props.columnResizingID); - - return ( - - ); -} - -/** - * A composable table component that provides filtering, search, and sorting functionality. - * - * This component uses a compositional pattern where the parent `` component manages - * all state (filtering, searching, sorting) and provides it via context. Child components - * consume this context to render different parts of the table UI. - * - * ## Compositional Pattern - * - * The Table follows a compound component pattern similar to ``, ``, or ``. - * You compose your table UI by nesting the sub-components you need: - * - * - `
` - The parent component that manages state and provides context - * - `` - Renders sortable column headers - * - `` - Renders the data rows using FlashList - * - `` - Renders a search input that filters data - * - * ## Middleware Architecture - * - * Data processing is handled through a pipeline of middleware functions: - * 1. **Filtering** - Applies dropdown filter selections - * 2. **Searching** - Applies search string filtering - * 3. **Sorting** - Sorts data by the active column - * 4. **Selection** - Applies row selection state & provides helpers for selection - * - * Each middleware transforms the data array and passes it to the next. - * - * ## Generic Type Parameters - * - * - `DataType` - The type of items in your data array - * - `ColumnKey` - String literal union of valid column keys (e.g., `'name' | 'date'`) - * - `FilterKey` - String literal union of valid filter keys - * - * @example Basic Usage - * ```tsx - * type Item = { id: string; name: string; category: string }; - * type ColumnKey = 'name' | 'category'; - * - * const columns: Array> = [ - * { key: 'name', label: 'Name' }, - * { key: 'category', label: 'Category' }, - * ]; - * - * - * data={items} - * columns={columns} - * renderItem={({ item }) => } - * keyExtractor={(item) => item.id} - * > - * - * - *
- * ``` - * - * @example With Search and Sorting - * ```tsx - * - * data={items} - * columns={columns} - * renderItem={renderItem} - * keyExtractor={keyExtractor} - * isItemInSearch={(item, searchString) => - * item.name.toLowerCase().includes(searchString.toLowerCase()) - * } - * compareItems={(a, b, { columnKey, order }) => { - * const multiplier = order === 'asc' ? 1 : -1; - * return a[columnKey].localeCompare(b[columnKey]) * multiplier; - * }} - * > - * - * - * - * - * ``` - * - * @example With Filters - * ```tsx - * const filterConfig: FilterConfig = { - * status: { - * filterType: 'singleSelect', - * options: [ - * { label: 'All', value: 'all' }, - * { label: 'Active', value: 'active' }, - * { label: 'Inactive', value: 'inactive' }, - * ], - * default: 'all', - * }, - * }; - * - * - * data={items} - * columns={columns} - * renderItem={renderItem} - * keyExtractor={keyExtractor} - * filters={filterConfig} - * isItemInFilter={(item, filterValues) => { - * if (filterValues.includes('all')) return true; - * return filterValues.includes(item.status); - * }} - * > - * - * - * - * ``` - * - * @example Programmatic Control via Ref - * ```tsx - * const tableRef = useRef>(null); - * - * // Programmatically update sorting - * tableRef.current?.updateSorting({ columnKey: 'name', order: 'desc' }); - * - * // Get current state - * const sorting = tableRef.current?.getActiveSorting(); - * - * - * - *
- * ``` - */ -function Table(props: TableProps) { - // Only tables that opted into resizing subscribe to stored widths - if (!props.columnResizingID) { - return ; - } - - return ( - - ); -} - export default Table; diff --git a/src/components/Table/TableContext.tsx b/src/components/Table/TableContext.tsx index c761dfacfafe..8d78d33f4fd4 100644 --- a/src/components/Table/TableContext.tsx +++ b/src/components/Table/TableContext.tsx @@ -11,7 +11,6 @@ import type {NativeScrollEvent, NativeSyntheticEvent, View} from 'react-native'; import React, {createContext, useContext} from 'react'; import type {TableListMetadata} from './buildTableListData'; -import type {ColumnResizeController} from './columnResize/useColumnResize/types'; import type {FilterConfig} from './middlewares/filtering'; import type {ActiveSorting, SortOrder} from './middlewares/sorting'; import type {TableHeaderProps} from './TableHeader'; @@ -82,14 +81,8 @@ type TableContextValue = { columns: [], dynamicGridTemplateColumns: undefined, scrollWidth: undefined, - rowWidth: undefined, - columnResize: undefined, tableWidth: 0, activeFilters: {}, activeSorting: { diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx index 155f6931c021..10fb6c091dd1 100644 --- a/src/components/Table/TableHeader.tsx +++ b/src/components/Table/TableHeader.tsx @@ -23,8 +23,6 @@ import {StyleSheet, View} from 'react-native'; import type {TableColumn, TableData} from './types'; import {rendersColumnHeaderInListHeader} from './buildTableListData'; -import ColumnResizeHandle from './columnResize/ColumnResizeHandle'; -import {TABLE_ROW_DATA_SET} from './columnResize/columnWidthExpressions'; import getGridTemplateColumns from './getGridTemplateColumns'; import {getColumnHeaderAccessibilityProps, getRowAccessibilityProps, shouldUseTableSemantics} from './tableAccessibility'; import {useTableContext} from './TableContext'; @@ -89,8 +87,6 @@ function TableHeader(); // Tables inside a narrow pane modal (RHP) opt into keying the header checkbox off the real screen size, since @@ -135,8 +131,6 @@ function TableHeader ); - // In the list header rather than FlashList's sticky overlay, so it scrolls sideways with the columns. Resizable tables - // size the inner row. The rest need the wrapper at the scroll width, or the background and bottom border fall short of the columns. - if (rendersColumnHeaderInListHeader(tableListMetadata)) { - return {header}; + // Sits in the list header rather than FlashList's sticky-row overlay, so the scroller carries it sideways with the + // columns. Needs an explicit width because the list header stretches to the scrolled content, which would leave the + // background and bottom border short of the columns. That background is what the rows scroll under once it's stuck. + if (rendersColumnHeaderInListHeader(tableListMetadata) && !!scrollWidth) { + return {header}; } if (!isStickyListHeader) { @@ -356,9 +346,6 @@ function TableHeaderColumn {sortButton} - - {/* Skipped in the sticky header's hidden twin to avoid duplicate handles. */} - {!isAccessibilityHidden && } ); } diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index 350039b357e0..84bfdd52947f 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -10,7 +10,6 @@ import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useLayoutSpacing from '@hooks/useLayoutSpacing'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -27,7 +26,6 @@ import {View} from 'react-native'; import Animated from 'react-native-reanimated'; import {rendersColumnHeader} from './buildTableListData'; -import {TABLE_ROW_DATA_SET} from './columnResize/columnWidthExpressions'; import getGridTemplateColumns from './getGridTemplateColumns'; import {assignCellColumnIndexes, getCellAccessibilityProps, getRowAccessibilityProps, shouldUseTableSemantics} from './tableAccessibility'; import {useTableContext, useTableRowSemanticID} from './TableContext'; @@ -83,7 +81,6 @@ export default function TableRow({ const theme = useTheme(); const styles = useThemeStyles(); const {pageGutterMargin} = useLayoutSpacing(); - const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, shouldUseNarrowLayout, isInNarrowPaneModal} = useResponsiveLayout(); @@ -99,7 +96,6 @@ export default function TableRow({ dynamicGridTemplateColumns, listProps, shouldFooterRenderAsLastRow, - rowWidth, } = useTableContext(); const semanticRowID = useTableRowSemanticID(); @@ -173,8 +169,6 @@ export default function TableRow({ const tableRowPressableStyles = [ pageGutterMargin, - // The list sizes rows from a measurement, so without this the background wouldn't follow a drag. - !!rowWidth && StyleUtils.getWidthStyle(rowWidth), isGroupHeader ? styles.hoveredComponentBG : styles.highlightBG, styles.userSelectNone, !isFirstRow && styles.borderTop, @@ -333,7 +327,6 @@ export default function TableRow({ onHoverIn={enableHoverStyle} pressDimmingValue={!interactive ? undefined : 1} role={interactive ? CONST.ROLE.BUTTON : CONST.ROLE.PRESENTATION} - dataSet={TABLE_ROW_DATA_SET} {...getRowAccessibilityProps(isTableSemanticsEnabled, rowIndex, false, semanticTableHasHeader)} onMouseDown={(e) => { captureEditingOnMouseDown(); diff --git a/src/components/Table/TableSemanticContainer.tsx b/src/components/Table/TableSemanticContainer.tsx index 370ead5a94ce..ac67d5af0713 100644 --- a/src/components/Table/TableSemanticContainer.tsx +++ b/src/components/Table/TableSemanticContainer.tsx @@ -10,7 +10,6 @@ import type {LayoutChangeEvent} from 'react-native'; import React from 'react'; import {View} from 'react-native'; -import ColumnResizeScope from './columnResize/ColumnResizeScope'; import {getTableContainerAccessibilityProps} from './tableAccessibility'; import TableBody from './TableBody'; import TableHeader from './TableHeader'; @@ -50,9 +49,8 @@ type TableSemanticContainerProps = { * The width the rows need when the columns don't fit, which scrolls the header/body run horizontally as one so the * header stays aligned with its rows. Set only for tables whose filter bar isn't in the list. The others are * scrolled by the list itself (see `TableBody`). - * Resizable tables always pass a CSS expression, so a drag past the edge scrolls without a re-render. */ - scrollWidth: number | string | undefined; + scrollWidth: number | undefined; /** * Measures the width the table's columns have to share. This node is the right thing to measure because it keeps the @@ -61,12 +59,6 @@ type TableSemanticContainerProps = { */ onLayout: ((event: LayoutChangeEvent) => void) | undefined; - /** Measures the same node as `onLayout` as soon as it mounts, ahead of the first paint. */ - measureWidthRef: ((node: unknown) => void) | undefined; - - /** Receives the element the resizable columns' widths are written on. Set only when column resizing is enabled. */ - onScopeElement: ((element: HTMLElement | null) => void) | undefined; - /** Table children — expected to contain a contiguous `TableHeader`/`TableBody` run. */ children: React.ReactNode; }; @@ -92,8 +84,6 @@ function TableSemanticContainer({ hasHeaderRow, scrollWidth, onLayout, - measureWidthRef, - onScopeElement, children, }: TableSemanticContainerProps) { const styles = useThemeStyles(); @@ -131,7 +121,6 @@ function TableSemanticContainer({ // it doesn't. Either way the measured node keeps the table's own width rather than growing with the // content, so measuring it can't feed back into the widths it produced. onLayout={scrollWidth ? undefined : onLayout} - ref={scrollWidth ? undefined : measureWidthRef} {...getTableContainerAccessibilityProps(isEnabled, title, rowCount, columnCount, hasHeaderRow)} > {rowGroup} @@ -141,26 +130,20 @@ function TableSemanticContainer({ // The columns don't fit, so the header and the body scroll horizontally as one and stay aligned. The content // container carries the width they need, and the rows fill it, matching how the Search table scrolls. renderedChildren.push( - // Wraps the scroller too, so one width write resizes its content along with the header and rows. - - {scrollWidth ? ( - - {rowGroupContainer} - - ) : ( - rowGroupContainer - )} - , + scrollWidth ? ( + + {rowGroupContainer} + + ) : ( + rowGroupContainer + ), ); rowGroup = []; }; diff --git a/src/components/Table/buildTableListData.ts b/src/components/Table/buildTableListData.ts index 335ec82dc223..142218eead98 100644 --- a/src/components/Table/buildTableListData.ts +++ b/src/components/Table/buildTableListData.ts @@ -31,10 +31,10 @@ type ColumnHeaderPlacementParams = { isColumnHeaderHiddenInNarrowLayout: boolean; /** - * Whether the columns sit in a horizontal scroller because they overflow or are resizable. FlashList's sticky-row overlay - * can't follow them sideways, so the column header moves into the list header instead (see `TableBody`). + * Whether the columns are wider than the table and so have to scroll. FlashList's sticky-row overlay can't follow + * them, so the column header moves into the list header instead (see `TableBody`). */ - hasHorizontalScrollContainer: boolean; + areColumnsScrollable: boolean; }; type TableListMetadataParams = Omit & { @@ -47,7 +47,7 @@ function getColumnHeaderPlacement({ hasPageHeader, hasRows, isColumnHeaderHiddenInNarrowLayout, - hasHorizontalScrollContainer, + areColumnsScrollable, }: ColumnHeaderPlacementParams): ColumnHeaderPlacement { if (!hasColumnHeaderElement) { return CONST.TABLES.COLUMN_HEADER_PLACEMENT.NONE; @@ -63,7 +63,7 @@ function getColumnHeaderPlacement({ return CONST.TABLES.COLUMN_HEADER_PLACEMENT.OUTSIDE_LIST; } - return hasHorizontalScrollContainer ? CONST.TABLES.COLUMN_HEADER_PLACEMENT.LIST_HEADER : CONST.TABLES.COLUMN_HEADER_PLACEMENT.STICKY_ROW; + return areColumnsScrollable ? CONST.TABLES.COLUMN_HEADER_PLACEMENT.LIST_HEADER : CONST.TABLES.COLUMN_HEADER_PLACEMENT.STICKY_ROW; } /** Whether the table has a column header at all, wherever it ends up. Drives `aria-rowindex`/`aria-rowcount`. */ diff --git a/src/components/Table/calculateDynamicColumnWidths.ts b/src/components/Table/calculateDynamicColumnWidths.ts index 1a80327f3a88..a8b4ce119e51 100644 --- a/src/components/Table/calculateDynamicColumnWidths.ts +++ b/src/components/Table/calculateDynamicColumnWidths.ts @@ -118,20 +118,6 @@ function distributeAvailableWidth(desiredWidths: number[], maxWidths: number[], return widths; } -/** Equal whole-px widths summing exactly to `availableWidth`. The remainder goes to the first column. */ -function distributeEqualWidths(columnCount: number, availableWidth: number): number[] { - if (columnCount <= 0) { - return []; - } - - const equalWidth = Math.floor(availableWidth / columnCount); - const widths = Array.from({length: columnCount}, () => equalWidth); - - widths[0] += availableWidth - equalWidth * columnCount; - - return widths; -} - /** * Resolves the widths of a table's dynamically sized columns from what their content needs and how much room the table * has, implementing the three behaviors from https://github.com/Expensify/App/issues/96510: @@ -226,5 +212,4 @@ function calculateDynamicColumnWidths(constraints: DynamicColumnConstraints[], a } export default calculateDynamicColumnWidths; -export {distributeEqualWidths}; export type {DynamicColumnConstraints}; diff --git a/src/components/Table/columnResize/ColumnResizeHandle/index.native.tsx b/src/components/Table/columnResize/ColumnResizeHandle/index.native.tsx deleted file mode 100644 index fc32fccde041..000000000000 --- a/src/components/Table/columnResize/ColumnResizeHandle/index.native.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type {ColumnResizeHandleProps} from '@components/Table/columnResize/types'; - -/** No-op on native. Keeps the DOM-only implementation out of the native bundle. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -- the props exist only to match the web signature -function ColumnResizeHandle(props: ColumnResizeHandleProps) { - return null; -} - -export default ColumnResizeHandle; diff --git a/src/components/Table/columnResize/ColumnResizeHandle/index.tsx b/src/components/Table/columnResize/ColumnResizeHandle/index.tsx deleted file mode 100644 index d127af019355..000000000000 --- a/src/components/Table/columnResize/ColumnResizeHandle/index.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import {RESIZE_INDICATOR_HEIGHT_VARIABLE, RESIZE_INDICATOR_OPACITY_VARIABLE, RESIZE_INDICATOR_TOP_VARIABLE} from '@components/Table/columnResize/columnWidthExpressions'; -import type {ColumnResizeHandleProps} from '@components/Table/columnResize/types'; -import {useTableContext} from '@components/Table/TableContext'; - -import useThemeStyles from '@hooks/useThemeStyles'; - -import CONST from '@src/CONST'; - -import React from 'react'; - -// A DOM `div` style, which the React Native style system can't type because of the custom property expressions. -const INDICATOR_STYLE: React.CSSProperties = { - position: 'absolute', - // Spans heading row to lowest row, which the handle's box doesn't match, so both are measured. - top: `var(${RESIZE_INDICATOR_TOP_VARIABLE}, 0px)`, - height: `var(${RESIZE_INDICATOR_HEIGHT_VARIABLE}, 100%)`, - left: '50%', - marginLeft: -CONST.TABLES.COLUMN_RESIZE.INDICATOR_WIDTH / 2, - width: CONST.TABLES.COLUMN_RESIZE.INDICATOR_WIDTH, - opacity: `var(${RESIZE_INDICATOR_OPACITY_VARIABLE}, 0)`, - pointerEvents: 'none', -}; - -/** - * Drag the strip over a column's right edge. Carries the indicator line, so it moves with the column through drags and scrolls. - * A plain `div` because it relies on DOM pointer capture. - */ -function ColumnResizeHandle({columnKey}: ColumnResizeHandleProps) { - const styles = useThemeStyles(); - const {columnResize} = useTableContext(); - const handleProps = columnResize?.getHandleProps(columnKey); - - if (!handleProps) { - return null; - } - - return ( -
-
-
- ); -} - -export default ColumnResizeHandle; diff --git a/src/components/Table/columnResize/ColumnResizeScope/index.native.tsx b/src/components/Table/columnResize/ColumnResizeScope/index.native.tsx deleted file mode 100644 index 7ac59df76547..000000000000 --- a/src/components/Table/columnResize/ColumnResizeScope/index.native.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import type {ColumnResizeScopeProps} from '@components/Table/columnResize/types'; - -/** No-op on native. */ -function ColumnResizeScope({children}: ColumnResizeScopeProps) { - return children; -} - -export default ColumnResizeScope; diff --git a/src/components/Table/columnResize/ColumnResizeScope/index.tsx b/src/components/Table/columnResize/ColumnResizeScope/index.tsx deleted file mode 100644 index 15bc844d4719..000000000000 --- a/src/components/Table/columnResize/ColumnResizeScope/index.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type {ColumnResizeScopeProps} from '@components/Table/columnResize/types'; - -import useThemeStyles from '@hooks/useThemeStyles'; - -import React from 'react'; - -/** Element the column widths are written on, so the header and rows below it inherit them. */ -function ColumnResizeScope({onScopeElement, children}: ColumnResizeScopeProps) { - const styles = useThemeStyles(); - - if (!onScopeElement) { - return children; - } - - return ( -
- {children} -
- ); -} - -export default ColumnResizeScope; diff --git a/src/components/Table/columnResize/applyColumnWidthOverrides.ts b/src/components/Table/columnResize/applyColumnWidthOverrides.ts deleted file mode 100644 index 8760523dd51d..000000000000 --- a/src/components/Table/columnResize/applyColumnWidthOverrides.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** Merges stored drag widths into a table's resolved column widths, and decides which column edges can be dragged. */ -import type {ColumnWidthOverrides} from '@src/types/onyx/TableColumnWidths'; - -import {getColumnWidthValue} from './columnWidthExpressions'; - -/** What resizing needs to know about a column, whatever lays the columns out. */ -type ColumnWidthOverrideColumn = { - /** Key a stored width is filed under. */ - key: string; - - /** Headless columns hold fixed-size content like an icon, checkbox or arrow, so they get no edge. */ - label: string; - - /** Columns with a declared width aren't resizable. */ - hasDeclaredWidth: boolean; -}; - -type ApplyColumnWidthOverridesParams = { - /** Every column, in render order. */ - columns: ColumnWidthOverrideColumn[]; - - /** Widths before stored overrides are applied. */ - baseColumnWidths: Record; - - columnWidthOverrides: ColumnWidthOverrides | undefined; -}; - -type AppliedColumnWidthOverrides = { - /** Widths after stored overrides, which drags start from. */ - columnWidths: Record; - - /** Each column's custom property CSS value with `columnWidths` as the fallback, in render order. */ - columnWidthValues: string[]; - - /** Keys of the columns whose right edge the user can drag, in render order. */ - resizableColumnKeys: string[]; -}; - -/** Whole px, deliberately not clamped: stored widths may legitimately fall outside drag bounds. */ -function getStoredColumnWidth(width: number): number { - return Math.max(Math.round(width), 0); -} - -/** Layout-agnostic, so it serves grid tracks and flex basis alike. */ -function applyColumnWidthOverrides({columns, baseColumnWidths, columnWidthOverrides}: ApplyColumnWidthOverridesParams): AppliedColumnWidthOverrides { - const columnWidths = {...baseColumnWidths}; - const resizableColumnKeys: string[] = []; - - for (const column of columns) { - // Only headed, content-sized columns drag. Columns with a declared width, like a switch, status or count, hold fixed-size content. - if (!column.label || column.hasDeclaredWidth) { - continue; - } - - const overriddenWidth = columnWidthOverrides?.[column.key]; - - if (overriddenWidth !== undefined) { - columnWidths[column.key] = getStoredColumnWidth(overriddenWidth); - } - - resizableColumnKeys.push(column.key); - } - - const columnWidthValues = columns.map((column) => getColumnWidthValue(column.key, columnWidths[column.key] ?? 0)); - - return {columnWidths, columnWidthValues, resizableColumnKeys}; -} - -export default applyColumnWidthOverrides; -export type {ColumnWidthOverrideColumn}; diff --git a/src/components/Table/columnResize/columnWidthExpressions.ts b/src/components/Table/columnResize/columnWidthExpressions.ts deleted file mode 100644 index e5374af6acbd..000000000000 --- a/src/components/Table/columnResize/columnWidthExpressions.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** CSS custom properties resizable tables read widths from, so a drag repaints without a React render. */ - -/** Prefix of the custom property a resizable column reads its width from. */ -const COLUMN_WIDTH_VARIABLE_PREFIX = '--table-column-width-'; - -/** Top of a handle's line: the heading row's top, relative to the handle. */ -const RESIZE_INDICATOR_TOP_VARIABLE = '--table-resize-indicator-top'; - -/** Height of a handle's line: heading row's top to the lowest drawn row's bottom. */ -const RESIZE_INDICATOR_HEIGHT_VARIABLE = '--table-resize-indicator-height'; - -/** Opacity of a handle's line, set on that handle so hovering never re-renders the table. */ -const RESIZE_INDICATOR_OPACITY_VARIABLE = '--table-resize-indicator-opacity'; - -/** Marks the header row and the data rows, so the line can find where the table's rows start and end. */ -const TABLE_ROW_DATA_SET = {tableRow: true}; - -const TABLE_ROW_SELECTOR = '[data-table-row]'; - -/** Column keys may hold characters a CSS custom property name can't, so those are replaced. */ -function getColumnWidthVariableName(columnKey: string): string { - return `${COLUMN_WIDTH_VARIABLE_PREFIX}${columnKey.replaceAll(/[^\w-]/g, '_')}`; -} - -/** A column's width custom property, falling back to its resolved width. */ -function getColumnWidthValue(columnKey: string, resolvedWidth: number): string { - return `var(${getColumnWidthVariableName(columnKey)}, ${resolvedWidth}px)`; -} - -/** Track that grows into the row's leftover width, keeping trailing headless columns pinned right. */ -function getGrowableColumnTrack(widthValue: string): string { - return `minmax(${widthValue}, 1fr)`; -} - -/** CSS sum of the columns plus chrome, floored at `floor` so the table stays full-width and overflow scrolls. */ -function getColumnsWidthExpression(columnWidthValues: string[], chromeWidth: number, floor: string): string { - if (columnWidthValues.length === 0) { - return `max(${floor}, ${chromeWidth}px)`; - } - - return `max(${floor}, calc(${columnWidthValues.join(' + ')} + ${chromeWidth}px))`; -} - -export { - RESIZE_INDICATOR_HEIGHT_VARIABLE, - RESIZE_INDICATOR_OPACITY_VARIABLE, - RESIZE_INDICATOR_TOP_VARIABLE, - TABLE_ROW_DATA_SET, - TABLE_ROW_SELECTOR, - getColumnWidthValue, - getColumnWidthVariableName, - getColumnsWidthExpression, - getGrowableColumnTrack, -}; diff --git a/src/components/Table/columnResize/getDraggedColumnWidth.ts b/src/components/Table/columnResize/getDraggedColumnWidth.ts deleted file mode 100644 index 4128f1a66be7..000000000000 --- a/src/components/Table/columnResize/getDraggedColumnWidth.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** Turns pointer travel into a column width, kept above the drag floor. */ -import CONST from '@src/CONST'; - -/** The width a drag puts the column at: its start width plus the pointer's travel, no narrower than the floor. */ -function getDraggedColumnWidth(startWidth: number, startClientX: number, clientX: number, minWidth: number = CONST.TABLES.COLUMN_RESIZE.MIN_WIDTH): number { - return Math.max(Math.round(startWidth + (clientX - startClientX)), minWidth); -} - -export default getDraggedColumnWidth; diff --git a/src/components/Table/columnResize/getMeasureWidthRef/index.native.ts b/src/components/Table/columnResize/getMeasureWidthRef/index.native.ts deleted file mode 100644 index e5be93594611..000000000000 --- a/src/components/Table/columnResize/getMeasureWidthRef/index.native.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** Native columns are never measured, so there is nothing to read ahead of paint. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -- the parameter exists only to match the web signature -function getMeasureWidthRef(onWidth: (width: number) => void): ((node: unknown) => void) | undefined { - return undefined; -} - -export default getMeasureWidthRef; diff --git a/src/components/Table/columnResize/getMeasureWidthRef/index.ts b/src/components/Table/columnResize/getMeasureWidthRef/index.ts deleted file mode 100644 index 4f260c3e4f42..000000000000 --- a/src/components/Table/columnResize/getMeasureWidthRef/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Reads a node's width as it mounts. Set in the commit, the width reaches the first paint, while `onLayout` only reports - * it a frame later, after the columns already painted at their static widths. - */ -function getMeasureWidthRef(onWidth: (width: number) => void): ((node: unknown) => void) | undefined { - return (node) => { - if (!(node instanceof HTMLElement)) { - return; - } - - // `offsetWidth`, like react-native-web's `onLayout`, so the layout event that follows matches and doesn't re-render. - onWidth(node.offsetWidth); - }; -} - -export default getMeasureWidthRef; diff --git a/src/components/Table/columnResize/getResizableColumnLayout.ts b/src/components/Table/columnResize/getResizableColumnLayout.ts deleted file mode 100644 index 3822c9980c4b..000000000000 --- a/src/components/Table/columnResize/getResizableColumnLayout.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** Lays a resizable table's columns out as custom property expressions, so a drag repaints the header, rows and scroller without a render. */ -import type {TableColumn, TableData} from '@components/Table/types'; - -import CONST from '@src/CONST'; -import type {ColumnWidthOverrides} from '@src/types/onyx/TableColumnWidths'; - -import applyColumnWidthOverrides from './applyColumnWidthOverrides'; -import {getColumnsWidthExpression, getGrowableColumnTrack} from './columnWidthExpressions'; - -/** Row space that isn't column width, so it can be added back around the column sum. */ -type RowChromeWidths = { - /** Leading checkbox column, `0` when selection is off. */ - selectionColumnWidth: number; - - /** Every gap between columns, the selection column included. */ - totalGapWidth: number; - - /** Outer margin on both sides, which the rows keep inside the scrolled content. */ - rowMarginWidth: number; - - /** Inner padding on both sides. */ - rowPaddingWidth: number; -}; - -type GetResizableColumnLayoutParams = { - /** Every column, in render order. */ - columns: Array>; - - /** Widths before stored overrides are applied, fixed columns included. */ - resolvedColumnWidths: Record; - - columnWidthOverrides: ColumnWidthOverrides | undefined; - - /** Measured width of the area the table renders into. */ - tableWidth: number; - - rowChromeWidths: RowChromeWidths; -}; - -type ResizableColumnLayout = { - /** Grid tracks reading each column's width custom property. */ - gridTemplateColumns: string[]; - - /** Scroller content width, floored at the table so it stays full-width. */ - scrollWidth: string; - - /** Row box width, which is `scrollWidth` minus the outer margin. */ - rowWidth: string; - - /** Keys of the columns whose right edge the user can drag, in render order. */ - resizableColumnKeys: string[]; - - /** Width drags start from: stored overrides applied, and the growable column at its painted width. */ - resolvedColumnWidths: Record; - - /** The narrowest width a drag may take a column to, for columns tighter than the default drag bound. */ - dragMinWidths: Record; -}; - -function getColumnsWidthSum(columns: Array>, columnWidths: Record): number { - return columns.reduce((total, column) => total + (columnWidths[column.key] ?? 0), 0); -} - -function getResizableColumnLayout({ - columns, - resolvedColumnWidths, - columnWidthOverrides, - tableWidth, - rowChromeWidths: {selectionColumnWidth, totalGapWidth, rowMarginWidth, rowPaddingWidth}, -}: GetResizableColumnLayoutParams): ResizableColumnLayout { - // Absorbs leftover width, so trailing headless columns like an arrow, menu or icon keep their size and stay pinned right. - const growableColumnKey = columns.findLast((column) => !!column.label)?.key; - - const {columnWidths, columnWidthValues, resizableColumnKeys} = applyColumnWidthOverrides({ - columns: columns.map((column) => ({ - key: column.key, - label: column.label, - hasDeclaredWidth: typeof column.width === 'number', - })), - baseColumnWidths: resolvedColumnWidths, - columnWidthOverrides, - }); - - // Row width sums the widths, not the tracks, so the growable track only grows into real leftover room. - const gridTemplateColumns = columnWidthValues.map((widthValue, index) => (columns.at(index)?.key === growableColumnKey ? getGrowableColumnTrack(widthValue) : widthValue)); - - const rowWidthValues = selectionColumnWidth > 0 ? [`${selectionColumnWidth}px`, ...columnWidthValues] : columnWidthValues; - - const rowContentWidth = getColumnsWidthSum(columns, columnWidths) + selectionColumnWidth + totalGapWidth + rowPaddingWidth; - const overflowWidth = rowContentWidth - (tableWidth - rowMarginWidth); - const dragStartWidths = {...columnWidths}; - const dragMinWidths: Record = {}; - - if (growableColumnKey && resizableColumnKeys.includes(growableColumnKey)) { - // Drags start from the painted track, which includes the leftover it grew into, so the first pixels of travel aren't dead. - const paintedWidth = (columnWidths[growableColumnKey] ?? 0) + Math.max(-overflowWidth, 0); - dragStartWidths[growableColumnKey] = paintedWidth; - - // Shrinking may only take back the overflow. Anything more would stretch the trailing headless columns or leave - // empty room at the row's end. Rounded up so the row never ends a fraction of a pixel short of the table. - const minWidth = Math.max(Math.ceil(paintedWidth - Math.max(overflowWidth, 0)), CONST.TABLES.COLUMN_RESIZE.MIN_WIDTH); - - // A width already below the drag bound stays put rather than jumping wider. - dragMinWidths[growableColumnKey] = Math.min(minWidth, paintedWidth); - } - - // Scroll at the live column sum, so a drag that widens a column past the table's edge starts scrolling mid-drag. - return { - gridTemplateColumns, - scrollWidth: getColumnsWidthExpression(rowWidthValues, totalGapWidth + rowMarginWidth + rowPaddingWidth, '100%'), - // Without the outer margin. Floored at px because `100%` resolves against the list cell, which includes the margin. - rowWidth: getColumnsWidthExpression(rowWidthValues, totalGapWidth + rowPaddingWidth, `${tableWidth - rowMarginWidth}px`), - resizableColumnKeys, - resolvedColumnWidths: dragStartWidths, - dragMinWidths, - }; -} - -export default getResizableColumnLayout; diff --git a/src/components/Table/columnResize/types.ts b/src/components/Table/columnResize/types.ts deleted file mode 100644 index 65b17946b4cf..000000000000 --- a/src/components/Table/columnResize/types.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type ChildrenProps from '@src/types/utils/ChildrenProps'; - -type ColumnResizeHandleProps = { - /** The column this handle resizes. Its edge is the right edge of the cell the handle renders in. */ - columnKey: string; -}; - -type ColumnResizeScopeProps = ChildrenProps & { - /** Receives the element the columns' width custom properties are written on. Omitted when the table isn't resizable. */ - onScopeElement?: (element: HTMLElement | null) => void; -}; - -export type {ColumnResizeHandleProps, ColumnResizeScopeProps}; diff --git a/src/components/Table/columnResize/useColumnResize/index.native.ts b/src/components/Table/columnResize/useColumnResize/index.native.ts deleted file mode 100644 index 4d1f7d7f345b..000000000000 --- a/src/components/Table/columnResize/useColumnResize/index.native.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type {ColumnResizeController, UseColumnResizeParams} from './types'; - -/** Column resizing is web-only. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -- the parameter exists only to match the web signature -function useColumnResize(params: UseColumnResizeParams): ColumnResizeController | undefined { - return undefined; -} - -export default useColumnResize; diff --git a/src/components/Table/columnResize/useColumnResize/index.ts b/src/components/Table/columnResize/useColumnResize/index.ts deleted file mode 100644 index 12d6048dc236..000000000000 --- a/src/components/Table/columnResize/useColumnResize/index.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Web column resizing: dragging a column's right edge sets its width. Widths live in CSS custom properties so React - * doesn't render mid-drag. Only the dragged column's final width is stored in Onyx. - */ -import getDraggedColumnWidth from '@components/Table/columnResize/getDraggedColumnWidth'; - -import useThemeStyles from '@hooks/useThemeStyles'; - -import {setTableColumnWidth} from '@libs/actions/TableColumnWidths'; - -import CONST from '@src/CONST'; - -import type React from 'react'; - -import {useEffect, useRef} from 'react'; - -import type {ColumnResizeController, ColumnResizeHandleDOMProps, UseColumnResizeParams} from './types'; - -import useLiveColumnWidths from './useLiveColumnWidths'; -import useResizeIndicator from './useResizeIndicator'; - -/** A DOM `div` style, which the React Native style system can't type */ -function getHandleStyle(columnGap: number): React.CSSProperties { - return { - position: 'absolute', - top: 0, - bottom: 0, - right: -(columnGap / 2 + CONST.TABLES.COLUMN_RESIZE.HANDLE_HIT_WIDTH / 2), - width: CONST.TABLES.COLUMN_RESIZE.HANDLE_HIT_WIDTH, - // Otherwise a touch drag on the handle is taken over by the table's own horizontal scrolling. - touchAction: 'none', - }; -} - -type Drag = { - columnKey: string; - - /** Where the pointer went down. */ - startClientX: number; - - /** The column's width when the drag started. */ - startWidth: number; -}; - -function useColumnResize({columnResizingID, resizableColumnKeys, resolvedColumnWidths, dragMinWidths, columnGap}: UseColumnResizeParams): ColumnResizeController | undefined { - const styles = useThemeStyles(); - const dragRef = useRef(null); - const {scopeElementRef, setScopeElement, writeColumnWidth, readColumnWidth, clearLiveWidths} = useLiveColumnWidths({resolvedColumnWidths, dragRef}); - const {revealIndicator, hideIndicator} = useResizeIndicator(scopeElementRef); - - const resetDrag = () => { - dragRef.current = null; - document.body.style.cursor = ''; - }; - - // Shared by pointerup, lost capture and cancel, so every way a drag can end keeps the width the user sees. - const endDrag = (drag: Drag) => { - resetDrag(); - hideIndicator(); - - const width = readColumnWidth(drag.columnKey) ?? drag.startWidth; - - // Nothing gets stored, so no render follows to clear the live widths. - if (!columnResizingID || width === drag.startWidth) { - clearLiveWidths(); - return; - } - - setTableColumnWidth(columnResizingID, drag.columnKey, width); - }; - - const handlePointerDown = (columnKey: string, event: React.PointerEvent) => { - // Secondary buttons open context menus rather than dragging. - if (event.button !== 0) { - return; - } - - // Keeps the drag from selecting the header's labels, and from reaching the column's sort button underneath. - event.preventDefault(); - event.stopPropagation(); - - // Keeps the drag on this handle once the pointer moves off it. - event.currentTarget.setPointerCapture(event.pointerId); - - revealIndicator(event.currentTarget); - - dragRef.current = { - columnKey, - startClientX: event.clientX, - startWidth: readColumnWidth(columnKey) ?? 0, - }; - // On the whole page mid-drag, so the cursor doesn't flicker once the pointer outruns the handle. - document.body.style.cursor = styles.cursorColResize.cursor ?? ''; - }; - - const handlePointerMove = (event: React.PointerEvent) => { - const drag = dragRef.current; - - if (!drag) { - return; - } - - // The line rides the handle, so it follows the clamped width, not the pointer. - writeColumnWidth(drag.columnKey, getDraggedColumnWidth(drag.startWidth, drag.startClientX, event.clientX, dragMinWidths?.[drag.columnKey])); - }; - - const handlePointerUp = (event: React.PointerEvent) => { - const drag = dragRef.current; - - if (!drag) { - return; - } - - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - - endDrag(drag); - }; - - /** Ends a drag whose pointer capture the browser reclaimed. Also fires after a normal pointerup, when it's a no-op. */ - const handleLostPointerCapture = () => { - const drag = dragRef.current; - - if (!drag) { - return; - } - - endDrag(drag); - }; - - // Unmounting mid-drag would otherwise leave the resize cursor on the document and a dangling drag. - useEffect( - () => () => { - if (!dragRef.current) { - return; - } - - resetDrag(); - }, - [], - ); - - if (!columnResizingID) { - return undefined; - } - - const getHandleProps = (columnKey: string): ColumnResizeHandleDOMProps | undefined => { - if (!resizableColumnKeys.includes(columnKey)) { - return undefined; - } - - return { - style: {...getHandleStyle(columnGap), ...styles.cursorColResize}, - onPointerDown: (event) => handlePointerDown(columnKey, event), - onPointerMove: handlePointerMove, - onPointerUp: handlePointerUp, - onPointerCancel: handleLostPointerCapture, - onLostPointerCapture: handleLostPointerCapture, - }; - }; - - return {setScopeElement, getHandleProps}; -} - -export default useColumnResize; diff --git a/src/components/Table/columnResize/useColumnResize/types.ts b/src/components/Table/columnResize/useColumnResize/types.ts deleted file mode 100644 index 59dcab67581a..000000000000 --- a/src/components/Table/columnResize/useColumnResize/types.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type React from 'react'; - -type UseColumnResizeParams = { - /** Key the column widths persist under. `undefined` disables resizing on native, in narrow layouts and for tables that didn't opt in. */ - columnResizingID: string | undefined; - - /** Keys of the columns whose right edge the user can drag. */ - resizableColumnKeys: string[]; - - /** Each column's resolved width, which a drag starts from. A new object means React rendered new widths. */ - resolvedColumnWidths: Record; - - /** The narrowest width a drag may take a column to, for columns tighter than the default drag bound. */ - dragMinWidths?: Record; - - /** Gap between columns, so handles can be centred in it. */ - columnGap: number; -}; - -type ColumnResizeHandleDOMProps = React.HTMLAttributes; - -type ColumnResizeController = { - /** Element holding the width custom properties. The header and rows inherit them, so one write repaints all. */ - setScopeElement: (element: HTMLElement | null) => void; - - /** Position and handlers for a column's handle, or `undefined` when its edge doesn't drag. */ - getHandleProps: (columnKey: string) => ColumnResizeHandleDOMProps | undefined; -}; - -export type {ColumnResizeController, ColumnResizeHandleDOMProps, UseColumnResizeParams}; diff --git a/src/components/Table/columnResize/useColumnResize/useLiveColumnWidths.ts b/src/components/Table/columnResize/useColumnResize/useLiveColumnWidths.ts deleted file mode 100644 index 74242b3233ec..000000000000 --- a/src/components/Table/columnResize/useColumnResize/useLiveColumnWidths.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** Widths painted mid-drag straight onto the DOM, ahead of React, and handed back once React renders them. */ -import {getColumnWidthVariableName} from '@components/Table/columnResize/columnWidthExpressions'; - -import type {RefObject} from 'react'; - -import {useLayoutEffect, useRef} from 'react'; - -type UseLiveColumnWidthsParams = { - /** What a column paints while it has no live width. A new object means React rendered new widths, so live widths are cleared. */ - resolvedColumnWidths: Record; - - /** Active drag. Live widths survive it so a width stored elsewhere can't yank the column. */ - dragRef: RefObject; -}; - -type LiveColumnWidths = { - /** Element holding the width custom properties. */ - scopeElementRef: RefObject; - - setScopeElement: (element: HTMLElement | null) => void; - - writeColumnWidth: (columnKey: string, width: number) => void; - - /** Currently painted width, ahead of React mid-gesture. `undefined` rather than zero, so a column never collapses. */ - readColumnWidth: (columnKey: string) => number | undefined; - - /** Hands every column back to React's fallback. */ - clearLiveWidths: () => void; -}; - -/** Widths written mid-gesture as CSS custom properties. None at rest, so they never need syncing with resolved widths. */ -function useLiveColumnWidths({resolvedColumnWidths, dragRef}: UseLiveColumnWidthsParams): LiveColumnWidths { - const scopeElementRef = useRef(null); - const liveWidthsRef = useRef>({}); - - const setScopeElement = (element: HTMLElement | null) => { - scopeElementRef.current = element; - - // The properties live on the element, so a new one starts out with none of them. - liveWidthsRef.current = {}; - }; - - const writeColumnWidth = (columnKey: string, width: number) => { - scopeElementRef.current?.style.setProperty(getColumnWidthVariableName(columnKey), `${width}px`); - liveWidthsRef.current[columnKey] = width; - }; - - const readColumnWidth = (columnKey: string): number | undefined => liveWidthsRef.current[columnKey] ?? resolvedColumnWidths[columnKey]; - - const clearLiveWidths = () => { - for (const columnKey of Object.keys(liveWidthsRef.current)) { - scopeElementRef.current?.style.removeProperty(getColumnWidthVariableName(columnKey)); - } - - liveWidthsRef.current = {}; - }; - - // React now renders the committed width as the fallback, so drop live widths before paint. - useLayoutEffect(() => { - // The drag's own commit clears later. - if (dragRef.current) { - return; - } - - clearLiveWidths(); - }, [resolvedColumnWidths, dragRef]); - - return {scopeElementRef, setScopeElement, writeColumnWidth, readColumnWidth, clearLiveWidths}; -} - -export default useLiveColumnWidths; diff --git a/src/components/Table/columnResize/useColumnResize/useResizeIndicator.ts b/src/components/Table/columnResize/useColumnResize/useResizeIndicator.ts deleted file mode 100644 index c8446a1041d3..000000000000 --- a/src/components/Table/columnResize/useColumnResize/useResizeIndicator.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** The line marking a dragged column edge, toggled through custom properties so a drag never re-renders the table. */ -import {RESIZE_INDICATOR_HEIGHT_VARIABLE, RESIZE_INDICATOR_OPACITY_VARIABLE, RESIZE_INDICATOR_TOP_VARIABLE, TABLE_ROW_SELECTOR} from '@components/Table/columnResize/columnWidthExpressions'; - -import type {RefObject} from 'react'; - -import {useRef} from 'react'; - -const INDICATOR_OPACITY = { - VISIBLE: '1', - HIDDEN: '0', -} as const; - -type ResizeIndicator = { - revealIndicator: (handleElement: HTMLElement) => void; - - hideIndicator: () => void; -}; - -/** Measured once per drag. The line lives in the handle, so drags and scrolls carry it. */ -function drawIndicatorAtHandle(scopeElement: HTMLElement | null, handleElement: HTMLElement) { - const handleRect = handleElement.getBoundingClientRect(); - const headerRowTop = (handleElement.closest(TABLE_ROW_SELECTOR) ?? handleElement).getBoundingClientRect().top; - let lowestRowBottom = handleRect.bottom; - - for (const row of scopeElement?.querySelectorAll(TABLE_ROW_SELECTOR) ?? []) { - // Skip the list's hidden twins, but not rows hidden by an outer modal's aria-hidden. - const hiddenAncestor = row.closest('[aria-hidden="true"]'); - - if (hiddenAncestor && scopeElement?.contains(hiddenAncestor)) { - continue; - } - - lowestRowBottom = Math.max(lowestRowBottom, row.getBoundingClientRect().bottom); - } - - handleElement.style.setProperty(RESIZE_INDICATOR_TOP_VARIABLE, `${headerRowTop - handleRect.top}px`); - handleElement.style.setProperty(RESIZE_INDICATOR_HEIGHT_VARIABLE, `${lowestRowBottom - headerRowTop}px`); - handleElement.style.setProperty(RESIZE_INDICATOR_OPACITY_VARIABLE, INDICATOR_OPACITY.VISIBLE); -} - -function useResizeIndicator(scopeElementRef: RefObject): ResizeIndicator { - // Lost pointer capture hands over no element, so the dragged handle is remembered to hide its line. - const activeHandleElementRef = useRef(null); - - const revealIndicator = (handleElement: HTMLElement) => { - activeHandleElementRef.current = handleElement; - drawIndicatorAtHandle(scopeElementRef.current, handleElement); - }; - - const hideIndicator = () => { - activeHandleElementRef.current?.style.setProperty(RESIZE_INDICATOR_OPACITY_VARIABLE, INDICATOR_OPACITY.HIDDEN); - activeHandleElementRef.current = null; - }; - - return {revealIndicator, hideIndicator}; -} - -export default useResizeIndicator; diff --git a/src/components/Table/columnResize/useStoredColumnWidths/index.native.ts b/src/components/Table/columnResize/useStoredColumnWidths/index.native.ts deleted file mode 100644 index a8b30cf1ee6d..000000000000 --- a/src/components/Table/columnResize/useStoredColumnWidths/index.native.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type {ColumnWidthOverrides} from '@src/types/onyx/TableColumnWidths'; - -/** Column resizing is web-only, so native never subscribes to stored widths. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -- the parameter exists only to match the web signature -function useStoredColumnWidths(columnResizingID: string): ColumnWidthOverrides | undefined { - return undefined; -} - -export default useStoredColumnWidths; diff --git a/src/components/Table/columnResize/useStoredColumnWidths/index.ts b/src/components/Table/columnResize/useStoredColumnWidths/index.ts deleted file mode 100644 index 878a9f95a9b7..000000000000 --- a/src/components/Table/columnResize/useStoredColumnWidths/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import useOnyx from '@hooks/useOnyx'; - -import ONYXKEYS from '@src/ONYXKEYS'; -import {tableColumnWidthsSelector} from '@src/selectors/TableColumnWidths'; -import type {ColumnWidthOverrides} from '@src/types/onyx/TableColumnWidths'; - -/** Widths the user dragged in tables sharing this `columnResizingID`. */ -function useStoredColumnWidths(columnResizingID: string): ColumnWidthOverrides | undefined { - const [columnWidthOverrides] = useOnyx(ONYXKEYS.TABLE_COLUMN_WIDTHS, {selector: tableColumnWidthsSelector(columnResizingID)}); - return columnWidthOverrides; -} - -export default useStoredColumnWidths; diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index 89afc020c4f2..32b8f6f054b3 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -223,12 +223,6 @@ type TableProps; diff --git a/src/components/Table/useDynamicColumnWidths.ts b/src/components/Table/useDynamicColumnWidths.ts index 561480acdc5c..57bfe5bd622e 100644 --- a/src/components/Table/useDynamicColumnWidths.ts +++ b/src/components/Table/useDynamicColumnWidths.ts @@ -8,15 +8,13 @@ import {fontScale} from '@styles/typography'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -import type {ColumnWidthOverrides} from '@src/types/onyx/TableColumnWidths'; import type {StyleProp, ViewStyle} from 'react-native'; import type {DynamicColumnConstraints} from './calculateDynamicColumnWidths'; import type {TableColumn, TableData} from './types'; -import calculateDynamicColumnWidths, {distributeEqualWidths} from './calculateDynamicColumnWidths'; -import getResizableColumnLayout from './columnResize/getResizableColumnLayout'; +import calculateDynamicColumnWidths from './calculateDynamicColumnWidths'; const {MIN_FREE_TEXT_COLUMN_WIDTH} = CONST.TABLES.DYNAMIC_COLUMNS; @@ -38,32 +36,6 @@ type UseDynamicColumnWidthsParams; - - /** Narrowest width a drag may take a column to, for columns tighter than the default drag bound. */ - dragMinWidths: Record; }; /** @@ -150,20 +122,11 @@ function useDynamicColumnWidths): UseDynamicColumnWidthsResult { +}: UseDynamicColumnWidthsParams): {gridTemplateColumns: string[] | undefined; scrollWidth: number | undefined} { const styles = useThemeStyles(); const {values} = useLayoutSpacing(); - const noDynamicWidths: UseDynamicColumnWidthsResult = { - gridTemplateColumns: undefined, - scrollWidth: undefined, - rowWidth: undefined, - resizableColumnKeys: [], - resolvedColumnWidths: {}, - dragMinWidths: {}, - }; + const noDynamicWidths = {gridTemplateColumns: undefined, scrollWidth: undefined}; // Checked before anything else, so native never walks the data to gather text that it can't measure anyway. if (!isEnabled || tableWidth <= 0 || !canMeasureText()) { @@ -171,7 +134,6 @@ function useDynamicColumnWidths> = []; - const fixedColumnWidths = new Map(); let fixedColumnsWidth = 0; for (const column of columns) { @@ -182,7 +144,6 @@ function useDynamicColumnWidths 0 ? widths : distributeEqualWidths(dynamicColumns.length, availableWidth); - // Keyed by column rather than tracked with a running index, so the tracks can be built without mutating a counter // from inside the mapping callback (which the React Compiler can't compile). - const resolvedColumnWidths: Record = {}; + const widthByColumnKey = new Map(); for (const [index, column] of dynamicColumns.entries()) { - resolvedColumnWidths[column.key] = resolvedWidths.at(index) ?? 0; - } - - for (const [columnKey, fixedWidth] of fixedColumnWidths) { - resolvedColumnWidths[columnKey] = fixedWidth; + widthByColumnKey.set(column.key, widths.at(index) ?? 0); } - if (!isColumnResizingEnabled) { - const gridTemplateColumns = columns.map((column) => `${resolvedColumnWidths[column.key] ?? 0}px`); + const gridTemplateColumns = columns.map((column) => (typeof column.width === 'number' ? `${column.width}px` : `${widthByColumnKey.get(column.key) ?? 0}px`)); - if (!shouldScrollHorizontally) { - return {...noDynamicWidths, gridTemplateColumns, resolvedColumnWidths}; - } - - // Rows overflow, so scroll at exactly their width. Margin is included because rows keep it inside the scrolled - // content, and without it their trailing edge gets clipped. - const scrollWidth = resolvedWidths.reduce((total, width) => total + width, 0) + fixedColumnsWidth + selectionColumnWidth + totalGapWidth + rowChromeWidth; - - return {...noDynamicWidths, gridTemplateColumns, scrollWidth, resolvedColumnWidths}; + if (!shouldScrollHorizontally) { + return {gridTemplateColumns, scrollWidth: undefined}; } - return getResizableColumnLayout({ - columns, - resolvedColumnWidths, - columnWidthOverrides, - tableWidth, - rowChromeWidths: {selectionColumnWidth, totalGapWidth, rowMarginWidth, rowPaddingWidth}, - }); + // The rows are wider than the table, so the caller scrolls them horizontally at exactly the width they need. This + // adds back all of the row chrome subtracted above, margin included: the rows keep their horizontal margin inside + // the scrolled content, so leaving it out would make the content container too narrow and clip the rows' trailing + // edge at the end of the scroll. + const scrollWidth = widths.reduce((total, width) => total + width, 0) + fixedColumnsWidth + selectionColumnWidth + totalGapWidth + rowChromeWidth; + + return {gridTemplateColumns, scrollWidth}; } export default useDynamicColumnWidths; diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx index e25a303d90a5..c0c84e65fab4 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx @@ -500,7 +500,6 @@ function WorkspaceCompanyCardsTable({ isItemInSearch={isItemInSearch} isItemInFilter={isItemInFilter} shouldUseDynamicColumns - columnResizingID={CONST.TABLES.COLUMN_RESIZING_IDS.WORKSPACE_COMPANY_CARDS} initialSortColumn="member" selectionEnabled={showTableControls} selectedKeys={validSelectedCardKeys} diff --git a/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx b/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx index cbebbde00fef..1c0117903eab 100644 --- a/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx +++ b/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx @@ -24,7 +24,6 @@ import WorkspaceCardListLabels from '@pages/workspace/expensifyCard/WorkspaceCar import {fontScale} from '@styles/typography'; import variables from '@styles/variables'; -import CONST from '@src/CONST'; import type {Card, PersonalDetails, PersonalDetailsList, Policy} from '@src/types/onyx'; import type {CardLimitType} from '@src/types/onyx/Card'; import type ExpensifyCardSettings from '@src/types/onyx/ExpensifyCardSettings'; @@ -329,7 +328,6 @@ export default function WorkspaceExpensifyCardsTable({ compareItems={compareItems} isItemInSearch={isItemInSearch} shouldUseDynamicColumns - columnResizingID={CONST.TABLES.COLUMN_RESIZING_IDS.WORKSPACE_EXPENSIFY_CARDS} initialSortColumn="name" narrowLayoutSortColumn="name" title={translate('workspace.common.expensifyCard')} diff --git a/src/components/Tables/WorkspaceListTable/WorkspaceRowBrickRoadIndicator.tsx b/src/components/Tables/WorkspaceListTable/WorkspaceRowBrickRoadIndicator.tsx index 84be594a3182..a7a78ad6d77c 100644 --- a/src/components/Tables/WorkspaceListTable/WorkspaceRowBrickRoadIndicator.tsx +++ b/src/components/Tables/WorkspaceListTable/WorkspaceRowBrickRoadIndicator.tsx @@ -1,6 +1,5 @@ import Icon from '@components/Icon'; -import useHasApprovalWorkflowWithNonMemberApprover from '@hooks/useHasApprovalWorkflowWithNonMemberApprover'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useOnyx from '@hooks/useOnyx'; import useTheme from '@hooks/useTheme'; @@ -56,11 +55,10 @@ function WorkspaceRowBrickRoadIndicator({policyID}: WorkspaceRowBrickRoadIndicat const [hasPolicyErrors] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {selector: createPolicyErrorsSelector(connectionSyncProgress)}); const [isHRCompleteSetupNeeded] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {selector: isMergeHRCompleteSetupNeededSelector}); const [isQBORefreshTokenExpiringSoon] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {selector: isQBORefreshTokenExpiringSoonSelector}); - const hasApprovalWorkflowWithNonMemberApprover = useHasApprovalWorkflowWithNonMemberApprover(policyID); // All of the error sources resolve to ERROR or undefined. The HR setup check and the QBO token expiry // warning are the only INFO sources, and every error takes precedence over them. - const hasError = !!hasReimbursementAccountErrors || !!hasCardFeedErrors || !!hasPolicyErrors || hasApprovalWorkflowWithNonMemberApprover; + const hasError = !!hasReimbursementAccountErrors || !!hasCardFeedErrors || !!hasPolicyErrors; if (!hasError && !isHRCompleteSetupNeeded && !isQBORefreshTokenExpiringSoon) { return null; diff --git a/src/components/Tables/WorkspaceMembersTable/index.tsx b/src/components/Tables/WorkspaceMembersTable/index.tsx index 91ecfb28a192..e32550d081a4 100644 --- a/src/components/Tables/WorkspaceMembersTable/index.tsx +++ b/src/components/Tables/WorkspaceMembersTable/index.tsx @@ -306,7 +306,6 @@ export default function WorkspaceMembersTable({ return ( ) => hasApprovalWorkflowWithNonMemberApprover({policy, currentUserLogin, rules, isMultipleApproversBetaEnabled}); - const [hasNonMemberApprover] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {selector: policySelector}); - - return !!hasNonMemberApprover; -} - -export default useHasApprovalWorkflowWithNonMemberApprover; diff --git a/src/hooks/useLocalReceiptThumbnail.ts b/src/hooks/useLocalReceiptThumbnail.ts index 04205a816098..54b87caa45aa 100644 --- a/src/hooks/useLocalReceiptThumbnail.ts +++ b/src/hooks/useLocalReceiptThumbnail.ts @@ -2,8 +2,6 @@ import {generateThumbnail} from '@pages/iou/request/step/IOURequestStepScan/crop import {useEffect, useRef, useState, useTransition} from 'react'; -import useReceiptUpgradeCount from './useReceiptUpgradeCount'; - const thumbnailCache = new Map(); /** Track how many mounted hook instances reference each sourceUri */ const thumbnailRefCount = new Map(); @@ -44,13 +42,10 @@ function useLocalReceiptThumbnail(sourceUri: string | undefined, isLocalFile: bo const [, startTransition] = useTransition(); const retainedUriRef = useRef(undefined); - const upgradeCount = useReceiptUpgradeCount(sourceUri); - // Resolve cached thumbnails synchronously during render (fast path) const cachedUri = sourceUri ? thumbnailCache.get(sourceUri) : undefined; const resultForCurrentSource = asyncResult?.source === sourceUri ? asyncResult : undefined; - const resolvedUri = cachedUri ?? resultForCurrentSource?.uri; - const thumbnailUri = resolvedUri && upgradeCount > 0 ? `${resolvedUri.split('#').at(0) ?? resolvedUri}#upgraded${upgradeCount}` : resolvedUri; + const thumbnailUri = cachedUri ?? resultForCurrentSource?.uri; const shouldGenerate = !!sourceUri && isLocalFile && !cachedUri; const isGenerating = shouldGenerate && !resultForCurrentSource?.done; diff --git a/src/hooks/usePolicyIndicatorChecks.ts b/src/hooks/usePolicyIndicatorChecks.ts index ed4ff9fb379f..50b686f25642 100644 --- a/src/hooks/usePolicyIndicatorChecks.ts +++ b/src/hooks/usePolicyIndicatorChecks.ts @@ -10,17 +10,15 @@ import { shouldShowPolicyError, shouldShowSyncError, } from '@libs/PolicyUtils'; -import {getApprovalWorkflowRulesForPolicy, hasApprovalWorkflowWithNonMemberApprover} from '@libs/WorkflowUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy} from '@src/types/onyx'; import type IndicatorStatus from '@src/types/utils/IndicatorStatus'; -import {accountIDSelector, emailSelector} from '@selectors/Session'; +import {accountIDSelector} from '@selectors/Session'; import useOnyx from './useOnyx'; -import usePermissions from './usePermissions'; import usePoliciesWithCardFeedErrors from './usePoliciesWithCardFeedErrors'; type PolicyIndicatorChecksResult = { @@ -43,10 +41,6 @@ function usePolicyIndicatorChecks(): PolicyIndicatorChecksResult { const [allDomains] = useOnyx(ONYXKEYS.COLLECTION.DOMAIN); const [allDomainPendingActions] = useOnyx(ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS); const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); - const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); - const {isBetaEnabled} = usePermissions(); - const isMultipleApproversBetaEnabled = isBetaEnabled(CONST.BETAS.MULTIPLE_APPROVERS); - const [allRules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const hasPendingDomainAdminRequests = Object.entries(allDomains ?? {}).some(([key, domain]) => hasPendingDomainAdminRequestsToReview(domain, currentUserAccountID, allDomainPendingActions?.[key.replace(ONYXKEYS.COLLECTION.DOMAIN, ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS)]), @@ -81,18 +75,6 @@ function usePolicyIndicatorChecks(): PolicyIndicatorChecksResult { ), ), ], - [ - CONST.INDICATOR_STATUS.HAS_APPROVAL_WORKFLOW_NON_MEMBER_APPROVER, - cleanPolicies.find((cleanPolicy) => - hasApprovalWorkflowWithNonMemberApprover({ - policy: cleanPolicy, - currentUserLogin, - // Rules only route the workflows under the beta, so they aren't used otherwise - rules: isMultipleApproversBetaEnabled ? getApprovalWorkflowRulesForPolicy(allRules, cleanPolicy?.id) : undefined, - isMultipleApproversBetaEnabled, - }), - ), - ], ]; const policyInfoChecks: Array<[IndicatorStatus, Policy | undefined]> = [ [CONST.INDICATOR_STATUS.HAS_MERGE_HR_SETUP_NEEDED, cleanPolicies.find((policy) => isPolicyAdmin(policy) && isMergeHRCompleteSetupNeeded(policy))], diff --git a/src/hooks/useReceiptUpgradeCount.ts b/src/hooks/useReceiptUpgradeCount.ts deleted file mode 100644 index 5c4bf0a9363b..000000000000 --- a/src/hooks/useReceiptUpgradeCount.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {getUpgradeCount, subscribe} from '@libs/ReceiptStorage/receiptUpgrades'; - -import {useSyncExternalStore} from 'react'; - -function toUpgradeKey(sourceUri: unknown): string { - if (typeof sourceUri !== 'string') { - return ''; - } - return sourceUri.split('/').pop()?.split('#').at(0)?.split('?').at(0) ?? ''; -} - -function useReceiptUpgradeCount(sourceUri: string | number | undefined): number { - return useSyncExternalStore(subscribe, () => getUpgradeCount(toUpgradeKey(sourceUri))); -} - -export default useReceiptUpgradeCount; diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index d570e8522a44..09bf411c31b8 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -5,7 +5,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import REPORT_LINK_ROUTE_PARAMS from '@libs/Navigation/reportLinkRouteParams'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; -import {getVisibleReportActionErrors, isReportPreviewAction} from '@libs/ReportActionsUtils'; +import {isReportPreviewAction} from '@libs/ReportActionsUtils'; import {getReportLastVisibleActionCreated, shouldReportAlignToTop} from '@libs/ReportUtils'; import type {ReportsSplitNavigatorParamList} from '@navigation/types'; @@ -21,7 +21,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; -import {isEmptyValueObject} from '@src/types/utils/EmptyObject'; import type {ViewableItemsChanged} from '@src/types/utils/ReactNativeCompat'; import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; @@ -348,8 +347,7 @@ function useReportActionsScroll({ return () => clearTimeout(timer); }, [actionIdToHighlight]); - // Only errors the user can actually see should pull the list down to them. - const lastIOUActionWithError = sortedVisibleReportActions.find((action) => !isEmptyValueObject(getVisibleReportActionErrors(action))); + const lastIOUActionWithError = sortedVisibleReportActions.find((action) => action.errors); const prevLastIOUActionWithError = usePrevious(lastIOUActionWithError); // Scroll to the bottom when a new errored action appears, so the user sees the failed money request. Re-checked diff --git a/src/hooks/useSaveMarketingAttribution.ts b/src/hooks/useSaveMarketingAttribution.ts deleted file mode 100644 index 5712f7bd9ff2..000000000000 --- a/src/hooks/useSaveMarketingAttribution.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {captureMarketingAttributionFromURL, saveMarketingAttribution} from '@libs/actions/MarketingAttribution'; - -import ONYXKEYS from '@src/ONYXKEYS'; -import {hasNonAnonymousSessionSelector} from '@src/selectors/Session'; -import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; - -import {useEffect, useRef, useState} from 'react'; - -import useOnyx from './useOnyx'; - -/** - * Reads marketing attribution from the landing URL on the first render, and saves it once the session has loaded, - * skipping it when the user is already signed in. Anonymous sessions (used to view public rooms) still save it, since those users - * can sign up from the room. Used in Expensify.tsx, which renders before NavigationRoot, - * so the query string is read before the router can strip it. - */ -function useSaveMarketingAttribution() { - const [capturedAttribution] = useState(captureMarketingAttributionFromURL); - const [hasNonAnonymousSession, sessionMetadata] = useOnyx(ONYXKEYS.SESSION, {selector: hasNonAnonymousSessionSelector}); - const isSessionLoading = isLoadingOnyxValue(sessionMetadata); - const hasHandledAttribution = useRef(false); - - useEffect(() => { - if (isSessionLoading || hasHandledAttribution.current) { - return; - } - hasHandledAttribution.current = true; - saveMarketingAttribution(capturedAttribution, !!hasNonAnonymousSession); - }, [isSessionLoading, hasNonAnonymousSession, capturedAttribution]); -} - -export default useSaveMarketingAttribution; diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 635fa6c4e26e..44161a56b19e 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -1426,12 +1426,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const deleteModalTitle = isDeletingOnlyExpenses ? getDeleteExpenseTitle(translate, expenseCount === 1 ? firstTransaction : undefined, expenseCount) : translate('iou.deleteReport', {count: deleteCount}); - let deleteModalPrompt = translate('iou.deleteReportConfirmation', {count: deleteCount}); - if (isDeletingOnlyExpenses) { - deleteModalPrompt = getDeleteConfirmationPrompt(translate, expenseCount === 1 ? firstTransaction : undefined, expenseCount, hasSomePendingExpenses); - } else if (isExpenseReportType && deleteCount === 1) { - deleteModalPrompt = translate('iou.deleteExpenseReportConfirmation'); - } + const deleteModalPrompt = isDeletingOnlyExpenses + ? getDeleteConfirmationPrompt(translate, expenseCount === 1 ? firstTransaction : undefined, expenseCount, hasSomePendingExpenses) + : translate('iou.deleteReportConfirmation', {count: deleteCount}); const handleDeleteSelectedTransactions = useCallback(async () => { if (!hash) { diff --git a/src/languages/de.ts b/src/languages/de.ts index 386fd4d97eaf..b9d9dac0d502 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -629,15 +629,6 @@ const translations: TranslationDeepObject = { launching: 'Expensify wird gestartet', expired: 'Ihre Sitzung ist abgelaufen.', signIn: 'Bitte melde dich erneut an.', - notValid: 'Ungültiger Link.', - sessionMismatch: 'Der Link, den Sie angeklickt haben, ist für Ihre aktuelle Sitzung nicht gültig.', - switchAccount: { - title: 'Konten wechseln?', - prompt: ({newEmail, currentEmail}: {newEmail: string; currentEmail: string}) => - `Sie sind als ${currentEmail} angemeldet. Diese Aktion meldet Sie stattdessen als ${newEmail} an.`, - confirm: 'Konten wechseln', - staySignedIn: 'Nicht das Konto wechseln', - }, }, multifactorAuthentication: { reviewTransaction: { @@ -858,7 +849,7 @@ const translations: TranslationDeepObject = { } else if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { type = 'report'; } - return `Sind Sie sicher, dass Sie diesen ${type} löschen möchten?${action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ? ' Alle Ausgaben in diesem Bericht werden als nicht gemeldet markiert.' : ''}`; + return `Sind Sie sicher, dass Sie diesen ${type} löschen möchten?`; }, onlyVisible: 'Nur sichtbar für', explain: 'Erklären', @@ -1493,7 +1484,6 @@ const translations: TranslationDeepObject = { one: 'Möchten Sie diesen Bericht wirklich löschen?', other: 'Möchten Sie diese Berichte wirklich löschen?', }), - deleteExpenseReportConfirmation: 'Möchten Sie diesen Bericht wirklich löschen? Alle Ausgaben in diesem Bericht werden als nicht gemeldet markiert.', settledExpensify: 'Bezahlt', paidStatusMarkedAsPaid: 'Als bezahlt markiert', paidStatusWithdrawing: 'Abhebung läuft', @@ -1638,7 +1628,6 @@ const translations: TranslationDeepObject = { invalidSplitYourself: 'Bitte gib für deine Aufteilung einen von null verschiedenen Betrag ein', noParticipantSelected: 'Bitte wählen Sie eine:n Teilnehmende:n aus', other: 'Unerwarteter Fehler. Bitte versuchen Sie es später erneut.', - payFailedExpenseDeleted: 'Die Zahlung ist fehlgeschlagen, weil die Spesenabrechnung gelöscht wurde.', genericCreateFailureMessage: 'Unerwarteter Fehler beim Einreichen dieser Ausgabe. Bitte versuche es später noch einmal.', genericCreateInvoiceFailureMessage: 'Unerwarteter Fehler beim Senden dieser Rechnung. Bitte versuche es später noch einmal.', genericHoldExpenseFailureMessage: 'Unerwarteter Fehler beim Zurückhalten dieser Ausgabe. Bitte versuche es später erneut.', @@ -3007,10 +2996,6 @@ ${amount} für ${merchant} – ${date}`, }, }, approverInMultipleWorkflows: 'Dieses Mitglied gehört bereits zu einem anderen Genehmigungsworkflow. Alle Aktualisierungen hier werden sich auch dort auswirken.', - approverNotWorkspaceMember: 'Diese genehmigende Person ist nicht mehr Mitglied des Arbeitsbereichs. Wählen Sie eine neue genehmigende Person oder löschen Sie diesen Workflow.', - defaultWorkflowApproverNotWorkspaceMember: 'Diese genehmigende Person ist nicht mehr Mitglied des Arbeitsbereichs. Wählen Sie eine neue genehmigende Person.', - overLimitApproverNotWorkspaceMember: - 'Die zusätzliche genehmigende Person für Berichte über dem Limit ist nicht mehr Mitglied des Arbeitsbereichs. Wählen Sie eine neue zusätzliche genehmigende Person.', approverCircularReference: (name1: string, name2: string) => `${name1} genehmigt bereits Berichte für ${name2}. Bitte wähle eine andere approvierende Person, um einen zirkulären Workflow zu vermeiden.`, emptyContent: { @@ -8924,17 +8909,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc setupIncomplete: (setupLink: string | undefined) => `Verbunden. ${setupLink ? `Einrichtung abschließen` : 'Einrichtung abschließen'} zum Importieren von Mitarbeitenden.`, mergeHR: { - groups: { - title: 'Gruppen', - description: 'Wählen Sie die Mitarbeitergruppen aus, die Sie mit diesem Workspace synchronisieren möchten', - staleSelectionError: (providerName: string) => - `Einige der Gruppen, die Sie ausgewählt haben, existieren in ${providerName} nicht mehr, daher werden deren Mitarbeitende nicht mehr synchronisiert.`, - updateSelectionLink: 'Aktualisieren Sie Ihre Gruppen.', - noGroupsFound: 'Keine Gruppen gefunden', - noGroupsFoundDescription: - 'Es gibt derzeit keine Gruppen zum Auswählen. Speichern Sie ohne ausgewählte Gruppen, um alle Mitarbeitenden zu synchronisieren, oder synchronisieren Sie die Verbindung erneut, sobald neue Gruppen vorhanden sind.', - unnamedGroup: (groupID: string) => `Unbenannte Gruppe (${groupID})`, - }, + groups: {title: 'Gruppen', description: 'Wählen Sie die Mitarbeitergruppen aus, die Sie mit diesem Workspace synchronisieren möchten'}, }, }, recruiting: { diff --git a/src/languages/el.ts b/src/languages/el.ts index a34beef18540..b721bca85b38 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -633,15 +633,6 @@ const translations: TranslationDeepObject = { launching: 'Εκκίνηση του Expensify', expired: 'Η συνεδρία σας έχει λήξει.', signIn: 'Παρακαλούμε συνδεθείτε ξανά.', - notValid: 'Μη έγκυρος σύνδεσμος.', - sessionMismatch: 'Ο σύνδεσμος που κάνατε κλικ δεν είναι έγκυρος για την τρέχουσα συνεδρία σας.', - switchAccount: { - title: 'Αλλαγή λογαριασμών;', - prompt: ({newEmail, currentEmail}: {newEmail: string; currentEmail: string}) => - `Έχετε συνδεθεί ως ${currentEmail}. Αυτή η ενέργεια θα σας συνδέσει ως ${newEmail} αντί για αυτό.`, - confirm: 'Αλλαγή λογαριασμών', - staySignedIn: 'Να μην αλλάξω λογαριασμούς', - }, }, multifactorAuthentication: { reviewTransaction: { @@ -876,7 +867,7 @@ const translations: TranslationDeepObject = { } else if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { type = 'report'; } - return `Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτό το ${type};${action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ? ' Όλες οι δαπάνες σε αυτήν την αναφορά θα γίνουν μη υποβληθείσες.' : ''}`; + return `Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτό το ${type};`; }, onlyVisible: 'Ορατό μόνο σε', explain: 'Εξηγήστε', @@ -1541,7 +1532,6 @@ const translations: TranslationDeepObject = { one: 'Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτήν την αναφορά;', other: 'Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτές τις αναφορές;', }), - deleteExpenseReportConfirmation: 'Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτήν την αναφορά; Όλες οι δαπάνες σε αυτήν την αναφορά θα γίνουν μη υποβληθείσες.', settledExpensify: 'Πληρωμένο', paidStatusMarkedAsPaid: 'Επισημάνθηκε ως πληρωμένο', paidStatusWithdrawing: 'Ανάληψη', @@ -1705,7 +1695,6 @@ const translations: TranslationDeepObject = { invalidSplitYourself: 'Παρακαλούμε εισαγάγετε ένα μη μηδενικό ποσό για τη διαίρεσή σας', noParticipantSelected: 'Παρακαλούμε επιλέξτε έναν συμμετέχοντα', other: 'Απρόσμενο σφάλμα. Παρακαλούμε δοκιμάστε ξανά αργότερα.', - payFailedExpenseDeleted: 'Η πληρωμή απέτυχε επειδή η έκθεση εξόδων διαγράφηκε.', genericCreateFailureMessage: 'Μη αναμενόμενο σφάλμα κατά την αποστολή αυτής της δαπάνης. Παρακαλούμε δοκιμάστε ξανά αργότερα.', genericCreateInvoiceFailureMessage: 'Προέκυψε απροσδόκητο σφάλμα κατά την αποστολή αυτού του τιμολογίου. Παρακαλούμε προσπαθήστε ξανά αργότερα.', genericHoldExpenseFailureMessage: 'Προέκυψε απρόσμενο σφάλμα κατά την κράτηση αυτής της δαπάνης. Δοκιμάστε ξανά αργότερα.', @@ -3066,9 +3055,6 @@ ${amount} για ${merchant} - ${date}`, }, }, approverInMultipleWorkflows: 'Αυτό το μέλος ανήκει ήδη σε άλλη ροή έγκρισης. Τυχόν ενημερώσεις εδώ θα αντικατοπτρίζονται και εκεί.', - approverNotWorkspaceMember: 'Αυτός ο εγκρίνων δεν είναι πλέον μέλος του χώρου εργασίας. Επιλέξτε νέο εγκρίνοντα ή διαγράψτε αυτή τη ροή εργασίας.', - defaultWorkflowApproverNotWorkspaceMember: 'Αυτός ο εγκρίνων δεν είναι πλέον μέλος του χώρου εργασίας. Επιλέξτε νέο εγκρίνοντα.', - overLimitApproverNotWorkspaceMember: 'Ο πρόσθετος εγκρίνων για αναφορές πάνω από το όριο δεν είναι πλέον μέλος του χώρου εργασίας. Επιλέξτε νέο πρόσθετο εγκρίνοντα.', approverCircularReference: (name1: string, name2: string) => `Ο/Η ${name1} ήδη εγκρίνει αναφορές προς τον/την ${name2}. Παρακαλούμε επιλέξτε διαφορετικό εγκρίνων για να αποφύγετε έναν κυκλικό κύκλο έγκρισης.`, emptyContent: { @@ -7856,13 +7842,6 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ groups: { title: 'Ομάδες', description: 'Επιλέξτε τις ομάδες υπαλλήλων που θέλετε να συγχρονίσετε με αυτόν τον χώρο εργασίας', - staleSelectionError: (providerName: string) => - `Ορισμένες από τις ομάδες που επιλέξατε δεν υπάρχουν πλέον στο ${providerName}, επομένως οι εργαζόμενοι τους έχουν σταματήσει να συγχρονίζονται.`, - updateSelectionLink: 'Ενημερώστε τις ομάδες σας.', - noGroupsFound: 'Δεν βρέθηκαν ομάδες', - noGroupsFoundDescription: - 'Δεν υπάρχουν ομάδες για επιλογή αυτή τη στιγμή. Αποθηκεύστε χωρίς επιλεγμένες ομάδες για να συγχρονίσετε όλους τους υπαλλήλους ή συγχρονίστε ξανά τη σύνδεση μόλις δημιουργηθούν νέες ομάδες.', - unnamedGroup: (groupID: string) => `Ομάδα χωρίς όνομα (${groupID})`, }, }, }, diff --git a/src/languages/en.ts b/src/languages/en.ts index 0fc11e32ea37..b63097414db7 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -703,14 +703,6 @@ const translations = { launching: 'Launching Expensify', expired: 'Your session has expired.', signIn: 'Please sign in again.', - notValid: 'Invalid link.', - sessionMismatch: "The link you clicked isn't valid for your current session.", - switchAccount: { - title: 'Switch accounts?', - prompt: ({newEmail, currentEmail}: {newEmail: string; currentEmail: string}) => `You're signed in as ${currentEmail}. This action will sign you in as ${newEmail} instead.`, - confirm: 'Switch accounts', - staySignedIn: "Don't switch accounts", - }, }, multifactorAuthentication: { reviewTransaction: { @@ -943,7 +935,7 @@ const translations = { } else if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { type = 'report'; } - return `Are you sure you want to delete this ${type}?${action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ? ' All expenses in this report will become unreported.' : ''}`; + return `Are you sure you want to delete this ${type}?`; }, onlyVisible: 'Only visible to', explain: 'Explain', @@ -1621,7 +1613,6 @@ const translations = { one: 'Are you sure that you want to delete this report?', other: 'Are you sure that you want to delete these reports?', }), - deleteExpenseReportConfirmation: 'Are you sure that you want to delete this report? All expenses in this report will become unreported.', settledExpensify: 'Paid', paidStatusMarkedAsPaid: 'Marked as paid', paidStatusWithdrawing: 'Withdrawing', @@ -1781,7 +1772,6 @@ const translations = { invalidSplitYourself: 'Please enter a non-zero amount for your split', noParticipantSelected: 'Please select a participant', other: 'Unexpected error. Please try again later.', - payFailedExpenseDeleted: 'The payment failed because the expense report was deleted.', genericCreateFailureMessage: 'Unexpected error submitting this expense. Please try again later.', genericCreateInvoiceFailureMessage: 'Unexpected error sending this invoice. Please try again later.', genericHoldExpenseFailureMessage: 'Unexpected error holding this expense. Please try again later.', @@ -3141,9 +3131,6 @@ const translations = { }, }, approverInMultipleWorkflows: 'This member already belongs to another approval workflow. Any updates here will reflect there too.', - approverNotWorkspaceMember: 'This approver is no longer a workspace member. Choose a new approver or delete this workflow.', - defaultWorkflowApproverNotWorkspaceMember: 'This approver is no longer a workspace member. Choose a new approver.', - overLimitApproverNotWorkspaceMember: 'The additional approver for reports over the limit is no longer a workspace member. Choose a new additional approver.', approverCircularReference: (name1: string, name2: string) => `${name1} already approves reports to ${name2}. Please choose a different approver to avoid a circular workflow.`, emptyContent: { @@ -8077,12 +8064,6 @@ const translations = { groups: { title: 'Groups', description: 'Choose the groups of employees you would like to sync with this workspace', - staleSelectionError: (providerName: string) => `Some of the groups you selected no longer exist in ${providerName}, so their employees have stopped syncing.`, - updateSelectionLink: 'Update your groups.', - noGroupsFound: 'No groups found', - noGroupsFoundDescription: - 'There are no groups to select right now. Save with no groups selected to sync all employees, or sync the connection again once new groups exist.', - unnamedGroup: (groupID: string) => `Unnamed group (${groupID})`, }, }, }, diff --git a/src/languages/es.ts b/src/languages/es.ts index b2722ac2eaa9..a25f1643ff3d 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -619,15 +619,6 @@ const translations: TranslationDeepObject = { launching: 'Cargando Expensify', expired: 'Tu sesión ha expirado.', signIn: 'Por favor, inicia sesión de nuevo.', - notValid: 'Enlace no válido.', - sessionMismatch: 'El enlace que has pulsado no es válido para tu sesión actual.', - switchAccount: { - title: '¿Cambiar de cuenta?', - prompt: ({newEmail, currentEmail}: {newEmail: string; currentEmail: string}) => - `Has iniciado sesión como ${currentEmail}. Esta acción hará que inicies sesión como ${newEmail} en su lugar.`, - confirm: 'Cambiar de cuenta', - staySignedIn: 'No cambies de cuenta', - }, }, multifactorAuthentication: { reviewTransaction: { @@ -840,7 +831,7 @@ const translations: TranslationDeepObject = { } else if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { type = 'informe'; } - return `¿Estás seguro de que quieres eliminar este ${type}?${action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ? ' Todos los gastos de este informe quedarán sin reportar.' : ''}`; + return `¿Estás seguro de que quieres eliminar este ${type}?`; }, onlyVisible: 'Visible sólo para', explain: 'Explicar', @@ -1483,7 +1474,6 @@ const translations: TranslationDeepObject = { one: '¿Estás seguro de que quieres eliminar este informe?', other: '¿Estás seguro de que quieres eliminar estos informes?', }), - deleteExpenseReportConfirmation: '¿Estás seguro de que quieres eliminar este informe? Todos los gastos de este informe quedarán sin reportar.', settledExpensify: 'Pagado', paidStatusMarkedAsPaid: 'Marcado como pagado', paidStatusWithdrawing: 'Retirada', @@ -1648,7 +1638,6 @@ const translations: TranslationDeepObject = { other: 'Error inesperado. Por favor, inténtalo más tarde.', genericHoldExpenseFailureMessage: 'Error inesperado al retener el gasto. Por favor, inténtalo de nuevo más tarde.', genericUnholdExpenseFailureMessage: 'Error inesperado al desbloquear el gasto. Por favor, inténtalo de nuevo más tarde.', - payFailedExpenseDeleted: 'El pago falló porque el informe de gastos fue eliminado.', genericCreateFailureMessage: 'Error inesperado al enviar este gasto. Por favor, inténtalo más tarde.', genericCreateInvoiceFailureMessage: 'Error inesperado al enviar la factura. Por favor, inténtalo de nuevo más tarde.', receiptDeleteFailureError: 'Error inesperado al borrar este recibo. Por favor, vuelve a intentarlo más tarde.', @@ -2909,9 +2898,6 @@ ${amount} para ${merchant} - ${date}`, }, }, approverInMultipleWorkflows: 'Este miembro ya pertenece a otro flujo de aprobación. Cualquier actualización aquí se reflejará allí también.', - approverNotWorkspaceMember: 'Este aprobador ya no es miembro del espacio de trabajo. Elige un nuevo aprobador o elimina este flujo de trabajo.', - defaultWorkflowApproverNotWorkspaceMember: 'Este aprobador ya no es miembro del espacio de trabajo. Elige un nuevo aprobador.', - overLimitApproverNotWorkspaceMember: 'El aprobador adicional para los informes que superan el límite ya no es miembro del espacio de trabajo. Elige un nuevo aprobador adicional.', approverCircularReference: (name1, name2) => `${name1} ya aprueba informes a ${name2}. Por favor, elige un aprobador diferente para evitar un flujo de trabajo circular.`, emptyContent: { @@ -7592,17 +7578,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, setupIncomplete: (setupLink: string | undefined) => `Conectado. ${setupLink ? `Completa la configuración` : 'Completar configuración'} para importar empleados.`, mergeHR: { - groups: { - title: 'Grupos', - description: 'Elige los grupos de empleados que te gustaría sincronizar con este espacio de trabajo', - staleSelectionError: (providerName: string) => - `Algunos de los grupos que seleccionaste ya no existen en ${providerName}, por lo que sus empleados han dejado de sincronizarse.`, - updateSelectionLink: 'Actualiza tus grupos.', - noGroupsFound: 'No se han encontrado grupos', - noGroupsFoundDescription: - 'No hay grupos para seleccionar ahora mismo. Guarda sin grupos seleccionados para sincronizar a todos los empleados o vuelve a sincronizar la conexión cuando existan nuevos grupos.', - unnamedGroup: (groupID: string) => `Grupo sin nombre (${groupID})`, - }, + groups: {title: 'Grupos', description: 'Elige los grupos de empleados que te gustaría sincronizar con este espacio de trabajo'}, }, }, recruiting: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index c6952dc82cfb..a23bff9e834b 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -628,15 +628,6 @@ const translations: TranslationDeepObject = { launching: 'Lancement d’Expensify', expired: 'Votre session a expiré.', signIn: 'Veuillez vous reconnecter.', - notValid: 'Lien invalide.', - sessionMismatch: 'Le lien sur lequel vous avez cliqué n’est pas valide pour votre session actuelle.', - switchAccount: { - title: 'Changer de compte ?', - prompt: ({newEmail, currentEmail}: {newEmail: string; currentEmail: string}) => - `Vous êtes connecté en tant que ${currentEmail}. Cette action vous connectera plutôt en tant que ${newEmail}.`, - confirm: 'Changer de compte', - staySignedIn: 'Ne changez pas de compte', - }, }, multifactorAuthentication: { reviewTransaction: { @@ -858,7 +849,7 @@ const translations: TranslationDeepObject = { } else if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { type = 'report'; } - return `Voulez-vous vraiment supprimer ce ${type} ?${action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ? ' Toutes les dépenses de ce rapport seront considérées comme non déclarées.' : ''}`; + return `Voulez-vous vraiment supprimer ce ${type} ?`; }, onlyVisible: 'Visible uniquement par', explain: 'Expliquer', @@ -1499,7 +1490,6 @@ const translations: TranslationDeepObject = { one: 'Êtes-vous sûr de vouloir supprimer ce rapport ?', other: 'Êtes-vous sûr de vouloir supprimer ces rapports ?', }), - deleteExpenseReportConfirmation: 'Êtes-vous sûr de vouloir supprimer ce rapport ? Toutes les dépenses de ce rapport seront considérées comme non déclarées.', settledExpensify: 'Payé', paidStatusMarkedAsPaid: 'Marqué comme payé', paidStatusWithdrawing: 'Retrait', @@ -1643,7 +1633,6 @@ const translations: TranslationDeepObject = { invalidSplitYourself: 'Veuillez saisir un montant non nul pour votre répartition', noParticipantSelected: 'Veuillez sélectionner un participant', other: 'Erreur inattendue. Veuillez réessayer plus tard.', - payFailedExpenseDeleted: 'Le paiement a échoué car la note de frais a été supprimée.', genericCreateFailureMessage: 'Erreur inattendue lors de la soumission de cette dépense. Veuillez réessayer plus tard.', genericCreateInvoiceFailureMessage: 'Erreur inattendue lors de l’envoi de cette facture. Veuillez réessayer plus tard.', genericHoldExpenseFailureMessage: 'Erreur inattendue lors de la mise en attente de cette dépense. Veuillez réessayer ultérieurement.', @@ -3012,10 +3001,6 @@ ${amount} pour ${merchant} - ${date}`, }, }, approverInMultipleWorkflows: 'Ce membre appartient déjà à un autre circuit d’approbation. Toute mise à jour effectuée ici sera également répercutée là-bas.', - approverNotWorkspaceMember: 'Cet approbateur n’est plus membre de l’espace de travail. Choisissez un nouvel approbateur ou supprimez ce flux de travail.', - defaultWorkflowApproverNotWorkspaceMember: 'Cet approbateur n’est plus membre de l’espace de travail. Choisissez un nouvel approbateur.', - overLimitApproverNotWorkspaceMember: - 'L’approbateur supplémentaire pour les notes de frais au-delà de la limite n’est plus membre de l’espace de travail. Choisissez un nouvel approbateur supplémentaire.', approverCircularReference: (name1: string, name2: string) => `${name1} approuve déjà les notes de frais de ${name2}. Veuillez choisir un autre approbateur pour éviter un circuit d'approbation circulaire.`, emptyContent: { @@ -8941,17 +8926,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e setupIncomplete: (setupLink: string | undefined) => `Connecté. ${setupLink ? `Terminer la configuration` : 'Terminer la configuration'} pour importer les employés.`, mergeHR: { - groups: { - title: 'Groupes', - description: 'Choisissez les groupes d’employés que vous souhaitez synchroniser avec cet espace de travail', - staleSelectionError: (providerName: string) => - `Certains des groupes que vous avez sélectionnés n’existent plus dans ${providerName}, donc leurs employés ont cessé de se synchroniser.`, - updateSelectionLink: 'Mettez à jour vos groupes.', - noGroupsFound: 'Aucun groupe trouvé', - noGroupsFoundDescription: - 'Il n’y a aucun groupe à sélectionner pour le moment. Enregistrez sans groupe sélectionné pour synchroniser tous les employés, ou synchronisez à nouveau la connexion une fois que de nouveaux groupes existent.', - unnamedGroup: (groupID: string) => `Groupe sans nom (${groupID})`, - }, + groups: {title: 'Groupes', description: 'Choisissez les groupes d’employés que vous souhaitez synchroniser avec cet espace de travail'}, }, }, recruiting: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 970e9d46503f..25a73a4db01b 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -628,15 +628,6 @@ const translations: TranslationDeepObject = { launching: 'Avvio di Expensify', expired: 'La tua sessione è scaduta.', signIn: 'Accedi di nuovo.', - notValid: 'Link non valido.', - sessionMismatch: 'Il link che hai cliccato non è valido per la tua sessione attuale.', - switchAccount: { - title: 'Passare ad un altro account?', - prompt: ({newEmail, currentEmail}: {newEmail: string; currentEmail: string}) => - `Hai eseguito l’accesso come ${currentEmail}. Questa azione ti farà accedere come ${newEmail} invece.`, - confirm: 'Cambia account', - staySignedIn: 'Non cambiare account', - }, }, multifactorAuthentication: { reviewTransaction: { @@ -858,7 +849,7 @@ const translations: TranslationDeepObject = { } else if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { type = 'report'; } - return `Sei sicuro di voler eliminare questo/questa ${type}?${action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ? ' Tutte le spese in questo report risulteranno non rendicontate.' : ''}`; + return `Sei sicuro di voler eliminare questo/questa ${type}?`; }, onlyVisible: 'Visibile solo a', explain: 'Spiega', @@ -1490,7 +1481,6 @@ const translations: TranslationDeepObject = { one: 'Sei sicuro di voler eliminare questo report?', other: 'Sei sicuro di voler eliminare questi report?', }), - deleteExpenseReportConfirmation: 'Sei sicuro di voler eliminare questo report? Tutte le spese in questo report risulteranno non rendicontate.', settledExpensify: 'Pagato', paidStatusMarkedAsPaid: 'Segnato come pagato', paidStatusWithdrawing: 'Prelievo', @@ -1634,7 +1624,6 @@ const translations: TranslationDeepObject = { invalidSplitYourself: 'Inserisci un importo diverso da zero per la tua suddivisione', noParticipantSelected: 'Seleziona un partecipante', other: 'Errore imprevisto. Riprova più tardi.', - payFailedExpenseDeleted: 'Il pagamento non è riuscito perché la nota spese è stata eliminata.', genericCreateFailureMessage: 'Errore imprevisto durante l’invio di questa nota spese. Riprova più tardi.', genericCreateInvoiceFailureMessage: 'Errore imprevisto nell’invio di questa fattura. Riprova più tardi.', genericHoldExpenseFailureMessage: 'Errore imprevisto durante il blocco di questa spesa. Riprova più tardi.', @@ -2993,9 +2982,6 @@ ${amount} per ${merchant} - ${date}`, }, }, approverInMultipleWorkflows: 'Questo membro appartiene già a un altro flusso di approvazione. Qualsiasi aggiornamento effettuato qui verrà applicato anche lì.', - approverNotWorkspaceMember: 'Questo approvatore non è più un membro dello spazio di lavoro. Scegli un nuovo approvatore oppure elimina questo workflow.', - defaultWorkflowApproverNotWorkspaceMember: 'Questo approvatore non è più un membro dello spazio di lavoro. Scegli un nuovo approvatore.', - overLimitApproverNotWorkspaceMember: 'L’approvatore aggiuntivo per i report oltre il limite non è più un membro dello spazio di lavoro. Scegli un nuovo approvatore aggiuntivo.', approverCircularReference: (name1: string, name2: string) => `${name1} approva già i report per ${name2}. Scegli un altro approvatore per evitare un flusso di lavoro circolare.`, emptyContent: { @@ -8867,17 +8853,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, setupIncomplete: (setupLink: string | undefined) => `Connesso. ${setupLink ? `Completa la configurazione` : 'Completa configurazione'} per importare i dipendenti.`, mergeHR: { - groups: { - title: 'Gruppi', - description: 'Scegli i gruppi di dipendenti che vuoi sincronizzare con questo spazio di lavoro', - staleSelectionError: (providerName: string) => - `Alcuni dei gruppi che hai selezionato non esistono più in ${providerName}, quindi i rispettivi dipendenti hanno smesso di sincronizzarsi.`, - updateSelectionLink: 'Aggiorna i tuoi gruppi.', - noGroupsFound: 'Nessun gruppo trovato', - noGroupsFoundDescription: - 'Al momento non ci sono gruppi da selezionare. Salva senza selezionare gruppi per sincronizzare tutti i dipendenti oppure sincronizza di nuovo la connessione quando esisteranno nuovi gruppi.', - unnamedGroup: (groupID: string) => `Gruppo senza nome (${groupID})`, - }, + groups: {title: 'Gruppi', description: 'Scegli i gruppi di dipendenti che vuoi sincronizzare con questo spazio di lavoro'}, }, }, recruiting: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 69d42701f511..03fee37c88b3 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -630,15 +630,6 @@ const translations: TranslationDeepObject = { launching: 'Expensify を起動中', expired: 'セッションの有効期限が切れました。', signIn: 'もう一度サインインしてください。', - notValid: '無効なリンクです。', - sessionMismatch: 'クリックされたリンクは現在のセッションでは有効ではありません。', - switchAccount: { - title: 'アカウントを切り替えますか?', - prompt: ({newEmail, currentEmail}: {newEmail: string; currentEmail: string}) => - `現在は ${currentEmail} としてサインインしています。この操作を行うと、代わりに ${newEmail} としてサインインします。`, - confirm: 'アカウントを切り替える', - staySignedIn: 'アカウントを切り替えないでください', - }, }, multifactorAuthentication: { reviewTransaction: { @@ -847,7 +838,7 @@ const translations: TranslationDeepObject = { } else if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { type = 'report'; } - return `この${type}を削除してもよろしいですか?${action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ? 'このレポート内のすべての経費は未報告になります。' : ''}`; + return `この${type}を削除してもよろしいですか?`; }, onlyVisible: '次のユーザーにのみ表示', explain: '説明', @@ -1476,7 +1467,6 @@ const translations: TranslationDeepObject = { one: 'このレポートを削除してもよろしいですか?', other: 'これらのレポートを削除してもよろしいですか?', }), - deleteExpenseReportConfirmation: 'このレポートを削除してもよろしいですか?このレポート内のすべての経費は未報告になります。', settledExpensify: '支払い済み', paidStatusMarkedAsPaid: '支払済みに設定しました', paidStatusWithdrawing: '出金中', @@ -1619,7 +1609,6 @@ const translations: TranslationDeepObject = { invalidSplitYourself: '分割する金額は 0 以外の数値を入力してください', noParticipantSelected: '参加者を選択してください', other: '予期しないエラーが発生しました。しばらくしてからもう一度お試しください。', - payFailedExpenseDeleted: 'この支払いは、経費精算書が削除されたため失敗しました。', genericCreateFailureMessage: 'この経費の送信中に予期しないエラーが発生しました。後でもう一度お試しください。', genericCreateInvoiceFailureMessage: 'この請求書の送信中に予期しないエラーが発生しました。後でもう一度お試しください。', genericHoldExpenseFailureMessage: 'この経費を保留中に予期しないエラーが発生しました。後でもう一度お試しください。', @@ -2968,9 +2957,6 @@ ${date} の ${merchant} への ${amount}`, }, }, approverInMultipleWorkflows: 'このメンバーはすでに別の承認ワークフローに属しています。ここでの更新内容はそちらにも反映されます。', - approverNotWorkspaceMember: 'この承認者はワークスペースのメンバーではなくなりました。新しい承認者を選択するか、このワークフローを削除してください。', - defaultWorkflowApproverNotWorkspaceMember: 'この承認者はワークスペースのメンバーではなくなりました。新しい承認者を選択してください。', - overLimitApproverNotWorkspaceMember: '上限を超えるレポートの追加承認者は、ワークスペースのメンバーではなくなりました。新しい追加承認者を選択してください。', approverCircularReference: (name1: string, name2: string) => `${name1} はすでに ${name2} にレポートを承認しています。ワークフローが循環しないよう、別の承認者を選択してください。`, emptyContent: { @@ -8756,16 +8742,7 @@ ${reportName}`, setupIncomplete: (setupLink: string | undefined) => `接続されました。従業員をインポートするには ${setupLink ? `セットアップを完了` : '設定を完了'} に接続してください。`, mergeHR: { - groups: { - title: 'グループ', - description: 'このワークスペースと同期したい従業員グループを選択してください', - staleSelectionError: (providerName: string) => `選択されたグループの一部は、${providerName} では既に存在しないため、その従業員の同期が停止しました。`, - updateSelectionLink: 'グループを更新します。', - noGroupsFound: 'グループが見つかりませんでした', - noGroupsFoundDescription: - '現在選択できるグループはありません。グループを選択せずに保存して全従業員を同期するか、新しいグループが作成されてから再度接続を同期してください。', - unnamedGroup: (groupID: string) => `名称未設定のグループ(${groupID})`, - }, + groups: {title: 'グループ', description: 'このワークスペースと同期したい従業員グループを選択してください'}, }, }, recruiting: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index ad3d2ea870e9..4bbf9c07dd2f 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -628,15 +628,6 @@ const translations: TranslationDeepObject = { launching: 'Expensify wordt gestart', expired: 'Je sessie is verlopen.', signIn: 'Meld je opnieuw aan.', - notValid: 'Ongeldige link.', - sessionMismatch: 'De link waarop je hebt geklikt is niet geldig voor je huidige sessie.', - switchAccount: { - title: 'Van account wisselen?', - prompt: ({newEmail, currentEmail}: {newEmail: string; currentEmail: string}) => - `Je bent aangemeld als ${currentEmail}. Deze actie meldt je in plaats daarvan aan als ${newEmail}.`, - confirm: 'Van account wisselen', - staySignedIn: 'Niet van account wisselen', - }, }, multifactorAuthentication: { reviewTransaction: { @@ -857,7 +848,7 @@ const translations: TranslationDeepObject = { } else if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { type = 'report'; } - return `Weet je zeker dat je deze ${type} wilt verwijderen?${action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ? ' Alle uitgaven in dit rapport krijgen de status Niet gerapporteerd.' : ''}`; + return `Weet je zeker dat je deze ${type} wilt verwijderen?`; }, onlyVisible: 'Alleen zichtbaar voor', explain: 'Uitleg', @@ -1491,7 +1482,6 @@ const translations: TranslationDeepObject = { one: 'Weet u zeker dat u dit rapport wilt verwijderen?', other: 'Weet u zeker dat u deze rapporten wilt verwijderen?', }), - deleteExpenseReportConfirmation: 'Weet u zeker dat u dit rapport wilt verwijderen? Alle uitgaven in dit rapport krijgen de status Niet gerapporteerd.', settledExpensify: 'Betaald', paidStatusMarkedAsPaid: 'Gemarkeerd als betaald', paidStatusWithdrawing: 'Opnemen', @@ -1634,7 +1624,6 @@ const translations: TranslationDeepObject = { invalidSplitYourself: 'Voer een ander bedrag dan nul in voor je verdeling', noParticipantSelected: 'Selecteer een deelnemer', other: 'Onverwachte fout. Probeer het later opnieuw.', - payFailedExpenseDeleted: 'De betaling is mislukt omdat de declaratie is verwijderd.', genericCreateFailureMessage: 'Onverwachte fout bij het indienen van deze uitgave. Probeer het later opnieuw.', genericCreateInvoiceFailureMessage: 'Onverwachte fout bij het verzenden van deze factuur. Probeer het later opnieuw.', genericHoldExpenseFailureMessage: 'Onverwachte fout bij het vasthouden van deze uitgave. Probeer het later opnieuw.', @@ -2997,9 +2986,6 @@ ${amount} voor ${merchant} - ${date}`, }, }, approverInMultipleWorkflows: 'Dit lid behoort al tot een andere goedkeuringsworkflow. Alle updates hier worden daar ook doorgevoerd.', - approverNotWorkspaceMember: 'Deze fiatteur is geen lid meer van de workspace. Kies een nieuwe fiatteur of verwijder deze workflow.', - defaultWorkflowApproverNotWorkspaceMember: 'Deze fiatteur is geen lid meer van de workspace. Kies een nieuwe fiatteur.', - overLimitApproverNotWorkspaceMember: 'De extra fiatteur voor rapporten boven de limiet is geen lid meer van de workspace. Kies een nieuwe extra fiatteur.', approverCircularReference: (name1: string, name2: string) => `${name1} keurt al rapporten goed voor ${name2}. Kies een andere fiatteur om een cirkelstroom te voorkomen.`, emptyContent: { @@ -8845,17 +8831,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, setupIncomplete: (setupLink: string | undefined) => `Verbonden. ${setupLink ? `Instellen voltooien` : 'Configuratie voltooien'} om werknemers te importeren.`, mergeHR: { - groups: { - title: 'Groepen', - description: 'Kies de groepen werknemers die je met deze workspace wilt synchroniseren', - staleSelectionError: (providerName: string) => - `Een aantal van de groepen die je hebt geselecteerd bestaan niet meer in ${providerName}, waardoor de werknemers in die groepen niet meer worden gesynchroniseerd.`, - updateSelectionLink: 'Werk je groepen bij.', - noGroupsFound: 'Geen groepen gevonden', - noGroupsFoundDescription: - 'Er zijn op dit moment geen groepen om te selecteren. Sla op zonder groepen te selecteren om alle werknemers te synchroniseren, of synchroniseer de verbinding opnieuw zodra er nieuwe groepen zijn.', - unnamedGroup: (groupID: string) => `Groep zonder naam (${groupID})`, - }, + groups: {title: 'Groepen', description: 'Kies de groepen werknemers die je met deze workspace wilt synchroniseren'}, }, }, recruiting: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 1651a4662bd2..2f73cfd6b219 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -631,14 +631,6 @@ const translations: TranslationDeepObject = { launching: 'Uruchamianie Expensify', expired: 'Twoja sesja wygasła.', signIn: 'Zaloguj się ponownie.', - notValid: 'Nieprawidłowy link.', - sessionMismatch: 'Link, który kliknąłeś, nie jest ważny dla twojej bieżącej sesji.', - switchAccount: { - title: 'Przełączyć konto?', - prompt: ({newEmail, currentEmail}: {newEmail: string; currentEmail: string}) => `Jesteś zalogowany jako ${currentEmail}. Ta akcja zaloguje cię zamiast tego jako ${newEmail}.`, - confirm: 'Przełącz konta', - staySignedIn: 'Nie przełączaj kont', - }, }, multifactorAuthentication: { reviewTransaction: { @@ -862,7 +854,7 @@ const translations: TranslationDeepObject = { } else if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { type = 'report'; } - return `Czy na pewno chcesz usunąć ten/ tę ${type}?${action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ? ' Wszystkie wydatki w tym raporcie zostaną oznaczone jako nierozliczone.' : ''}`; + return `Czy na pewno chcesz usunąć ten/ tę ${type}?`; }, onlyVisible: 'Widoczne tylko dla', explain: 'Wyjaśnij', @@ -1524,7 +1516,6 @@ const translations: TranslationDeepObject = { one: 'Czy na pewno chcesz usunąć ten raport?', other: 'Czy na pewno chcesz usunąć te raporty?', }), - deleteExpenseReportConfirmation: 'Czy na pewno chcesz usunąć ten raport? Wszystkie wydatki w tym raporcie zostaną oznaczone jako nierozliczone.', settledExpensify: 'Zapłacono', paidStatusMarkedAsPaid: 'Oznaczono jako zapłacone', paidStatusWithdrawing: 'Wypłata', @@ -1667,7 +1658,6 @@ const translations: TranslationDeepObject = { invalidSplitYourself: 'Wprowadź niezerową kwotę dla swojego podziału', noParticipantSelected: 'Wybierz uczestnika', other: 'Nieoczekiwany błąd. Spróbuj ponownie później.', - payFailedExpenseDeleted: 'Płatność nie powiodła się, ponieważ raport wydatków został usunięty.', genericCreateFailureMessage: 'Niespodziewany błąd podczas przesyłania tego wydatku. Spróbuj ponownie później.', genericCreateInvoiceFailureMessage: 'Nieoczekiwany błąd podczas wysyłania tej faktury. Spróbuj ponownie później.', genericHoldExpenseFailureMessage: 'Nieoczekiwany błąd podczas wstrzymywania tego wydatku. Spróbuj ponownie później.', @@ -3027,9 +3017,6 @@ ${amount} dla ${merchant} - ${date}`, }, }, approverInMultipleWorkflows: 'Ten członek należy już do innego procesu zatwierdzania. Wszelkie zmiany wprowadzone tutaj będą widoczne także tam.', - approverNotWorkspaceMember: 'Ten zatwierdzający nie jest już członkiem tego workspace’u. Wybierz nowego zatwierdzającego albo usuń ten workflow.', - defaultWorkflowApproverNotWorkspaceMember: 'Ten zatwierdzający nie jest już członkiem tego workspace’u. Wybierz nowego zatwierdzającego.', - overLimitApproverNotWorkspaceMember: 'Dodatkowy zatwierdzający dla raportów powyżej limitu nie jest już członkiem tego workspace’u. Wybierz nowego dodatkowego zatwierdzającego.', approverCircularReference: (name1: string, name2: string) => `${name1} już zatwierdza raporty dla ${name2}. Wybierz innego zatwierdzającego, aby uniknąć zapętlenia przepływu pracy.`, emptyContent: { @@ -8864,17 +8851,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, setupIncomplete: (setupLink: string | undefined) => `Połączono. ${setupLink ? `Zakończ konfigurację` : 'Zakończ konfigurację'}, aby zaimportować pracowników.`, mergeHR: { - groups: { - title: 'Grupy', - description: 'Wybierz grupy pracowników, które chcesz zsynchronizować z tą przestrzenią roboczą', - staleSelectionError: (providerName: string) => - `Niektóre z wybranych przez ciebie grup już nie istnieją w ${providerName}, więc ich pracownicy przestali się synchronizować.`, - updateSelectionLink: 'Zaktualizuj swoje grupy.', - noGroupsFound: 'Nie znaleziono grup', - noGroupsFoundDescription: - 'Aktualnie nie ma żadnych grup do wyboru. Zapisz bez zaznaczania grup, aby zsynchronizować wszystkich pracowników, albo ponownie zsynchronizuj połączenie, gdy pojawią się nowe grupy.', - unnamedGroup: (groupID: string) => `Grupa bez nazwy (${groupID})`, - }, + groups: {title: 'Grupy', description: 'Wybierz grupy pracowników, które chcesz zsynchronizować z tą przestrzenią roboczą'}, }, }, recruiting: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 8d305e772888..c8bd2dea74e6 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -627,15 +627,6 @@ const translations: TranslationDeepObject = { launching: 'Iniciando Expensify', expired: 'Sua sessão expirou.', signIn: 'Faça login novamente.', - notValid: 'Link inválido.', - sessionMismatch: 'O link em que você clicou não é válido para a sua sessão atual.', - switchAccount: { - title: 'Trocar de conta?', - prompt: ({newEmail, currentEmail}: {newEmail: string; currentEmail: string}) => - `Você está conectado como ${currentEmail}. Esta ação fará com que você entre como ${newEmail} em vez disso.`, - confirm: 'Alterar contas', - staySignedIn: 'Não trocar de conta', - }, }, multifactorAuthentication: { reviewTransaction: { @@ -857,7 +848,7 @@ const translations: TranslationDeepObject = { } else if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { type = 'report'; } - return `Tem certeza de que deseja excluir este(a) ${type}?${action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ? ' Todas as despesas deste relatório passarão a não ser informadas.' : ''}`; + return `Tem certeza de que deseja excluir este(a) ${type}?`; }, onlyVisible: 'Visível apenas para', explain: 'Explicar', @@ -1490,7 +1481,6 @@ const translations: TranslationDeepObject = { one: 'Tem certeza de que deseja excluir este relatório?', other: 'Tem certeza de que deseja excluir estes relatórios?', }), - deleteExpenseReportConfirmation: 'Tem certeza de que deseja excluir este relatório? Todas as despesas deste relatório passarão a não ser informadas.', settledExpensify: 'Pago', paidStatusMarkedAsPaid: 'Marcado como pago', paidStatusWithdrawing: 'Sacando', @@ -1632,7 +1622,6 @@ const translations: TranslationDeepObject = { invalidSplitYourself: 'Insira um valor diferente de zero para a sua divisão', noParticipantSelected: 'Selecione um participante', other: 'Erro inesperado. Tente novamente mais tarde.', - payFailedExpenseDeleted: 'O pagamento falhou porque o relatório de despesas foi excluído.', genericCreateFailureMessage: 'Erro inesperado ao enviar esta despesa. Tente novamente mais tarde.', genericCreateInvoiceFailureMessage: 'Erro inesperado ao enviar esta fatura. Tente novamente mais tarde.', genericHoldExpenseFailureMessage: 'Erro inesperado ao reter esta despesa. Tente novamente mais tarde.', @@ -2986,9 +2975,6 @@ ${amount} para ${merchant} - ${date}`, }, }, approverInMultipleWorkflows: 'Este membro já pertence a outro fluxo de aprovação. Qualquer atualização aqui será refletida lá também.', - approverNotWorkspaceMember: 'Esse aprovador não é mais membro do workspace. Escolha um novo aprovador ou exclua este fluxo de trabalho.', - defaultWorkflowApproverNotWorkspaceMember: 'Esse aprovador não é mais membro do workspace. Escolha um novo aprovador.', - overLimitApproverNotWorkspaceMember: 'O aprovador adicional para relatórios acima do limite não é mais membro do workspace. Escolha um novo aprovador adicional.', approverCircularReference: (name1: string, name2: string) => `${name1} já aprova relatórios para ${name2}. Escolha um aprovador diferente para evitar um fluxo de aprovação circular.`, emptyContent: { @@ -8849,17 +8835,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, setupIncomplete: (setupLink: string | undefined) => `Conectado. ${setupLink ? `Concluir configuração` : 'Concluir configuração'} para importar funcionários.`, mergeHR: { - groups: { - title: 'Grupos', - description: 'Escolha os grupos de funcionários que você gostaria de sincronizar com este workspace', - staleSelectionError: (providerName: string) => - `Alguns dos grupos que você selecionou não existem mais em ${providerName}, então os funcionários desses grupos pararam de ser sincronizados.`, - updateSelectionLink: 'Atualize seus grupos.', - noGroupsFound: 'Nenhum grupo encontrado', - noGroupsFoundDescription: - 'Não há grupos para selecionar no momento. Salve sem nenhum grupo selecionado para sincronizar todos os funcionários ou sincronize a conexão novamente quando existirem novos grupos.', - unnamedGroup: (groupID: string) => `Grupo sem nome (${groupID})`, - }, + groups: {title: 'Grupos', description: 'Escolha os grupos de funcionários que você gostaria de sincronizar com este workspace'}, }, }, recruiting: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 4ee1dd5a0eab..b673e27085cc 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -625,14 +625,6 @@ const translations: TranslationDeepObject = { launching: '正在启动 Expensify', expired: '您的会话已过期。', signIn: '请重新登录。', - notValid: '链接无效。', - sessionMismatch: '您点击的链接对您当前的会话无效。', - switchAccount: { - title: '切换账户?', - prompt: ({newEmail, currentEmail}: {newEmail: string; currentEmail: string}) => `您当前以 ${currentEmail} 身份登录。此操作将改为以 ${newEmail} 身份登录。`, - confirm: '切换账户', - staySignedIn: '不要切换账户', - }, }, multifactorAuthentication: { reviewTransaction: { @@ -836,7 +828,7 @@ const translations: TranslationDeepObject = { } else if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { type = 'report'; } - return `确定要删除此${type}吗?${action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ? '此报告中的所有费用都将变为未报销状态。' : ''}`; + return `确定要删除此${type}吗?`; }, onlyVisible: '仅对以下对象可见', explain: '解释', @@ -1434,7 +1426,6 @@ const translations: TranslationDeepObject = { one: '您确定要删除此报告吗?', other: '您确定要删除这些报告吗?', }), - deleteExpenseReportConfirmation: '您确定要删除此报告吗?此报告中的所有费用都将变为未报销状态。', settledExpensify: '已支付', paidStatusMarkedAsPaid: '已标记为已支付', paidStatusWithdrawing: '提现中', @@ -1575,7 +1566,6 @@ const translations: TranslationDeepObject = { invalidSplitYourself: '请输入一个非零金额用于分摊', noParticipantSelected: '请选择参与者', other: '发生意外错误。请稍后重试。', - payFailedExpenseDeleted: '此付款失败,因为报销报告已被删除。', genericCreateFailureMessage: '提交此报销时发生意外错误。请稍后重试。', genericCreateInvoiceFailureMessage: '发送此发票时发生未知错误。请稍后重试。', genericHoldExpenseFailureMessage: '暂时无法搁置此报销。请稍后再试。', @@ -2893,9 +2883,6 @@ ${amount},商户:${merchant} - 日期:${date}`, }, }, approverInMultipleWorkflows: '此成员已属于另一个审批流程。此处的任何更新也会在那边同步生效。', - approverNotWorkspaceMember: '此审批人已不再是工作区成员。请选择新的审批人或删除此工作流程。', - defaultWorkflowApproverNotWorkspaceMember: '此审批人已不再是工作区成员。请选择新的审批人。', - overLimitApproverNotWorkspaceMember: '超出限额报告的额外审批人已不再是工作区成员。请选择新的额外审批人。', approverCircularReference: (name1: string, name2: string) => `${name1} 已经将报表提交给 ${name2} 审批。请选择其他审批人以避免形成循环审批流程。`, emptyContent: { title: '没有可显示的成员', @@ -8535,15 +8522,7 @@ ${reportName}`, setupIncomplete: (setupLink: string | undefined) => `已连接。${setupLink ? `完成设置` : '完成设置'} 用于导入员工。`, mergeHR: { - groups: { - title: '群组', - description: '选择要与此工作区同步的员工分组', - staleSelectionError: (providerName: string) => `您选择的部分群组在 ${providerName} 中已不存在,因此其员工已停止同步。`, - updateSelectionLink: '更新你的群组。', - noGroupsFound: '未找到群组', - noGroupsFoundDescription: '目前没有可选择的分组。可在未选择任何分组的情况下保存以同步所有员工,或在新分组创建后再次同步连接。', - unnamedGroup: (groupID: string) => `未命名群组(${groupID})`, - }, + groups: {title: '群组', description: '选择要与此工作区同步的员工分组'}, }, }, recruiting: { diff --git a/src/libs/API/index.ts b/src/libs/API/index.ts index 5230eef14d96..14c224d8b036 100644 --- a/src/libs/API/index.ts +++ b/src/libs/API/index.ts @@ -186,8 +186,8 @@ function read(command: TComm // Apply optimistic updates of read requests immediately const request = prepareRequest(command, CONST.API_REQUEST_TYPE.READ, apiCommandParameters, onyxData); - // Sign in with supportAuthToken command shouldn't be blocked by write commands - if (command === READ_COMMANDS.SIGN_IN_WITH_SUPPORT_AUTH_TOKEN) { + // Sign in with shortLivedAuthToken command shouldn't be blocked by write commands + if (command === READ_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN || command === READ_COMMANDS.SIGN_IN_WITH_SUPPORT_AUTH_TOKEN) { processRequest(request, CONST.API_REQUEST_TYPE.READ); return; } diff --git a/src/libs/API/parameters/BeginAppleSignInParams.ts b/src/libs/API/parameters/BeginAppleSignInParams.ts index f4d610f78845..1fd844c2ab03 100644 --- a/src/libs/API/parameters/BeginAppleSignInParams.ts +++ b/src/libs/API/parameters/BeginAppleSignInParams.ts @@ -1,10 +1,9 @@ import type Locale from '@src/types/onyx/Locale'; -import type MarketingAttribution from '@src/types/onyx/MarketingAttribution'; type BeginAppleSignInParams = { idToken: string | undefined | null; preferredLocale: Locale | null; deviceInfo: string; -} & MarketingAttribution; +}; export default BeginAppleSignInParams; diff --git a/src/libs/API/parameters/BeginGoogleSignInParams.ts b/src/libs/API/parameters/BeginGoogleSignInParams.ts index f6161d78f8b4..5aa6775d0cc3 100644 --- a/src/libs/API/parameters/BeginGoogleSignInParams.ts +++ b/src/libs/API/parameters/BeginGoogleSignInParams.ts @@ -1,10 +1,9 @@ import type Locale from '@src/types/onyx/Locale'; -import type MarketingAttribution from '@src/types/onyx/MarketingAttribution'; type BeginGoogleSignInParams = { token: string | null; preferredLocale: Locale | null; deviceInfo: string; -} & MarketingAttribution; +}; export default BeginGoogleSignInParams; diff --git a/src/libs/API/parameters/SignInWithShortLivedAuthTokenParams.ts b/src/libs/API/parameters/SignInWithShortLivedAuthTokenParams.ts index fd6e287a5736..65f099a21632 100644 --- a/src/libs/API/parameters/SignInWithShortLivedAuthTokenParams.ts +++ b/src/libs/API/parameters/SignInWithShortLivedAuthTokenParams.ts @@ -13,11 +13,6 @@ type SignInWithShortLivedAuthTokenParams = { */ authMethod?: ValueOf; deviceInfo: string; - /** - * The device's current session `authToken`, sent only when the device is already authenticated. - * Omit when the device has no current session. - */ - currentAuthToken?: string; }; export default SignInWithShortLivedAuthTokenParams; diff --git a/src/libs/API/parameters/SignUpUserParams.ts b/src/libs/API/parameters/SignUpUserParams.ts index a854586628de..908467252b1e 100644 --- a/src/libs/API/parameters/SignUpUserParams.ts +++ b/src/libs/API/parameters/SignUpUserParams.ts @@ -1,11 +1,10 @@ import type Locale from '@src/types/onyx/Locale'; -import type MarketingAttribution from '@src/types/onyx/MarketingAttribution'; type SignUpUserParams = { email?: string; preferredLocale: Locale | null; hasSMSMarketingConsent?: boolean; deviceInfo: string; -} & MarketingAttribution; +}; export default SignUpUserParams; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index e3d830442bc5..ecd0b6232b10 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -1647,6 +1647,7 @@ const READ_COMMANDS = { OPEN_INITIAL_SETTINGS_PAGE: 'OpenInitialSettingsPage', OPEN_ENABLE_PAYMENTS_PAGE: 'OpenEnablePaymentsPage', BEGIN_SIGNIN: 'BeginSignIn', + SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN: 'SignInWithShortLivedAuthToken', SIGN_IN_WITH_SUPPORT_AUTH_TOKEN: 'SignInWithSupportAuthToken', GET_POLICY_CATEGORIES: 'GetPolicyCategories', OPEN_WORKSPACE: 'OpenWorkspace', @@ -1768,6 +1769,7 @@ type ReadCommandParameters = { [READ_COMMANDS.GET_INSIGHTS]: Parameters.GetInsightsParams; [READ_COMMANDS.GET_TRANSACTIONS_CONVERTED_AMOUNT]: Parameters.GetTransactionsConvertedAmountParams; [READ_COMMANDS.BEGIN_SIGNIN]: Parameters.BeginSignInParams; + [READ_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN]: Parameters.SignInWithShortLivedAuthTokenParams; [READ_COMMANDS.SIGN_IN_WITH_SUPPORT_AUTH_TOKEN]: Parameters.SignInWithSupportAuthTokenParams; [READ_COMMANDS.GET_POLICY_CATEGORIES]: Parameters.GetPolicyCategoriesParams; [READ_COMMANDS.OPEN_WORKSPACE]: Parameters.OpenWorkspaceParams; @@ -1862,7 +1864,6 @@ const SIDE_EFFECT_REQUEST_COMMANDS = { CREATE_DIGITAL_WALLET: 'CreateDigitalWallet', LOCK_ACCOUNT: 'LockAccount', SET_VACATION_DELEGATE: 'SetVacationDelegate', - SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN: 'SignInWithShortLivedAuthToken', CALCULATE_BILL_NEW_DOT: 'CalculateBillNewDot', ACCEPT_SPOTNANA_TERMS: 'AcceptSpotnanaTerms', @@ -1914,7 +1915,6 @@ type SideEffectRequestCommandParameters = { [SIDE_EFFECT_REQUEST_COMMANDS.CREATE_DIGITAL_WALLET]: Parameters.CreateDigitalWalletParams; [SIDE_EFFECT_REQUEST_COMMANDS.LOCK_ACCOUNT]: Parameters.LockAccountParams; [SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE]: Parameters.SetVacationDelegateParams; - [SIDE_EFFECT_REQUEST_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN]: Parameters.SignInWithShortLivedAuthTokenParams; [SIDE_EFFECT_REQUEST_COMMANDS.CALCULATE_BILL_NEW_DOT]: null; [SIDE_EFFECT_REQUEST_COMMANDS.ACCEPT_SPOTNANA_TERMS]: Parameters.AcceptSpotnanaTermsParams; [SIDE_EFFECT_REQUEST_COMMANDS.GET_SCIM_TOKEN]: Parameters.GetScimTokenParams; diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 29f3bdb82b79..2d8ec5e3f3c6 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -122,12 +122,7 @@ const COMPANY_CARD_BANK_ICON_NAMES = [ type CompanyCardBankIconName = TupleToUnion; type CompanyCardBankIcons = Record; -const CUSTOM_FEED_PREFIXES = [ - CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD, - CONST.COMPANY_CARD.FEED_BANK_NAME.VISA, - CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK, - CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX, -]; +const CUSTOM_FEED_PREFIXES = [CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD, CONST.COMPANY_CARD.FEED_BANK_NAME.VISA, CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX]; type CardConnectionStatusDisplay = { statusKey: TranslationPaths; @@ -154,7 +149,6 @@ type CardConnectionStatusDisplayParams = { const feedNamesMapping = { [CONST.COMPANY_CARD.FEED_BANK_NAME.CSV]: CONST.COMPANY_CARDS.CARD_TYPE_NAMES.CSV, - [CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK]: CONST.COMPANY_CARDS.CARD_TYPE_NAMES.MOCK_COMMERCIAL, [CONST.COMPANY_CARD.FEED_BANK_NAME.VISA]: CONST.COMPANY_CARDS.CARD_TYPE_NAMES.VISA, [CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD]: CONST.COMPANY_CARDS.CARD_TYPE_NAMES.MASTERCARD, [CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX]: CONST.COMPANY_CARDS.BANKS.AMEX, @@ -1047,7 +1041,6 @@ const ANZ_NZ_COMMERCIAL_FEED_DISPLAY_NAME = 'ANZ NZ'; const COMMERCIAL_FEED_DISPLAY_BASES = [ {base: ANZ_NZ_COMMERCIAL_FEED_BASE, displayName: ANZ_NZ_COMMERCIAL_FEED_DISPLAY_NAME, shouldHideOne: false}, - {base: CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK, displayName: getBankName(CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK), shouldHideOne: true}, {base: CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX, displayName: getBankName(CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX), shouldHideOne: true}, {base: CONST.COMPANY_CARD.FEED_BANK_NAME.VISA, displayName: getBankName(CONST.COMPANY_CARD.FEED_BANK_NAME.VISA), shouldHideOne: true}, {base: CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD, displayName: getBankName(CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD), shouldHideOne: true}, @@ -1618,13 +1611,7 @@ function isSmartLimitEnabled(cardsList: CardList) { return hasAssignedCardMatching(cardsList, (card) => card.nameValuePairs?.limitType === CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART); } -const CUSTOM_FEEDS = [ - CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD, - CONST.COMPANY_CARD.FEED_BANK_NAME.VISA, - CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK, - CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX, - CONST.COMPANY_CARD.FEED_BANK_NAME.CSV, -]; +const CUSTOM_FEEDS = [CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD, CONST.COMPANY_CARD.FEED_BANK_NAME.VISA, CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX, CONST.COMPANY_CARD.FEED_BANK_NAME.CSV]; function collectUsedCSVFeedSlotNumbersFromCompanyCards(companyCards: CompanyFeeds | undefined, csvPrefix: string): number[] { const numbers: number[] = []; diff --git a/src/libs/ExportOnyxState/masking.ts b/src/libs/ExportOnyxState/masking.ts index b1912a70125c..47e3d71a2f83 100644 --- a/src/libs/ExportOnyxState/masking.ts +++ b/src/libs/ExportOnyxState/masking.ts @@ -369,7 +369,6 @@ const safeOnyxKeys = new Set([ ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED, ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING, ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL, - ONYXKEYS.TABLE_COLUMN_WIDTHS, ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH, ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, ONYXKEYS.TRAVEL_BILLING_STATEMENT, @@ -460,7 +459,6 @@ const onyxKeysToMaskFragileData = new Set([ ONYXKEYS.LAST_ROUTE, ONYXKEYS.LAST_VISITED_PATH, ONYXKEYS.LOGINS, - ONYXKEYS.MARKETING_ATTRIBUTION, ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS, ONYXKEYS.NEW_GROUP_CHAT_DRAFT, ONYXKEYS.NVP_BILLING_FUND_ID, diff --git a/src/libs/Middleware/SaveResponseInOnyx.ts b/src/libs/Middleware/SaveResponseInOnyx.ts index a8798425cd7f..1a423540de20 100644 --- a/src/libs/Middleware/SaveResponseInOnyx.ts +++ b/src/libs/Middleware/SaveResponseInOnyx.ts @@ -22,7 +22,7 @@ const requestsToIgnoreLastUpdateID = new Set([ ]); // A request belongs here when its successData/finallyData is what unblocks authentication, because parking that leaves the client unable to reauthenticate. -const requestsToApplyWithoutAdvancingLastUpdateID = new Set([SIDE_EFFECT_REQUEST_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN, READ_COMMANDS.SIGN_IN_WITH_SUPPORT_AUTH_TOKEN]); +const requestsToApplyWithoutAdvancingLastUpdateID = new Set([READ_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN, READ_COMMANDS.SIGN_IN_WITH_SUPPORT_AUTH_TOKEN]); const SaveResponseInOnyx: Middleware = (requestResponse: Promise | void>, request: OnyxRequest) => requestResponse.then((response = {}) => { diff --git a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx index d4b35b30d570..0250e3d1e4b9 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx @@ -169,10 +169,8 @@ function AuthScreensInitHandler() { const isLoggingInAsNewUser = !!session?.email && SessionUtils.isLoggingInAsNewUser(currentUrl, session.email); // Sign out the current user if we're transitioning with a different user const isTransitioning = currentUrl.includes(ROUTES.TRANSITION_BETWEEN_APPS); - const isSupportalTransition = getSearchParamFromUrl(currentUrl, 'authTokenType') === CONST.AUTH_TOKEN_TYPES.SUPPORT; - - // A non-supportal account switch waits for the user to confirm in LogOutPreviousUserPage instead of signing out here. - if (isLoggingInAsNewUser && isTransitioning && isSupportalTransition) { + const isSupportalTransition = currentUrl.includes('authTokenType=support'); + if (isLoggingInAsNewUser && isTransitioning) { Log.info('[AuthScreensInitHandler] Signing out for a transition to another user', false, { isLinkNamingDelegator: SessionUtils.isLoggingInAsDelegate(currentUrl), isDelegateSession: Session.isDelegateSession(session), diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 29ef730b798e..3814b475e959 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2666,7 +2666,6 @@ type MoneyRequestNavigatorParamList = { upgradeBackTo?: Routes; upgradePath?: ValueOf; shouldSubmitExpense?: boolean; - shouldReturnToConfirmation?: boolean; }; [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_DESTINATION]: { action: IOUAction; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 02ee61e2650e..bf4fea45cc33 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -2437,7 +2437,7 @@ function getValidOptions( } const policy = policiesCollection?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; - if (!doesReportMatchSearchTerms(report, searchTerms, translate)) { + if (!doesReportMatchSearchTerms(report, searchTerms)) { return false; } @@ -2570,7 +2570,7 @@ function getValidOptions( recentReportOptions = recentReportOptions.filter((report) => !report.reportID || !reportIDsToExclude.has(report.reportID)); } } else if (recentAttendees && recentAttendees?.length > 0) { - recentReportOptions = filterReports(recentAttendees as SearchOptionData[], searchTerms, translate) as Array>; + recentReportOptions = filterReports(recentAttendees as SearchOptionData[], searchTerms) as Array>; // Only cap the recent attendees when there's no active search. During a search we surface every match, since // all recent attendees are excluded from "Contacts" and capped-off matches would otherwise vanish from the list. @@ -3067,7 +3067,7 @@ function filteredPersonalDetailsOfRecentReports(rece /** * Filters options based on the search input value */ -function filterReports(reports: SearchOptionData[], searchTerms: string[], translate: LocalizedTranslate): SearchOptionData[] { +function filterReports(reports: SearchOptionData[], searchTerms: string[]): SearchOptionData[] { const normalizedSearchTerms = searchTerms.map((term) => StringUtils.normalizeForMatch(term)); // We search eventually for multiple whitespace separated search terms. // We start with the search term at the end, and then narrow down those filtered search results with the next search term. @@ -3096,12 +3096,6 @@ function filterReports(reports: SearchOptionData[], searchTerms: string[], trans } } - // Let the current user find their self DM by the localized "You"/"Me", like their own contact row - if (item.isSelfDM) { - values.push(StringUtils.normalizeForMatch(translate('common.you'))); - values.push(StringUtils.normalizeForMatch(translate('common.me'))); - } - return uniqFast(values); }), // We start from all unfiltered reports: @@ -3116,7 +3110,7 @@ function filterReports(reports: SearchOptionData[], searchTerms: string[], trans * canonical matcher handles apostrophes, hyphens, zero-width characters, diacritics, and emails searched without * their dots, and only runs when the cheap check misses. Narrowing the cheap check silently drops matches. */ -function doesReportMatchSearchTerms(report: SearchOption, searchTerms: string[], translate: LocalizedTranslate): boolean { +function doesReportMatchSearchTerms(report: SearchOption, searchTerms: string[]): boolean { const normalizeSearchText = (value: string) => deburr(StringUtils.normalizeForMatch(value).toLocaleLowerCase()); const normalizedSearchTerms = searchTerms.map(normalizeSearchText); let searchText = `${report.text ?? ''}${report.login ?? ''}`; @@ -3130,12 +3124,9 @@ function doesReportMatchSearchTerms(report: SearchOption, searchTerms: s const participantsSearchText = report.participantsList?.map((participant) => [participant.displayName, participant.login].filter(Boolean).join(' ')).join(' ') ?? ''; searchText += participantsSearchText; } - if (report.isSelfDM) { - searchText += `${translate('common.you')} ${translate('common.me')}`; - } searchText = normalizeSearchText(searchText); - return normalizedSearchTerms.every((term) => searchText.includes(term)) || filterReports([report], normalizedSearchTerms, translate).length > 0; + return normalizedSearchTerms.every((term) => searchText.includes(term)) || filterReports([report], normalizedSearchTerms).length > 0; } function filterWorkspaceChats(reports: SearchOptionData[], searchTerms: string[]): SearchOptionData[] { @@ -3220,8 +3211,8 @@ function filterUserToInvite( }); } -function filterSelfDMChat(report: SearchOptionData, searchTerms: string[], translate: LocalizedTranslate): SearchOptionData | undefined { - return filterReports([report], searchTerms, translate).at(0); +function filterSelfDMChat(report: SearchOptionData, searchTerms: string[]): SearchOptionData | undefined { + return filterReports([report], searchTerms).at(0); } function filterOptions( @@ -3243,7 +3234,7 @@ function filterOptions( const searchValue = parsedPhoneNumber.possible && parsedPhoneNumber.number?.e164 ? parsedPhoneNumber.number.e164 : trimmedSearchInput.toLowerCase(); const searchTerms = searchValue ? searchValue.split(' ') : []; - const recentReports = filterReports(options.recentReports, searchTerms, translate); + const recentReports = filterReports(options.recentReports, searchTerms); const personalDetails = filterPersonalDetails(options.personalDetails, searchTerms, currentUserAccountID, translate); const currentUserOption = filterCurrentUserOption(options.currentUserOption, searchTerms, translate); const userToInvite = filterUserToInvite( @@ -3266,7 +3257,7 @@ function filterOptions( ); const workspaceChats = filterWorkspaceChats(options.workspaceChats ?? [], searchTerms); - const selfDMChat = options.selfDMChat ? filterSelfDMChat(options.selfDMChat, searchTerms, translate) : undefined; + const selfDMChat = options.selfDMChat ? filterSelfDMChat(options.selfDMChat, searchTerms) : undefined; return { personalDetails, diff --git a/src/libs/PolicyUtils/index.ts b/src/libs/PolicyUtils/index.ts index db899b438fe0..f90f8efeebec 100644 --- a/src/libs/PolicyUtils/index.ts +++ b/src/libs/PolicyUtils/index.ts @@ -1355,14 +1355,6 @@ function getDefaultApprover(policy: OnyxEntry): string { return policy?.approver || policy?.owner || ''; } -/** - * Whether an approver email points at someone who is no longer on the workspace. The owner always counts as a member, - * and HR advanced (manager) mode is excluded, because its final approver doesn't have to be a workspace member. - */ -function isNonMemberApprover(policy: OnyxEntry, approverEmail: string | undefined): boolean { - return !!approverEmail && !isPolicyMember(policy, approverEmail) && !getHRAdvancedModeFinalApprover(policy); -} - /** * Whether the policy has at least one custom approval workflow. A workflow is considered custom when either: * - the default workflow was modified by changing its first approver or adding an "Approves to" user, or @@ -2533,7 +2525,6 @@ export { getIntegrationLastSuccessfulDate, getCurrentConnectionName, getDefaultApprover, - isNonMemberApprover, hasCustomApprovalWorkflow, getApprovalWorkflow, getReimburserAccountID, diff --git a/src/libs/PolicyUtils/permissions.ts b/src/libs/PolicyUtils/permissions.ts index 4acbc414ab30..5c900439239f 100644 --- a/src/libs/PolicyUtils/permissions.ts +++ b/src/libs/PolicyUtils/permissions.ts @@ -23,14 +23,6 @@ type PolicyFeatureAccess = ValueOf; const isPolicyAdmin = (policy: OnyxInputOrEntry, login?: string, shouldCheckGlobalPolicyRole = true): boolean => getPolicyRole(policy, login, shouldCheckGlobalPolicyRole) === CONST.POLICY.ROLE.ADMIN; -/** - * Checks if the current user is an auditor of the policy. - * - * When `login` belongs to somebody other than the current user, pass `shouldCheckGlobalPolicyRole = false` (see `isPolicyAdmin`). - */ -const isPolicyAuditor = (policy: OnyxInputOrEntry, login?: string, shouldCheckGlobalPolicyRole = true): boolean => - getPolicyRole(policy, login, shouldCheckGlobalPolicyRole) === CONST.POLICY.ROLE.AUDITOR; - /** * Checks if the given account is the owner (creator) of the policy. * @@ -44,9 +36,8 @@ const isPolicyOwner = (policy: OnyxInputOrEntry>, * * Only a member who was invited to the chat can be removed from it. Everybody else is there by virtue of the * workspace configuration, so their membership is governed by that configuration and not by this screen — see the - * expense chat rules in `contributingGuides/philosophies/SECURITY.md`. That covers admins, the policy owner, - * approvers, who are auto-added to the chats of everybody who submits to them, and auditors, who are default members - * of every workspace chat. + * expense chat rules in `contributingGuides/philosophies/SECURITY.md`. That covers admins, the policy owner and + * approvers, who are auto-added to the chats of everybody who submits to them. * * Fails closed on a missing `login`: without one we cannot resolve the member's role, and offering removal for a * member whose role is unknown could remove a workspace admin. Both the member list and the member details page must @@ -65,7 +56,7 @@ const isPolicyOwner = (policy: OnyxInputOrEntry>, * protection, so every caller must state it even when it is `undefined`. */ const isRoomMemberProtectedByPolicyRole = (policy: OnyxInputOrEntry, login: string | undefined, accountID: number | undefined): boolean => - isPolicyOwner(policy, accountID) || !login || isPolicyAdmin(policy, login, false) || isPolicyAuditor(policy, login, false) || isPolicyApprover(policy, login); + isPolicyOwner(policy, accountID) || !login || isPolicyAdmin(policy, login, false) || isPolicyApprover(policy, login); const ALL_POLICY_FEATURES = Object.values(CONST.POLICY.POLICY_FEATURE); @@ -294,6 +285,12 @@ const isPolicyUser = (policy: OnyxInputOrEntry, currentUserLogin?: strin */ const isPolicyGuest = (policy: OnyxInputOrEntry, currentUserLogin?: string): boolean => getPolicyRole(policy, currentUserLogin) === CONST.POLICY.ROLE.GUEST; +/** + * Checks if the current user is an auditor of the policy + */ +const isPolicyAuditor = (policy: OnyxInputOrEntry, currentUserLogin?: string): boolean => + (policy?.role ?? (currentUserLogin && policy?.employeeList?.[currentUserLogin]?.role)) === CONST.POLICY.ROLE.AUDITOR; + /** * Checks if the current user is a workspace or card admin of the policy and the policy has a card product enabled. */ diff --git a/src/libs/ReceiptStorage/index.native.ts b/src/libs/ReceiptStorage/index.native.ts index 51234e482575..f735b5bebfd4 100644 --- a/src/libs/ReceiptStorage/index.native.ts +++ b/src/libs/ReceiptStorage/index.native.ts @@ -1,8 +1,6 @@ -import {checkFileExistsWithReason} from '@libs/fileDownload/checkFileExists'; import {cleanFileName, isLocalFile} from '@libs/fileDownload/FileUtils'; import fileURIToPath from '@libs/fileURIToPath'; import getReceiptsUploadFolderPath from '@libs/getReceiptsUploadFolderPath'; -import Log from '@libs/Log'; import {rand64} from '@libs/NumberUtils'; import RNFS from 'react-native-fs'; @@ -10,31 +8,11 @@ import RNFS from 'react-native-fs'; import type ReceiptStorage from './types'; import {toDurableName} from './durableFolder'; -import {claimForRead} from './receiptUpgrades'; // A durable name is the bare filename inside the receipts folder. Never store a full path: iOS moves // the app data container on most upgrades, so an absolute path stored before the upgrade names a // directory the device no longer has, even though iOS carried the file itself across. -const SWEEP_IDLE_TIMEOUT_MS = 10000; - -// Specific enough that no adopted file carries them: `adopt` keeps the picked file's extension, so a plain -// `.backup` attachment would otherwise look like a swap leftover to the sweep. -const STAGED_SUFFIX = '.receipt-swap-staged'; -const BACKUP_SUFFIX = '.receipt-swap-backup'; - -// RNFS `readDir` on the receipts folder costs ~1ms per file on Android and blocks every other native call -// while it runs, so the sweep lists this folder of pending swaps instead. -const PENDING_SWAPS_FOLDER = '.pending-receipt-swaps'; - -const swapsInFlight = new Map>(); - -const committedSwaps = new Map>(); - -async function waitForCommittedSwap(target: string) { - await committedSwaps.get(target)?.catch(() => {}); -} - async function verify(dir: string, name: string): Promise { if (!name || !(await RNFS.exists(`${dir}/${name}`))) { throw new Error('[ReceiptStorage] file is not in durable storage'); @@ -72,194 +50,6 @@ const adopt: ReceiptStorage['adopt'] = async (uriOrPath, fileName) => { return verify(dir, uniqueName); }; -const discard: ReceiptStorage['discard'] = async (uriOrPath) => { - const path = fileURIToPath(uriOrPath); - if (!(await RNFS.exists(path))) { - return; - } - await RNFS.unlink(path); -}; - -async function discardLeftover(path: string): Promise { - try { - await discard(path); - return true; - } catch (error) { - Log.warn('[ReceiptStorage] could not delete a leftover receipt copy', {path, error: error instanceof Error ? error.message : String(error)}); - return false; - } -} - -async function restoreInterruptedSwap(target: string, {shouldWaitForRunningSwap = true} = {}): Promise { - if (await RNFS.exists(target)) { - return true; - } - - if (shouldWaitForRunningSwap && swapsInFlight.has(target)) { - await swapsInFlight.get(target)?.catch(() => {}); - return RNFS.exists(target); - } - - const backupPath = `${target}${BACKUP_SUFFIX}`; - if (!(await RNFS.exists(backupPath))) { - return RNFS.exists(target); - } - - try { - await RNFS.moveFile(backupPath, target); - } catch (error) { - Log.warn('[ReceiptStorage] could not put back a receipt left by an interrupted swap', {error: error instanceof Error ? error.message : String(error)}); - return RNFS.exists(target); - } - - Log.info('[ReceiptStorage] put back a receipt left by an interrupted swap'); - return true; -} - -async function swapFiles(dir: string, durableName: string, target: string, uriOrPath: string, shouldAbort?: () => boolean): Promise { - // Neither platform can move a file onto one that already exists. NSFileManager refuses and Android's - // `renameTo` is unreliable, so stage the new bytes beside the receipt, then swap them in with two - // renames inside the directory. The original keeps a second name until the swap succeeds. - const stagedPath = `${target}${STAGED_SUFFIX}`; - const backupPath = `${target}${BACKUP_SUFFIX}`; - // An attempt that died mid-swap would leave these occupied, and iOS refuses an occupied destination. - await discard(stagedPath); - await discard(backupPath); - await RNFS.moveFile(fileURIToPath(uriOrPath), stagedPath); - - if (shouldAbort?.()) { - await discardLeftover(stagedPath); - throw new Error('[ReceiptStorage] an upload claimed the receipt, so it was left as it stands'); - } - - let releaseCommitted: () => void = () => {}; - committedSwaps.set( - target, - new Promise((resolve) => { - releaseCommitted = resolve; - }), - ); - - try { - await RNFS.moveFile(target, backupPath); - await RNFS.moveFile(stagedPath, target); - } catch (error) { - await discardLeftover(stagedPath); - - if (await RNFS.exists(target)) { - throw error; - } - - try { - await RNFS.moveFile(backupPath, target); - } catch (restoreError) { - const reason = restoreError instanceof Error ? restoreError.message : String(restoreError); - throw new Error(`[ReceiptStorage] could not restore the receipt, it is left at ${backupPath}: ${reason}`); - } - - throw error; - } finally { - committedSwaps.delete(target); - releaseCommitted(); - } - - await discardLeftover(backupPath); - - return verify(dir, durableName); -} - -async function markSwapStarted(dir: string, durableName: string) { - const pendingSwapsDir = `${dir}/${PENDING_SWAPS_FOLDER}`; - await RNFS.mkdir(pendingSwapsDir); - await RNFS.writeFile(`${pendingSwapsDir}/${durableName}`, '', 'utf8'); -} - -async function markSwapFinished(dir: string, durableName: string, target: string) { - try { - const [isInPlace, hasStaged, hasBackup] = await Promise.all([RNFS.exists(target), RNFS.exists(`${target}${STAGED_SUFFIX}`), RNFS.exists(`${target}${BACKUP_SUFFIX}`)]); - if (!isInPlace || hasStaged || hasBackup) { - return; - } - await discard(`${dir}/${PENDING_SWAPS_FOLDER}/${durableName}`); - } catch (error) { - Log.warn('[ReceiptStorage] could not clear a finished swap from the pending swaps', {error: error instanceof Error ? error.message : String(error)}); - } -} - -async function swapIntoPlace(dir: string, durableName: string, target: string, uriOrPath: string, shouldAbort?: () => boolean): Promise { - await markSwapStarted(dir, durableName); - try { - return await swapFiles(dir, durableName, target, uriOrPath, shouldAbort); - } finally { - await markSwapFinished(dir, durableName, target); - } -} - -async function sweepInterruptedSwaps(dir: string) { - const pendingSwapsDir = `${dir}/${PENDING_SWAPS_FOLDER}`; - if (!(await RNFS.exists(pendingSwapsDir))) { - return; - } - - const names = (await RNFS.readDir(pendingSwapsDir)).map((entry) => entry.name); - - await Promise.all( - names.map(async (name) => { - const target = `${dir}/${name}`; - const isRestored = await restoreInterruptedSwap(target, {shouldWaitForRunningSwap: false}); - const isStagedGone = await discardLeftover(`${target}${STAGED_SUFFIX}`); - - if (!isRestored) { - if (isStagedGone && !(await RNFS.exists(`${target}${BACKUP_SUFFIX}`))) { - await discardLeftover(`${pendingSwapsDir}/${name}`); - } - return; - } - const isBackupGone = await discardLeftover(`${target}${BACKUP_SUFFIX}`); - - if (isStagedGone && isBackupGone) { - await discardLeftover(`${pendingSwapsDir}/${name}`); - } - }), - ); -} - -let leftoversSwept: Promise | undefined; - -function whenLeftoversSwept(dir: string): Promise { - leftoversSwept ??= sweepInterruptedSwaps(dir).catch((error: unknown) => { - Log.warn('[ReceiptStorage] could not sweep leftover receipt copies', {error: error instanceof Error ? error.message : String(error)}); - }); - return leftoversSwept; -} - -const overwrite: ReceiptStorage['overwrite'] = async (durableName, uriOrPath, shouldAbort) => { - const dir = getReceiptsUploadFolderPath(); - if (!dir) { - throw new Error('[ReceiptStorage] no receipts folder on this platform'); - } - - const target = `${dir}/${durableName}`; - - if (swapsInFlight.has(target)) { - throw new Error('[ReceiptStorage] a swap is already running for this receipt'); - } - - const swap = (async () => { - await whenLeftoversSwept(dir); - await restoreInterruptedSwap(target, {shouldWaitForRunningSwap: false}); - await verify(dir, durableName); - return swapIntoPlace(dir, durableName, target, uriOrPath, shouldAbort); - })(); - swapsInFlight.set(target, swap); - - try { - return await swap; - } finally { - swapsInFlight.delete(target); - } -}; - const toLocalUri: ReceiptStorage['toLocalUri'] = (durableName) => `file://${getReceiptsUploadFolderPath()}/${durableName}`; const retain: ReceiptStorage['retain'] = () => {}; @@ -275,81 +65,6 @@ const resolve: ReceiptStorage['resolve'] = (source) => { return durableName ? toLocalUri(durableName) : source; }; -const settle: ReceiptStorage['settle'] = async (durableName) => { - if (!durableName) { - return; - } - - claimForRead(durableName); - - const dir = getReceiptsUploadFolderPath(); - if (!dir) { - return; - } - - await waitForCommittedSwap(`${dir}/${durableName}`); -}; - -const locate: ReceiptStorage['locate'] = async (source) => { - const uri = resolve(source); - if (!uri) { - return undefined; - } - - if (isLocalFile(uri)) { - const dir = getReceiptsUploadFolderPath(); - const target = fileURIToPath(uri); - - if (dir) { - whenLeftoversSwept(dir).catch(() => {}); - } - - await settle(target.split('/').pop() ?? ''); - } - - if ((await checkFileExistsWithReason(uri)).exists) { - return uri; - } - - if (!isLocalFile(uri) || !(await restoreInterruptedSwap(fileURIToPath(uri)))) { - return undefined; - } - - return uri; -}; - -const recheckAfterSwap: ReceiptStorage['recheckAfterSwap'] = async (source) => { - const uri = resolve(source); - if (!uri || !isLocalFile(uri)) { - return false; - } - - const target = fileURIToPath(uri); - try { - await swapsInFlight.get(target)?.catch(() => {}); - return await restoreInterruptedSwap(target); - } catch (error) { - Log.warn('[ReceiptStorage] could not recheck a receipt after a failed read', {error: error instanceof Error ? error.message : String(error)}); - return false; - } -}; - -const sweepLeftovers: ReceiptStorage['sweepLeftovers'] = async () => { - try { - const dir = getReceiptsUploadFolderPath(); - if (!dir) { - return; - } - - await new Promise((onIdle) => { - requestIdleCallback(() => onIdle(), {timeout: SWEEP_IDLE_TIMEOUT_MS}); - }); - await whenLeftoversSwept(dir); - } catch (error) { - Log.warn('[ReceiptStorage] could not sweep leftover receipt copies at startup', {error: error instanceof Error ? error.message : String(error)}); - } -}; - -const receiptStorage: ReceiptStorage = {adopt, overwrite, discard, locate, settle, recheckAfterSwap, toLocalUri, retain, resolve, sweepLeftovers}; +const receiptStorage: ReceiptStorage = {adopt, toLocalUri, retain, resolve}; export default receiptStorage; diff --git a/src/libs/ReceiptStorage/index.ts b/src/libs/ReceiptStorage/index.ts index 943d672b93a1..8fa98e6d5d15 100644 --- a/src/libs/ReceiptStorage/index.ts +++ b/src/libs/ReceiptStorage/index.ts @@ -34,15 +34,9 @@ const resolve: ReceiptStorage['resolve'] = (source) => { const receiptStorage: ReceiptStorage = { adopt: (uriOrPath) => Promise.resolve(uriOrPath), - overwrite: (durableName) => Promise.resolve(durableName), - discard: () => Promise.resolve(), - locate: (source) => Promise.resolve(resolve(source)), - settle: () => Promise.resolve(), - recheckAfterSwap: () => Promise.resolve(false), toLocalUri: (durableName) => durableName, retain, resolve, - sweepLeftovers: () => Promise.resolve(), }; export default receiptStorage; diff --git a/src/libs/ReceiptStorage/receiptUpgrades.ts b/src/libs/ReceiptStorage/receiptUpgrades.ts deleted file mode 100644 index f93b8fef6aac..000000000000 --- a/src/libs/ReceiptStorage/receiptUpgrades.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Tracks in-flight receipt upgrades and how many times each receipt has been replaced, so views can - * re-decode a file whose bytes changed under an unchanged path and uploads can claim a receipt. - */ -import Log from '@libs/Log'; - -const MAX_TRACKED_UPGRADE_COUNTS = 50; - -type PendingUpgrade = { - isClaimed: boolean; -}; - -const upgradesInFlight = new Map(); - -const upgradeCounts = new Map(); - -const listeners = new Set<() => void>(); - -function notify() { - for (const listener of listeners) { - listener(); - } -} - -function start(durableName: string) { - if (upgradesInFlight.has(durableName)) { - return; - } - - upgradesInFlight.set(durableName, {isClaimed: false}); - notify(); -} - -function finish(durableName: string) { - const upgrade = upgradesInFlight.get(durableName); - if (!upgrade) { - return; - } - - upgradesInFlight.delete(durableName); - notify(); -} - -function recordUpgrade(durableName: string) { - upgradeCounts.set(durableName, (upgradeCounts.get(durableName) ?? 0) + 1); - - while (upgradeCounts.size > MAX_TRACKED_UPGRADE_COUNTS) { - const oldest = upgradeCounts.keys().next().value; - if (oldest === undefined) { - break; - } - upgradeCounts.delete(oldest); - } - - notify(); -} - -function claimForRead(durableName: string) { - const upgrade = upgradesInFlight.get(durableName); - if (!upgrade || upgrade.isClaimed) { - return; - } - - upgrade.isClaimed = true; - Log.info('[ReceiptUpgrades] a reader claimed the receipt, so the upgrade will keep the snapshot', false, {durableName}); -} - -function getUpgradeCount(durableName: string): number { - return upgradeCounts.get(durableName) ?? 0; -} - -function isClaimedForRead(durableName: string): boolean { - return upgradesInFlight.get(durableName)?.isClaimed ?? false; -} - -function subscribe(listener: () => void): () => void { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; -} - -export {start, finish, recordUpgrade, claimForRead, isClaimedForRead, getUpgradeCount, subscribe}; diff --git a/src/libs/ReceiptStorage/types.ts b/src/libs/ReceiptStorage/types.ts index b2e27a910951..affc64c59a53 100644 --- a/src/libs/ReceiptStorage/types.ts +++ b/src/libs/ReceiptStorage/types.ts @@ -5,24 +5,6 @@ type ReceiptStorage = { /** Moves a file into the receipts folder and returns its durable name. Rejects when the file did not land. */ adopt: (uriOrPath: string, fileName?: string) => Promise; - /** - * Replaces a receipt's bytes under the same durable name, putting the original back if the swap fails. - * Gives up before the first rename when `shouldAbort` returns true, e.g. because an upload claimed the receipt. - */ - overwrite: (durableName: string, uriOrPath: string, shouldAbort?: () => boolean) => Promise; - - /** Deletes a file if it exists. */ - discard: (uriOrPath: string) => Promise; - - /** Resolves a stored source to a file that exists, waiting for a committed swap and restoring an interrupted one. */ - locate: (source: ReceiptSource | null | undefined) => Promise; - - /** Claims a receipt for reading, so an upgrade still preparing keeps the snapshot, and waits for a swap already renaming. */ - settle: (durableName: string) => Promise; - - /** After a failed read, waits for a running swap and restores an interrupted one. Resolves true when the file is back. */ - recheckAfterSwap: (source: ReceiptSource | null | undefined) => Promise; - /** Valid for this launch only, so never store the result. */ toLocalUri: (durableName: string) => string; @@ -34,9 +16,6 @@ type ReceiptStorage = { /** Re-roots a stored source onto the current folder. A remote source passes through unchanged. */ resolve: (source: ReceiptSource | null | undefined) => string | undefined; - - /** Deletes copies an interrupted swap left in the receipts folder, once the app is idle after startup. */ - sweepLeftovers: () => Promise; }; export default ReceiptStorage; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 5ce38b0095b8..85dae5f5a0bb 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -1297,27 +1297,6 @@ function isResolvedConciergeDescriptionOptions(reportAction: OnyxEntry): boolean { - return isReportPreviewAction(reportAction) && isDeletedAction(reportAction) && !!reportAction?.errors?.[CONST.IOU.PAY_FAILURE_PREVIEW_ERROR_KEY]; -} - -/** - * The errors a consumer should act on. While the expense report still exists a payment failure belongs on the pay - * action inside it, so drop the copy mirrored onto the chat's preview. - */ -function getVisibleReportActionErrors(reportAction: OnyxEntry): ReportAction['errors'] { - const errors = reportAction?.errors ?? {}; - if (!isReportPreviewAction(reportAction) || isDeletedAction(reportAction) || !(CONST.IOU.PAY_FAILURE_PREVIEW_ERROR_KEY in errors)) { - return errors; - } - const {[CONST.IOU.PAY_FAILURE_PREVIEW_ERROR_KEY]: payFailure, ...rest} = errors; - return rest; -} - /** * Checks if a reportAction is fit for display, meaning that it's not deprecated, is of a valid * and supported type, it's not deleted and also not closed. @@ -1419,10 +1398,6 @@ function shouldReportActionBeVisible( return false; } - if (isDeletedReportPreviewWithError(reportAction)) { - return true; - } - // All other actions are displayed except thread parents, deleted, or non-pending actions return !!reportAction.pendingAction || !isDeletedAction(reportAction) || isDeletedParentAction(reportAction) || isReversedTransaction(reportAction); } @@ -1499,7 +1474,7 @@ function isReportActionVisibleAsLastAction( return false; } - if (Object.keys(getVisibleReportActionErrors(reportAction) ?? {}).length > 0) { + if (Object.keys(reportAction.errors ?? {}).length > 0) { return false; } @@ -5475,8 +5450,6 @@ export { isCurrentActionUnread, isDeletedAction, isDeletedParentAction, - isDeletedReportPreviewWithError, - getVisibleReportActionErrors, isMemberChangeAction, isLeavePolicyAction, isExportIntegrationAction, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 3285bf3a98df..e24b00cada2f 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -211,8 +211,6 @@ import { isCurrentActionUnread, isDeletedAction, isDeletedParentAction, - isDeletedReportPreviewWithError, - getVisibleReportActionErrors, isDynamicExternalWorkflowApproveFailedAction, isDynamicExternalWorkflowSubmitFailedAction, isExportIntegrationAction, @@ -253,7 +251,6 @@ import { getCardName, getCategory, getConvertedAmount, - getConvertedTaxAmount, getCurrency, getDescription, getDisplayTransactionWithoutInvalidCommuterExclusion, @@ -288,12 +285,10 @@ import { hasReceipt as hasReceiptTransactionUtils, hasViolation, hasWarningTypeViolation, - isCorporateCardTransaction, isDeletedTransaction, isDemoTransaction, isDistanceRequest, isExpenseValueUnsettled, - isExpensifyCardTransaction, isFailedScanAmountPlaceholder, isFetchingWaypointsFromServer, isManagedCardTransaction, @@ -845,7 +840,6 @@ type TransactionDetails = { odometerStart?: number; odometerEnd?: number; convertedAmount: number; - convertedTaxAmount?: number; gpsCoordinates?: string; }; @@ -3632,10 +3626,6 @@ function shouldCurrentUserSubmitReport(iouReport: OnyxEntry, chatReport: } function canDeleteCardTransaction(transaction: OnyxEntry, policy: OnyxEntry, cardList: OnyxEntry): boolean { - if (isExpensifyCardTransaction(transaction)) { - return false; - } - const isCardTransaction = isManagedCardTransaction(transaction); if (!isCardTransaction) { return true; @@ -3678,18 +3668,6 @@ function canDeleteMoneyRequestReport( return true; } - const hasExpensifyCardTransaction = reportTransactions.some(isExpensifyCardTransaction); - const hasRestrictedCorporateCardTransaction = reportTransactions.some(isCorporateCardTransaction); - // Expensify Card transactions cannot be deleted or unreported, including by workspace admins. - if (isReportLevelDelete && hasExpensifyCardTransaction) { - return false; - } - - // Admins can delete reports containing third-party card expenses because those expenses become unreported. - if (isReportLevelDelete && !isReportPolicyAdmin && hasRestrictedCorporateCardTransaction) { - return false; - } - const isUnreported = isSelfDM(report) || transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; const canCardTransactionBeDeleted = canDeleteCardTransaction(transaction, policy, cardList); @@ -3698,6 +3676,7 @@ function canDeleteMoneyRequestReport( } // Admins can delete a draft report even when they are not its submitter, but not its individual expenses. + // Card liability does not apply here: deleting a draft report leaves its expenses unreported rather than deleting them. const isDraft = report?.statusNum === CONST.REPORT.STATUS_NUM.OPEN && report?.stateNum === CONST.REPORT.STATE_NUM.OPEN; if (isDraft && isReportPolicyAdmin && isReportLevelDelete) { return true; @@ -5665,7 +5644,6 @@ function getTransactionDetails( originalAmount: getOriginalAmount(transaction), originalCurrency: getOriginalCurrency(transaction), convertedAmount: getConvertedAmount(transaction, isFromExpenseReport, transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID, allowNegativeAmount, disableOppositeConversion), - convertedTaxAmount: getConvertedTaxAmount(transaction, isFromExpenseReport), postedDate: getFormattedPostedDate(transaction), transactionID: transaction.transactionID, ...(isDistanceRequest(transaction) && {distance: transaction.comment?.customUnit?.quantity ?? undefined}), @@ -10797,15 +10775,13 @@ function getAllReportActionsErrorsAndReportActionThatRequiresAttention( isReportArchived = false, reports?: OnyxCollection, ): ReportErrorsAndReportActionThatRequiresAttention { - // Keep a preview errored because its report was deleted. It still has to mark the chat as needing attention. - const reportActionsArray = Object.values(reportActions ?? {}).filter((action) => !isDeletedAction(action) || isDeletedReportPreviewWithError(action)); + const reportActionsArray = Object.values(reportActions ?? {}).filter((action) => !isDeletedAction(action)); const reportActionErrors: ErrorFields = {}; let reportAction: OnyxEntry; for (const action of reportActionsArray) { - const actionErrors = getVisibleReportActionErrors(action); - if (action && !isEmptyValueObject(actionErrors)) { - Object.assign(reportActionErrors, actionErrors); + if (action && !isEmptyValueObject(action.errors)) { + Object.assign(reportActionErrors, action.errors); if (!reportAction) { reportAction = action; diff --git a/src/libs/SessionUtils.ts b/src/libs/SessionUtils.ts index 16470257cf32..b90b19acbbd1 100644 --- a/src/libs/SessionUtils.ts +++ b/src/libs/SessionUtils.ts @@ -3,8 +3,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import Onyx from 'react-native-onyx'; -import StringUtils from './StringUtils'; - const NEW_PARTNER_USER_ID_PREFIX = 'expensify.cash-'; /** @@ -13,7 +11,7 @@ const NEW_PARTNER_USER_ID_PREFIX = 'expensify.cash-'; function isLoggingInAsNewUser(transitionURL?: string, sessionEmail?: string): boolean { // The OldDot mobile app does not URL encode the parameters, but OldDot web // does. We don't want to deploy OldDot mobile again, so as a work around we - // fall back from URLSearchParams to a regex-based lookup if the email doesn't match. + // compare the session email to both the decoded and raw email from the transition link. const params = new URLSearchParams(transitionURL); const paramsEmail = params.get('email'); const delegatorEmail = params.get('delegatorEmail'); @@ -25,14 +23,18 @@ function isLoggingInAsNewUser(transitionURL?: string, sessionEmail?: string): bo return false; } - // If URLSearchParams didn't find it (e.g. transitionURL is a full URL which - // mangles the first query-param key), fall back to regex - const linkedEmail = getEmailFromTransitionURL(transitionURL) ?? null; + // If they do not match it might be due to encoding, so check the raw value + // Capture the un-encoded text in the email param + const emailParamRegex = /[?&]email=([^&]*)/g; + const matches = emailParamRegex.exec(transitionURL ?? ''); + const linkedEmail = matches?.[1] ?? null; if (linkedEmail === sessionEmail) { return false; } + // If URLSearchParams didn't find it (e.g. transitionURL is a full URL which + // mangles the first query-param key), fall back to regex const linkedDelegatorEmail = getDelegatorEmailFromURL(transitionURL) ?? null; return linkedEmail !== sessionEmail && linkedDelegatorEmail !== sessionEmail; @@ -64,38 +66,6 @@ function getDelegatorEmailFromURL(url?: string): string | undefined { return delegatorMatches?.[1]; } -/** - * Looks for *email* param in given URL using regex - */ -function getEmailFromTransitionURL(url?: string): string | undefined { - if (!url) { - return undefined; - } - - const [urlWithoutHash] = url.split('#', 2); - const queryIndex = urlWithoutHash.indexOf('?'); - if (queryIndex === -1) { - return undefined; - } - - const queryString = urlWithoutHash.slice(queryIndex + 1); - const match = queryString.match(/(?:^|&)email=([^&]*)/); - const email = match?.[1]; - - if (!email) { - return undefined; - } - - let decodedEmail: string; - try { - decodedEmail = decodeURIComponent(email); - } catch { - decodedEmail = email; - } - - return StringUtils.normalize(decodedEmail); -} - let loggedInDuringSession: boolean | undefined; // To tell if the user logged in during this session we will check the value of session.authToken once when the app's JS inits. When the user logs out @@ -156,13 +126,4 @@ function isAgentEmail(email?: string): boolean { return AGENT_EMAIL_REGEX.test(email); } -export { - isLoggingInAsNewUser, - didUserLogInDuringSession, - resetDidUserLogInDuringSession, - checkIfShouldUseNewPartnerName, - getPartnerCredentials, - isLoggingInAsDelegate, - isAgentEmail, - getEmailFromTransitionURL, -}; +export {isLoggingInAsNewUser, didUserLogInDuringSession, resetDidUserLogInDuringSession, checkIfShouldUseNewPartnerName, getPartnerCredentials, isLoggingInAsDelegate, isAgentEmail}; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 5d204cf44bd8..d11bf75f32f0 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -533,34 +533,19 @@ function getAmount(transaction: OnyxInputOrEntry, isFromExpenseRepo } /** - * Convert a stored tax amount to the sign we display. + * Return the tax amount field from the transaction. */ -function normalizeTaxAmountSign(rawAmount: number | undefined, isFromExpenseReport: boolean): number { +function getTaxAmount(transaction: OnyxInputOrEntry, isFromExpenseReport: boolean): number { // IOU requests cannot have negative values but they can be stored as negative values, let's return absolute value if (!isFromExpenseReport) { - return Math.abs(rawAmount ?? 0); + return Math.abs(transaction?.taxAmount ?? 0); } // To avoid -0 being shown, lets only change the sign if the value is other than 0. - const amount = rawAmount ?? 0; + const amount = transaction?.taxAmount ?? 0; return amount ? -amount : 0; } -/** - * Return the tax amount field from the transaction. - */ -function getTaxAmount(transaction: OnyxInputOrEntry, isFromExpenseReport: boolean): number { - return normalizeTaxAmountSign(transaction?.taxAmount, isFromExpenseReport); -} - -/** - * Return the converted tax amount field from the transaction. - * It follows the same sign convention as `getTaxAmount`. - */ -function getConvertedTaxAmount(transaction: OnyxInputOrEntry, isFromExpenseReport: boolean): number { - return normalizeTaxAmountSign(transaction?.convertedTaxAmount, isFromExpenseReport); -} - /** * Return the tax code from the transaction. */ @@ -1992,7 +1977,6 @@ export { getMCCForDisplay, hasDisplayableMCC, getConvertedAmount, - getConvertedTaxAmount, isTimeRequest, getExpenseTypeTranslationKey, getDetailedExpenseTypeTranslationKey, diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 3ff72bf38f01..ad90888495d6 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -3,7 +3,6 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; @@ -26,10 +25,10 @@ import type {ValueOf} from 'type-fest'; import {Str} from 'expensify-common'; import {isBankAccountPartiallySetup} from './BankAccountUtils'; -import {getConnectedHRProvider, getHRAdvancedModeFinalApprover, getHRFinalApprover, isAnyHRConnected, isHRAdvancedMode, isAnyHRReadOnlyWorkflowMode} from './merge/HRUtils'; +import {getConnectedHRProvider, getHRAdvancedModeFinalApprover, getHRFinalApprover, isAnyHRConnected, isAnyHRReadOnlyWorkflowMode} from './merge/HRUtils'; import {getConnectedATSProvider, isAnyRecruitingReadOnlyWorkflowMode} from './merge/RecruitingUtils'; import {rand64} from './NumberUtils'; -import {canMemberWrite, getDefaultApprover, isExpensifyTeam, isNonMemberApprover, isSubmitPolicy, shouldFilterExpensifyTeam, shouldHideDynamicExternalWorkflowPeople} from './PolicyUtils'; +import {getDefaultApprover, isExpensifyTeam, shouldFilterExpensifyTeam} from './PolicyUtils'; import {fromIndexMap, isApprovalWorkflowRule, isRuleFilterComparison, toIndexMap} from './RuleUtils'; const INITIAL_APPROVAL_WORKFLOW: ApprovalWorkflowOnyx = { @@ -87,12 +86,6 @@ type GetApproversParams = { * Email of the first approver */ firstEmail: string; - - /** - * Policy the chain belongs to. When passed, an approver who is no longer on the workspace is kept at the end of - * the chain, flagged, instead of the chain silently stopping before them. - */ - policy?: OnyxEntry; }; /** Resolve the display name for an over-limit forwarder email, falling back to the email itself */ @@ -104,7 +97,7 @@ function getOverLimitForwardsToDisplayName(overLimitForwardsTo: string | undefin } /** Get the list of approvers for a given email */ -function calculateApprovers({employees, firstEmail, personalDetailsByEmail, policy}: GetApproversParams): Approver[] { +function calculateApprovers({employees, firstEmail, personalDetailsByEmail}: GetApproversParams): Approver[] { const approvers: Approver[] = []; // Keep track of approver emails to detect circular references const currentApproverEmails = new Set(); @@ -112,17 +105,6 @@ function calculateApprovers({employees, firstEmail, personalDetailsByEmail, poli let nextEmail: string | undefined = firstEmail; while (nextEmail) { if (!employees[nextEmail]) { - // Reports still route to an approver who left the workspace, so show them flagged for the admin to replace - if (policy && isNonMemberApprover(policy, nextEmail)) { - approvers.push({ - email: nextEmail, - forwardsTo: undefined, - avatar: personalDetailsByEmail[nextEmail]?.avatar, - displayName: personalDetailsByEmail[nextEmail]?.displayName ?? nextEmail, - isCircularReference: false, - isNotWorkspaceMember: true, - }); - } break; } @@ -137,7 +119,6 @@ function calculateApprovers({employees, firstEmail, personalDetailsByEmail, poli approvalLimit: employee.approvalLimit, overLimitForwardsTo: employee.overLimitForwardsTo, overLimitForwardsToDisplayName: getOverLimitForwardsToDisplayName(employee.overLimitForwardsTo, personalDetailsByEmail), - ...(policy && isNonMemberApprover(policy, employee.overLimitForwardsTo) ? {isOverLimitForwardsToNotWorkspaceMember: true} : {}), pendingAction: employee.pendingAction, errors: employee.errors, }); @@ -249,9 +230,7 @@ function convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, fir availableMembers.push(member); } - // A member who submits to someone no longer on the workspace is still shown under that approver, flagged, - // so the admin can see the broken workflow and fix it instead of it being hidden. - if (!submitsTo) { + if (!submitsTo || (!employees[submitsTo] && !hrAdvancedModeFinalApproverEmail)) { continue; } @@ -259,9 +238,7 @@ function convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, fir const effectiveSubmitsTo = shouldFilterOutExpensifyTeam && employees[submitsTo] ? (findFirstNonExpensifyApprover(employees, submitsTo) ?? submitsTo) : submitsTo; if (!approvalWorkflows[effectiveSubmitsTo]) { - let approvers = calculateApprovers({employees, firstEmail: effectiveSubmitsTo, personalDetailsByEmail, policy}); - // The first approver isn't on employeeList but isn't flagged either, e.g. the owner or an HR advanced mode - // manager, so still show them as the approver + let approvers = calculateApprovers({employees, firstEmail: effectiveSubmitsTo, personalDetailsByEmail}); if (approvers.length === 0) { approvers = [ { @@ -274,9 +251,7 @@ function convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, fir ]; } if (shouldFilterOutExpensifyTeam) { - // Keep a non-member approver even when it is an Expensify team email, so the workflow never ends up - // without approvers and the admin can still see and fix it. - approvers = approvers.filter((approver) => !!approver.isNotWorkspaceMember || !isExpensifyTeam(approver.email)); + approvers = approvers.filter((approver) => !isExpensifyTeam(approver.email)); } if (hrAdvancedModeFinalApproverEmail) { const lastApprover = approvers.at(-1); @@ -339,7 +314,7 @@ function convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, fir if (firstWorkflow && !firstWorkflow.isDefault) { sortedApprovalWorkflows.unshift({ members: [], - approvers: calculateApprovers({employees, firstEmail: defaultApprover, personalDetailsByEmail, policy}), + approvers: calculateApprovers({employees, firstEmail: defaultApprover, personalDetailsByEmail}), isDefault: true, }); } @@ -392,15 +367,11 @@ type UpdateWorkflowDataOnApproverRemovalParams = { /** * The email of the approver being removed */ - removedApproverEmail: string; + removedApprover: PersonalDetails; /** * The email of the workspace owner */ - ownerEmail: string; - /** - * Personal details of the workspace owner, used for the owner's avatar and display name when they replace the removed approver - */ - ownerDetails?: PersonalDetails | null; + ownerDetails: PersonalDetails; }; type UpdateWorkflowDataOnApproverRemovalResult = Array< @@ -436,11 +407,6 @@ function convertApprovalWorkflowToPolicyEmployees({ const pendingAction = type === CONST.APPROVAL_WORKFLOW.TYPE.CREATE ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE; for (const [index, approver] of approvalWorkflow.approvers.entries()) { - // A non-member approver has no employee entry to update, and sending one would add a fake employee - if (approver.isNotWorkspaceMember) { - continue; - } - const nextApprover = approvalWorkflow.approvers.at(index + 1); const forwardsTo = type === CONST.APPROVAL_WORKFLOW.TYPE.REMOVE ? '' : (nextApprover?.email ?? ''); const approvalLimit = type === CONST.APPROVAL_WORKFLOW.TYPE.REMOVE ? null : approver.approvalLimit; @@ -505,11 +471,7 @@ function convertApprovalWorkflowToPolicyEmployees({ // For each approver to remove, we update the employee list with forwardsTo set to '' // which will reset the forwardsTo on the backend. if (approversToRemove) { - for (const {email, isNotWorkspaceMember} of approversToRemove) { - if (isNotWorkspaceMember) { - continue; - } - + for (const {email} of approversToRemove) { updatedEmployeeList[email] = { ...(updatedEmployeeList[email] ? updatedEmployeeList[email] : {email}), forwardsTo: '', @@ -521,15 +483,12 @@ function convertApprovalWorkflowToPolicyEmployees({ return updatedEmployeeList; } -function updateWorkflowDataOnApproverRemoval({ - approvalWorkflows, - removedApproverEmail, - ownerEmail, - ownerDetails, -}: UpdateWorkflowDataOnApproverRemovalParams): UpdateWorkflowDataOnApproverRemovalResult { +function updateWorkflowDataOnApproverRemoval({approvalWorkflows, removedApprover, ownerDetails}: UpdateWorkflowDataOnApproverRemovalParams): UpdateWorkflowDataOnApproverRemovalResult { const defaultWorkflow = approvalWorkflows.find((workflow) => workflow.isDefault); - const ownerAvatar = ownerDetails?.avatar ?? ''; - const ownerDisplayName = ownerDetails?.displayName ?? ownerEmail; + const removedApproverEmail = removedApprover.login; + const ownerEmail = ownerDetails.login; + const ownerAvatar = ownerDetails.avatar ?? ''; + const ownerDisplayName = ownerDetails.displayName ?? ''; return approvalWorkflows.flatMap((workflow) => { // Drop any workflow that has no approvers. There is nothing to update on it, and passing it to @@ -554,7 +513,7 @@ function updateWorkflowDataOnApproverRemoval({ ...currentApprover, avatar: ownerAvatar, displayName: ownerDisplayName, - email: ownerEmail, + email: ownerEmail ?? '', }, ], }; @@ -616,10 +575,10 @@ function updateWorkflowDataOnApproverRemoval({ }); const newApprover = { - email: ownerEmail, + email: ownerEmail ?? '', forwardsTo: undefined, - avatar: ownerAvatar, - displayName: ownerDisplayName, + avatar: ownerDetails?.avatar ?? '', + displayName: ownerDetails?.displayName ?? '', isCircularReference: workflow.approvers.at(removedApproverIndex)?.isCircularReference, }; @@ -659,7 +618,7 @@ function updateWorkflowDataOnApproverRemoval({ ...currentApprover, avatar: ownerAvatar, displayName: ownerDisplayName, - email: ownerEmail, + email: ownerEmail ?? '', }, ], }; @@ -705,10 +664,10 @@ function updateWorkflowDataOnApproverRemoval({ }); const newApprover = { - email: ownerEmail, + email: ownerEmail ?? '', forwardsTo: undefined, - avatar: ownerAvatar, - displayName: ownerDisplayName, + avatar: ownerDetails?.avatar ?? '', + displayName: ownerDetails?.displayName ?? '', isCircularReference: workflow.approvers.at(removedApproverIndex)?.isCircularReference, }; @@ -1676,23 +1635,19 @@ function convertApprovalWorkflowRulesToWorkflows({ availableMembers.push(member); } - // Mirrors the legacy builder: a submitter whose approver is no longer on the workspace is still shown, flagged. const hasInitialRule = !!resolveFirstApprover(email, rules, {}); - if (!hasInitialRule && !submitsTo) { + if (!hasInitialRule && (!submitsTo || (!employees[submitsTo] && !hrAdvancedModeFinalApproverEmail))) { + // Mirrors the existing legacy filter: skip submitters whose first approver isn't reachable. continue; } - let chain = buildApproverChainFromRules({submitter: email, rules, employees, personalDetailsByEmail}).map((approver) => ({ - ...approver, - ...(isNonMemberApprover(policy, approver.email) ? {isNotWorkspaceMember: true} : {}), - ...(isNonMemberApprover(policy, approver.overLimitForwardsTo) ? {isOverLimitForwardsToNotWorkspaceMember: true} : {}), - })); + let chain = buildApproverChainFromRules({submitter: email, rules, employees, personalDetailsByEmail}); if (chain.length === 0) { continue; } if (shouldFilterOutExpensifyTeam) { - chain = chain.filter((approver) => !!approver.isNotWorkspaceMember || !isExpensifyTeam(approver.email)); + chain = chain.filter((approver) => !isExpensifyTeam(approver.email)); } if (hrAdvancedModeFinalApproverEmail) { const last = chain.at(-1); @@ -1797,7 +1752,7 @@ function convertApprovalWorkflowRulesToWorkflows({ if (firstWorkflow && !firstWorkflow.isDefault) { sortedApprovalWorkflows.unshift({ members: [], - approvers: calculateApprovers({employees, firstEmail: defaultApprover, personalDetailsByEmail, policy}), + approvers: calculateApprovers({employees, firstEmail: defaultApprover, personalDetailsByEmail}), isDefault: true, }); } @@ -1807,81 +1762,6 @@ function convertApprovalWorkflowRulesToWorkflows({ return {approvalWorkflows: sortedApprovalWorkflows, usedApproverEmails: [...usedApproverEmails], availableMembers}; } -type HasApprovalWorkflowWithNonMemberApproverParams = { - /** Policy whose approval workflows are checked */ - policy: OnyxEntry; - - /** Current user's login, used to check they can edit approvals and whether Expensify team members are filtered out */ - currentUserLogin: string | undefined; - - /** The policy's approval-workflow rules keyed by ruleID, which route the workflows under the `MULTIPLE_APPROVERS` beta */ - rules?: Record; - - /** Whether the `MULTIPLE_APPROVERS` beta is enabled, which builds the workflows from rules */ - isMultipleApproversBetaEnabled: boolean; -}; - -/** - * Whether the Workflows page shows the current user a workflow they can fix whose approver is no longer on the workspace. - * The workflows are built with the same builder and approval-mode filter as the page, so the red dot shows exactly - * when a flagged card does. - */ -function hasApprovalWorkflowWithNonMemberApprover({policy, currentUserLogin, rules, isMultipleApproversBetaEnabled}: HasApprovalWorkflowWithNonMemberApproverParams): boolean { - // Only flag workflows the page shows with an Edit button: approvals are on, an integration doesn't own the - // workflows, a Dynamic External Workflow doesn't hide them, and the user can edit approvals - const areApprovalsEnabled = !!policy?.approvalMode && policy.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL && !isSubmitPolicy(policy); - if ( - !areApprovalsEnabled || - isApprovalWorkflowLockedByIntegration(policy) || - shouldHideDynamicExternalWorkflowPeople(policy) || - !canMemberWrite(policy, currentUserLogin ?? '', CONST.POLICY.POLICY_FEATURE.WORKFLOWS_APPROVALS) - ) { - return false; - } - - // Without the beta, an approver is only flagged when the default approver or some employee's `submitsTo`, - // `forwardsTo` or `overLimitForwardsTo` points at a non-member, so skip building the workflows when none does - if (!isMultipleApproversBetaEnabled) { - const hasNonMemberRoute = - isNonMemberApprover(policy, getHRFinalApprover(policy) ?? getDefaultApprover(policy)) || - Object.values(policy?.employeeList ?? {}).some( - (employee) => - isNonMemberApprover(policy, employee.submitsTo) || isNonMemberApprover(policy, employee.forwardsTo) || isNonMemberApprover(policy, employee.overLimitForwardsTo), - ); - if (!hasNonMemberRoute) { - return false; - } - } - - // Only the approver flags are read, so personal details and sorting are skipped - const params = {policy, personalDetails: {}, localeCompare: () => 0, currentUserLogin, rules}; - const {approvalWorkflows} = isMultipleApproversBetaEnabled ? convertApprovalWorkflowRulesToWorkflows(params) : convertPolicyEmployeesToApprovalWorkflows(params); - - // Same approval-mode filter as the Approvals tab: only the advanced modes show workflows beyond the default one - const showsAllWorkflows = - isMultipleApproversBetaEnabled || - policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.ADVANCED || - policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL || - isHRAdvancedMode(policy); - const shownApprovalWorkflows = showsAllWorkflows ? approvalWorkflows : approvalWorkflows.filter((workflow) => workflow.isDefault); - - return shownApprovalWorkflows.some((workflow) => workflow.approvers.some((approver) => !!getNonMemberApproverError(approver, workflow.isDefault))); -} - -/** - * The error for an approver who is no longer on the workspace, or whose additional approver for reports over the limit - * isn't, or undefined when neither is. The default workflow can't be deleted, so its copy only asks for a new approver. - */ -function getNonMemberApproverError(approver: Approver | undefined, isDefaultWorkflow: boolean | undefined): TranslationPaths | undefined { - if (approver?.isNotWorkspaceMember) { - return isDefaultWorkflow ? 'workflowsPage.defaultWorkflowApproverNotWorkspaceMember' : 'workflowsPage.approverNotWorkspaceMember'; - } - if (approver?.isOverLimitForwardsToNotWorkspaceMember) { - return 'workflowsPage.overLimitApproverNotWorkspaceMember'; - } - return undefined; -} - export { addMembersToRule, applyApprovalWorkflowRulesDiff, @@ -1896,13 +1776,11 @@ export { getApprovalLimitDescription, getApprovalWorkflowRulesForPolicy, getApprovalWorkflowSource, - getNonMemberApproverError, filterRulesForPolicy, getRulesSubmitterToFirstApprover, getRulesSubmitterToWorkflowKey, getWorkflowMemberEmails, hasApprovalWorkflowRules, - hasApprovalWorkflowWithNonMemberApprover, hasRuleBasedDefaultWorkflow, includesEveryWorkspaceMember, isApprovalWorkflowLockedByIntegration, diff --git a/src/libs/actions/IOU/PayMoneyRequest.ts b/src/libs/actions/IOU/PayMoneyRequest.ts index 93ba0d50cf95..56f7675d202f 100644 --- a/src/libs/actions/IOU/PayMoneyRequest.ts +++ b/src/libs/actions/IOU/PayMoneyRequest.ts @@ -404,8 +404,7 @@ function getPayMoneyRequestParams({ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, value: { [optimisticIOUReportAction.reportActionID]: { - // Same key as the preview copy below, so dismissing this one clears that one too. - errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.other', CONST.IOU.PAY_FAILURE_PREVIEW_ERROR_KEY), + errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.other', 0), }, }, }, @@ -424,8 +423,7 @@ function getPayMoneyRequestParams({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, value: { - // Clear only our own previous failure so a retry does not keep showing a stale RBR. - [optimisticReportPreviewAction.reportActionID]: {...optimisticReportPreviewAction, errors: {[CONST.IOU.PAY_FAILURE_PREVIEW_ERROR_KEY]: null}}, + [optimisticReportPreviewAction.reportActionID]: optimisticReportPreviewAction, }, }); onyxData.failureData?.push({ @@ -434,9 +432,6 @@ function getPayMoneyRequestParams({ value: { [optimisticReportPreviewAction.reportActionID]: { created: optimisticReportPreviewAction.created, - // The error above sits in the expense report, which the payer cannot reach once it is deleted. - // Mirror it here. Only shown once the preview is deleted, see getVisibleReportActionErrors. - errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.other', CONST.IOU.PAY_FAILURE_PREVIEW_ERROR_KEY), }, }, }); diff --git a/src/libs/actions/IOU/Receipt.ts b/src/libs/actions/IOU/Receipt.ts index 4fc0390f436a..7cde573f9bd2 100644 --- a/src/libs/actions/IOU/Receipt.ts +++ b/src/libs/actions/IOU/Receipt.ts @@ -458,22 +458,6 @@ function setMoneyRequestReceipt( }); } -function readReceiptFile(receiptFilename: string, receiptPath: ReceiptSource, receiptType: string | undefined, onSuccess: (file: File) => void, onFailure: () => void) { - const path = ReceiptStorage.resolve(receiptPath) ?? receiptPath.toString(); - let didFail = false; - const markFailed = () => { - didFail = true; - }; - - return readFileAsync(path, receiptFilename, onSuccess, markFailed, receiptType).then(() => { - if (!didFail) { - return; - } - - return ReceiptStorage.recheckAfterSwap(receiptPath).then((isPresent) => (isPresent ? readFileAsync(path, receiptFilename, onSuccess, onFailure, receiptType) : onFailure())); - }); -} - // eslint-disable-next-line rulesdir/no-negated-variables function navigateToStartStepIfScanFileCannotBeRead( receiptFilename: string | undefined, @@ -509,7 +493,7 @@ function navigateToStartStepIfScanFileCannotBeRead( } navigateToStartMoneyRequestStep(requestType, iouType, transactionID, reportID); }; - return readReceiptFile(receiptFilename, receiptPath, receiptType, onSuccess, onFailure); + readFileAsync(ReceiptStorage.resolve(receiptPath) ?? receiptPath.toString(), receiptFilename, onSuccess, onFailure, receiptType); } function checkIfLocalFileIsAccessible( @@ -524,7 +508,7 @@ function checkIfLocalFileIsAccessible( return Promise.resolve(); } - return readReceiptFile(receiptFilename, receiptPath, receiptType, onSuccess, onFailure); + return readFileAsync(ReceiptStorage.resolve(receiptPath) ?? receiptPath.toString(), receiptFilename, onSuccess, onFailure, receiptType); } function clearReceiptUploadError({ diff --git a/src/libs/actions/MarketingAttribution.ts b/src/libs/actions/MarketingAttribution.ts deleted file mode 100644 index c9007998394f..000000000000 --- a/src/libs/actions/MarketingAttribution.ts +++ /dev/null @@ -1,73 +0,0 @@ -import {getSearchParamFromUrl} from '@libs/Url'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type MarketingAttribution from '@src/types/onyx/MarketingAttribution'; -import type {StoredMarketingAttribution} from '@src/types/onyx/MarketingAttribution'; - -import Onyx from 'react-native-onyx'; - -/** - * The UTM params, Google Ads device and network, and Google Ads click IDs we capture from the landing URL, keyed by their backend request - * param names. UTM params aren't present on new.expensify.com URLs today, but we capture them - * anyway so attribution works automatically if that changes. - */ -const MARKETING_PARAM_KEYS: Array = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'device', 'network', 'gclid', 'gbraid', 'wbraid']; - -/** - * Reads marketing attribution (UTM params, Google Ads device and network, and Google Ads click IDs) from the landing URL. - * This only finds values on web, where ad traffic lands. It has no side effects, and returns undefined when the URL carries none of the params. - */ -function captureMarketingAttributionFromURL(): MarketingAttribution | undefined { - if (typeof window === 'undefined' || !window.location) { - return undefined; - } - - const captured: MarketingAttribution = {}; - for (const key of MARKETING_PARAM_KEYS) { - const value = getSearchParamFromUrl(window.location.href, key); - if (value) { - captured[key] = value; - } - } - - return Object.keys(captured).length > 0 ? captured : undefined; -} - -/** - * Persists the attribution read from the landing URL to Onyx so it can be sent during signup. - * Does nothing when the user is already signed in to a real account, since attribution only matters for signup. - * - * We replace the whole stored attribution instead of merging into it, so values from different ad clicks - * (e.g. a Google gclid and later Reddit UTMs) never get combined. A page load without any of the params - * keeps the stored attribution. - */ -function saveMarketingAttribution(captured: MarketingAttribution | undefined, hasSession: boolean) { - if (!captured || hasSession) { - return; - } - - Onyx.set(ONYXKEYS.MARKETING_ATTRIBUTION, {...captured, capturedAt: Date.now()}); -} - -/** - * Returns the stored attribution to send with a signup request, without the local capture time. - * Google Ads only accepts click ID conversions for 90 days, so attribution older than that is cleared and ignored. - * Values stored without a capture time are treated as expired, because we can't tell how old they are and crediting a - * signup to a stale ad is worse than missing one. - */ -function getFreshMarketingAttribution(stored: StoredMarketingAttribution | undefined | null): MarketingAttribution | undefined { - if (!stored) { - return undefined; - } - - const {capturedAt, ...attribution} = stored; - if (typeof capturedAt !== 'number' || Date.now() - capturedAt > CONST.MARKETING_ATTRIBUTION_MAX_AGE_MS) { - Onyx.set(ONYXKEYS.MARKETING_ATTRIBUTION, null); - return undefined; - } - - return Object.keys(attribution).length > 0 ? attribution : undefined; -} - -export {captureMarketingAttributionFromURL, saveMarketingAttribution, getFreshMarketingAttribution}; diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index 304f8aec5ad9..248d29096802 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -732,8 +732,7 @@ async function updateWorkspaceMembersRole(policy: OnyxEntry, selectedMem employees: JSON.stringify(memberRoles.map((item) => ({email: item.email, role: item.role}))), }; - // The backend can refuse the approve-only role and list why in response.data.blockedReasons. API.write never - // exposes the response, so this role goes through a side-effects request to show those reasons in a modal. + // The approve-only blocked reasons live only on the response, which API.write does not expose. if (newRole === CONST.POLICY.ROLE.APPROVE_ONLY) { // eslint-disable-next-line rulesdir/no-api-side-effects-method const response = await API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.UPDATE_WORKSPACE_MEMBERS_ROLE, params, {optimisticData, successData}); @@ -743,8 +742,7 @@ async function updateWorkspaceMembersRole(policy: OnyxEntry, selectedMem return response; } - // Only one request runs per call: the approve-only branch above returns early, so this is not a chained call. - // eslint-disable-next-line rulesdir/no-multiple-api-calls + // eslint-disable-next-line rulesdir/no-multiple-api-calls -- the approve-only branch returns early API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_MEMBERS_ROLE, params, {optimisticData, successData, failureData}); } diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index be1cca52ba01..fc9c159271fe 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -349,12 +349,6 @@ type OpenReportActionParams = { isNewThread?: boolean; - /** - * Remove the optimistically created report if the create fails, rather than leaving a `createChat` error on it. - * Set it for reports the app creates on the user's behalf, where a "Fix" badge is not actionable. - */ - shouldRemoveOptimisticReportOnFailure?: boolean; - /** The transaction object for legacy transactions that don't have a transaction thread or money request preview yet */ transaction?: Transaction; @@ -1702,7 +1696,6 @@ function openReport(params: OpenReportActionParams) { isFromDeepLink = false, personalDetails, isNewThread = false, - shouldRemoveOptimisticReportOnFailure = false, transaction, transactionViolations, parentReportID, @@ -1727,10 +1720,6 @@ function openReport(params: OpenReportActionParams) { const participantAccountIDList = participants.map((p) => p.accountID).filter((id): id is number => id !== undefined); const existingReportName = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.reportName; const isCreatingNewReport = !isEmptyObject(newReportObject); - - // `failureData` runs after the response's own `onyxData`, so nulling these keys also clears what the server wrote. - const shouldRollBackOptimisticReport = isCreatingNewReport && shouldRemoveOptimisticReportOnFailure; - let shouldClearManualUnreadMarker = false; if (!shouldKeepManualUnreadMarker) { // True only on a genuine return trip: `flagReportNavigatedAway` sets it on blur/unmount, so it is false on the @@ -1841,7 +1830,7 @@ function openReport(params: OpenReportActionParams) { }, ]; - if (isNewThread && !shouldRollBackOptimisticReport) { + if (isNewThread) { failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, @@ -1963,15 +1952,6 @@ function openReport(params: OpenReportActionParams) { }, }); - // The preview action exists only to point at the thread, so it goes too, or its `childReportID` dangles. - if (shouldRollBackOptimisticReport) { - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionParentReportID}`, - value: {[iouReportActionID]: null}, - }); - } - parameters.moneyRequestPreviewReportActionID = iouReportActionID; // Log how often the legacy transaction fallback path is taken @@ -2100,7 +2080,7 @@ function openReport(params: OpenReportActionParams) { failureData.push(PersonalDetailsUtils.buildPersonalDetailsUpdate(settledPersonalDetails)); } - if (!isNewThread && !shouldRollBackOptimisticReport) { + if (!isNewThread) { failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, @@ -2112,34 +2092,11 @@ function openReport(params: OpenReportActionParams) { }); } - if (shouldRollBackOptimisticReport) { - // `SidebarUtils` force-displays any report carrying errors, so keeping this one leaves a dead LHN row. - failureData.push( - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - value: null, - }, - { - // Remove only the action we created. Nulling the whole key would take anything the user queued in - // the thread with it, e.g. a comment written while offline. - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, - value: {[optimisticCreatedAction.reportActionID]: null}, - }, - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, - value: null, - }, - ); - } else { - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, - value: {[optimisticCreatedAction.reportActionID]: {pendingAction: null}}, - }); - } + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: {[optimisticCreatedAction.reportActionID]: {pendingAction: null}}, + }); // Add the createdReportActionID parameter to the API call parameters.createdReportActionID = optimisticCreatedAction.reportActionID; @@ -2154,8 +2111,7 @@ function openReport(params: OpenReportActionParams) { failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${newReportObject.parentReportID}`, - // The thread is rolled back, so the parent action must not keep pointing at it. - value: {[parentReportActionID]: shouldRollBackOptimisticReport ? {childReportID: null, childType: ''} : {childType: ''}}, + value: {[parentReportActionID]: {childType: ''}}, }); } } @@ -2568,8 +2524,6 @@ function createTransactionThreadReport(params: CreateTransactionThreadReportPara personalDetails, newReportObject: optimisticTransactionThread, parentReportActionID: iouReportAction?.reportActionID, - // The app creates this thread, not the user, so a "Fix" row on it is not actionable. Roll it back instead. - shouldRemoveOptimisticReportOnFailure: true, transaction, transactionViolations, parentReportID: selfDMReportID, diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 3b8981d0e91d..1f7cf8b0b538 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -1,4 +1,3 @@ -import {getFreshMarketingAttribution} from '@libs/actions/MarketingAttribution'; import * as PersistedRequests from '@libs/actions/PersistedRequests'; import * as API from '@libs/API'; import type { @@ -76,7 +75,6 @@ import ADD_WORK_EMAIL_INPUT_IDS from '@src/types/form/AddWorkEmailForm'; import type {Report, TryNewDot} from '@src/types/onyx'; import type Credentials from '@src/types/onyx/Credentials'; import type Locale from '@src/types/onyx/Locale'; -import type {StoredMarketingAttribution} from '@src/types/onyx/MarketingAttribution'; import type {OnyxData} from '@src/types/onyx/Request'; import type Response from '@src/types/onyx/Response'; import type Session from '@src/types/onyx/Session'; @@ -660,9 +658,9 @@ function buildOnyxDataToCleanUpAnonymousUser(): PersonalDetailsOnyxUpdate { /** * Creates an account for the new user and signs them into the application with the newly created account. - * The marketing attribution captured from the landing URL is sent with the request and cleared once signup succeeds. + * */ -function signUpUser(login: string | undefined, preferredLocale: Locale | undefined, hasSMSMarketingConsent?: boolean, storedMarketingAttribution?: OnyxEntry) { +function signUpUser(login: string | undefined, preferredLocale: Locale | undefined, hasSMSMarketingConsent?: boolean) { const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -676,7 +674,7 @@ function signUpUser(login: string | undefined, preferredLocale: Locale | undefin const onyxOperationToCleanUpAnonymousUser = buildOnyxDataToCleanUpAnonymousUser(); - const successData: Array | PersonalDetailsOnyxUpdate> = [ + const successData: Array | PersonalDetailsOnyxUpdate> = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -685,12 +683,6 @@ function signUpUser(login: string | undefined, preferredLocale: Locale | undefin }, }, onyxOperationToCleanUpAnonymousUser, - // The attribution is stored on the new account, so clear it to avoid sending it again on a later signup - { - onyxMethod: Onyx.METHOD.SET, - key: ONYXKEYS.MARKETING_ATTRIBUTION, - value: null, - }, ]; const failureData: Array> = [ @@ -704,7 +696,7 @@ function signUpUser(login: string | undefined, preferredLocale: Locale | undefin ]; Device.getDeviceInfoWithID().then((deviceInfo) => { - const params: SignUpUserParams = {email: login, preferredLocale: preferredLocale ?? null, deviceInfo, ...getFreshMarketingAttribution(storedMarketingAttribution)}; + const params: SignUpUserParams = {email: login, preferredLocale: preferredLocale ?? null, deviceInfo}; if (hasSMSMarketingConsent !== undefined) { params.hasSMSMarketingConsent = hasSMSMarketingConsent; } @@ -865,44 +857,29 @@ function setupNewDotAfterTransitionFromOldDot(hybridAppSettings: HybridAppSettin }); } -/** - * Builds the Onyx data for a Google or Apple sign in. These can create a new account, so on success we clear the marketing - * attribution sent with the request, the same way signUpUser does, so it isn't sent again on a later signup. - */ -function getThirdPartySignInOnyxData(): OnyxData { - const {optimisticData, successData, failureData} = signInAttemptState(); - return { - optimisticData, - successData: [...(successData ?? []), {onyxMethod: Onyx.METHOD.SET, key: ONYXKEYS.MARKETING_ATTRIBUTION, value: null}], - failureData, - }; -} - /** * Given an idToken from Sign in with Apple, checks the API to see if an account * exists for that email address and signs the user in if so. - * The marketing attribution captured from the landing URL is sent along, since this can create a new account. */ -function beginAppleSignIn(idToken: string | undefined | null, preferredLocale: Locale | undefined, storedMarketingAttribution?: OnyxEntry) { - const onyxData = getThirdPartySignInOnyxData(); +function beginAppleSignIn(idToken: string | undefined | null, preferredLocale: Locale | undefined) { + const {optimisticData, successData, failureData} = signInAttemptState(); Device.getDeviceInfoWithID().then((deviceInfo) => { - const params: BeginAppleSignInParams = {idToken, preferredLocale: preferredLocale ?? null, deviceInfo, ...getFreshMarketingAttribution(storedMarketingAttribution)}; - API.write(WRITE_COMMANDS.SIGN_IN_WITH_APPLE, params, onyxData); + const params: BeginAppleSignInParams = {idToken, preferredLocale: preferredLocale ?? null, deviceInfo}; + API.write(WRITE_COMMANDS.SIGN_IN_WITH_APPLE, params, {optimisticData, successData, failureData}); }); } /** * Shows Google sign-in process, and if an auth token is successfully obtained, - * passes the token on to the Expensify API to sign in with. - * The marketing attribution captured from the landing URL is sent along, since this can create a new account. + * passes the token on to the Expensify API to sign in with */ -function beginGoogleSignIn(token: string | null, preferredLocale: Locale | undefined, storedMarketingAttribution?: OnyxEntry) { - const onyxData = getThirdPartySignInOnyxData(); +function beginGoogleSignIn(token: string | null, preferredLocale: Locale | undefined) { + const {optimisticData, successData, failureData} = signInAttemptState(); Device.getDeviceInfoWithID().then((deviceInfo) => { - const params: BeginGoogleSignInParams = {token, preferredLocale: preferredLocale ?? null, deviceInfo, ...getFreshMarketingAttribution(storedMarketingAttribution)}; - API.write(WRITE_COMMANDS.SIGN_IN_WITH_GOOGLE, params, onyxData); + const params: BeginGoogleSignInParams = {token, preferredLocale: preferredLocale ?? null, deviceInfo}; + API.write(WRITE_COMMANDS.SIGN_IN_WITH_GOOGLE, params, {optimisticData, successData, failureData}); }); } @@ -910,7 +887,7 @@ function beginGoogleSignIn(token: string | null, preferredLocale: Locale | undef * Will create a temporary login for the user in the passed authenticate response which is used when * re-authenticating after an authToken expires. */ -function signInWithShortLivedAuthToken(authToken: string, currentAuthToken: string | undefined, isSAML: boolean, exitTo: string | undefined, login: string | undefined) { +function signInWithShortLivedAuthToken(authToken: string, isSAML: boolean, exitTo: string | undefined, login: string | undefined) { const {optimisticData, failureData, finallyData} = getShortLivedLoginParams(false, isSAML); const authMethod = isSAML ? CONST.AUTH_METHOD.SAML : CONST.AUTH_METHOD.SHORT_LIVED_AUTH_TOKEN; // Set the in-flight guard synchronously, before awaiting device info. optimisticData below (which also sets this key) @@ -919,20 +896,12 @@ function signInWithShortLivedAuthToken(authToken: string, currentAuthToken: stri // re-fires and loops. This key is RAM-only (resets on reload), so setting it early carries no stuck-state risk; the // optimisticData re-sets it and finallyData reverts it exactly as before. Onyx.set(ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN, true); + Device.getDeviceInfoWithID().then((deviceInfo) => { + API.read(READ_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN, {authToken, skipReauthentication: true, authMethod, deviceInfo}, {optimisticData, failureData, finallyData}); + }); NetworkStore.setLastShortAuthToken(authToken); - - const signInPromise = Device.getDeviceInfoWithID().then((deviceInfo) => - // We use makeRequestWithSideEffects here because the caller needs to inspect the response to detect a SESSION_MISMATCH error - // eslint-disable-next-line rulesdir/no-api-side-effects-method - API.makeRequestWithSideEffects( - SIDE_EFFECT_REQUEST_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN, - {authToken, skipReauthentication: true, authMethod, deviceInfo, currentAuthToken}, - {optimisticData, failureData, finallyData}, - ), - ); - if (!exitTo) { - return signInPromise; + return; } // waitForUserSignIn keeps a single resolver that openReportFromDeepLink may already hold, so wait on the routes instead. @@ -950,8 +919,6 @@ function signInWithShortLivedAuthToken(authToken: string, currentAuthToken: stri Log.warn('Unable to return to the last visited path after SAML sign in', {error}); } }); - - return signInPromise; } /** diff --git a/src/libs/actions/TableColumnWidths.ts b/src/libs/actions/TableColumnWidths.ts deleted file mode 100644 index 9e9b8a450578..000000000000 --- a/src/libs/actions/TableColumnWidths.ts +++ /dev/null @@ -1,11 +0,0 @@ -import ONYXKEYS from '@src/ONYXKEYS'; - -import Onyx from 'react-native-onyx'; - -/** Persists a dragged column's width once the drag ends. The drag itself only touches CSS. */ -function setTableColumnWidth(columnResizingID: string, columnKey: string, width: number) { - Onyx.merge(ONYXKEYS.TABLE_COLUMN_WIDTHS, {[columnResizingID]: {[columnKey]: width}}); -} - -// eslint-disable-next-line import/prefer-default-export -- additional actions may be added here -export {setTableColumnWidth}; diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index 10bf8e37c5b8..4af432692d17 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -2,7 +2,7 @@ import {write} from '@libs/API'; import type {CreateWorkspaceApprovalParams, RemoveWorkspaceApprovalParams, SetApprovalWorkflowParams, UpdateWorkspaceApprovalParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; -import {getDefaultApprover, isNonMemberApprover} from '@libs/PolicyUtils'; +import {getDefaultApprover} from '@libs/PolicyUtils'; import type {ApprovalWorkflowRulesDiff} from '@libs/WorkflowUtils'; import { addMembersToRule, @@ -12,7 +12,6 @@ import { calculateApprovers, convertApprovalWorkflowToPolicyEmployees, getApprovalWorkflowRulesForPolicy, - getNonMemberApproverError, getOverLimitForwardsToDisplayName, getWorkflowMemberEmails, hasRuleBasedDefaultWorkflow, @@ -287,7 +286,7 @@ function removeApprovalWorkflow(approvalWorkflow: ApprovalWorkflow, policy: Onyx onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, value: { - employeeList: Object.fromEntries(Object.keys(updatedEmployees).map((key) => [key, {pendingAction: null, pendingFields: null}])), + employeeList: Object.fromEntries(Object.keys(updatedEmployees).map((key) => [key, {pendingAction: null}])), }, }, ]; @@ -640,8 +639,6 @@ function setApprovalWorkflowApprover({approver, approverIndex, currentApprovalWo return { ...existingApprover, isCircularReference: hasCircularReference, - // Re-check, so picking a new additional approver for reports over the limit clears the flag - isOverLimitForwardsToNotWorkspaceMember: isNonMemberApprover(policy, existingApprover.overLimitForwardsTo) || undefined, }; }); @@ -768,11 +765,6 @@ function validateApprovalWorkflow(approvalWorkflow: ApprovalWorkflowOnyx): appro errors[`approver-${approverIndex}`] = 'workflowsPage.approverCircularReference'; } - const nonMemberApproverError = getNonMemberApproverError(approver, approvalWorkflow.isDefault); - if (nonMemberApproverError) { - errors[`approver-${approverIndex}`] = nonMemberApproverError; - } - // Validate that if overLimitForwardsTo is set, approvalLimit must also be set if (approver?.overLimitForwardsTo && (!approver?.approvalLimit || approver.approvalLimit <= 0)) { errors[`approver-${approverIndex}`] = 'workflowsApprovalLimitPage.enterAmountError'; diff --git a/src/libs/actions/connections/merge/index.ts b/src/libs/actions/connections/merge/index.ts index 9589b9c78f5b..d71001f8d73e 100644 --- a/src/libs/actions/connections/merge/index.ts +++ b/src/libs/actions/connections/merge/index.ts @@ -68,9 +68,6 @@ function syncMerge(policy: OnyxEntry, connectionName: MergeConnectionNam syncStatus: CONST.MERGE.SYNC_STATUS.SYNCING, syncType: CONST.MERGE.SYNC_TYPE.MANUAL, manualSyncTimestamps: [DateUtils.getDBTime(), ...(previousLastSync?.manualSyncTimestamps ?? [])], - // A prior failure may have been a configuration error; starting a fresh sync clears - // that flag so a later unrelated failure isn't mistaken for one that's already fixed. - isConfigurationError: false, }, }, }, @@ -89,7 +86,6 @@ function syncMerge(policy: OnyxEntry, connectionName: MergeConnectionNam syncStatus: CONST.MERGE.SYNC_STATUS.FAILED, errorMessage: null, manualSyncTimestamps: previousLastSync?.manualSyncTimestamps ?? null, - isConfigurationError: false, }, }, }, diff --git a/src/libs/fileDownload/validateReceiptFile/index.native.ts b/src/libs/fileDownload/validateReceiptFile/index.native.ts index 55e31d8627db..22af1fef7ce7 100644 --- a/src/libs/fileDownload/validateReceiptFile/index.native.ts +++ b/src/libs/fileDownload/validateReceiptFile/index.native.ts @@ -24,11 +24,14 @@ function validateReceiptFile( const localPath = ReceiptStorage.resolve(receiptPath) ?? receiptPathString; - const succeed = () => onSuccess({uri: localPath, name: receiptFilename, type: receiptType, source: localPath} as File); + return checkFileExists(localPath).then((exists) => { + if (!exists) { + onFailure(); + return; + } - return checkFileExists(localPath) - .then((exists) => exists || ReceiptStorage.recheckAfterSwap(receiptPath).then((isPresent) => isPresent && checkFileExists(localPath))) - .then((exists) => (exists ? succeed() : onFailure())); + onSuccess({uri: localPath, name: receiptFilename, type: receiptType, source: localPath} as File); + }); } export default validateReceiptFile; diff --git a/src/libs/getImageRecyclingKey.ts b/src/libs/getImageRecyclingKey.ts index 6457d1a4a132..7e7fe71e2e87 100644 --- a/src/libs/getImageRecyclingKey.ts +++ b/src/libs/getImageRecyclingKey.ts @@ -3,14 +3,6 @@ import type {ImageRequireSource, ImageURISource} from 'react-native'; type ImageSourceParam = ImageSource | number | ImageSource[] | Omit | ImageRequireSource | undefined; -/** expo-image blanks the view whenever recyclingKey changes, so a fragment that only forces a reload of the same file stays out of the key. */ -function withoutFragment(uri: string): string { - if (uri.startsWith('data:')) { - return uri; - } - return uri.split('#').at(0) ?? uri; -} - /** * Returns a string key for image recycling in FlashList. * Extracts the URI if available, otherwise stringifies the source. @@ -30,7 +22,7 @@ function getImageRecyclingKey(source: ImageSourceParam): string | undefined { } if (typeof source === 'object' && 'uri' in source && source.uri) { - return withoutFragment(source.uri); + return source.uri; } return JSON.stringify(source); diff --git a/src/libs/merge/HRUtils.ts b/src/libs/merge/HRUtils.ts index 1c1c3f3e67ae..eb3ae048f74f 100644 --- a/src/libs/merge/HRUtils.ts +++ b/src/libs/merge/HRUtils.ts @@ -48,52 +48,6 @@ function isMergeHRCompleteSetupNeeded(policy?: OnyxEntry): boolean { return syncDone && hasGroups && !setupComplete; } -/** - * True when a selected group ID is missing from the cached group list, meaning it no longer exists upstream. - * Returns false if the cache has never synced, since there's nothing to compare against. - */ -function hasStaleMergeHRGroups(policy?: OnyxEntry): boolean { - const mergeHR = policy?.connections?.merge_hris; - const selectedGroupIDs = mergeHR?.config?.groups; - // allGroupIDs (not the display-filtered groups) is what the backend prunes against, so a group - // missing a name/type isn't wrongly flagged as deleted here. - const availableGroupIDs = mergeHR?.data?.allGroupIDs; - // allGroupIDs is explicitly [] once a sync has actually run and found zero groups, so only - // undefined (never synced) means there's nothing to compare against yet. - if (!selectedGroupIDs?.length || !availableGroupIDs) { - return false; - } - return selectedGroupIDs.some((groupID) => !availableGroupIDs.includes(groupID)); -} - -/** - * The admin's group selection, minus any group that no longer exists anywhere in the HR system. Compares - * against allGroupIDs (the same source hasStaleMergeHRGroups uses), falling back to data.groups when - * allGroupIDs hasn't synced yet. A cache that has never synced is left untouched. - */ -function getValidMergeHRGroupIDs(policy?: OnyxEntry): string[] { - const mergeHR = policy?.connections?.merge_hris; - const selectedGroupIDs = mergeHR?.config?.groups ?? []; - const availableGroupIDs = mergeHR?.data?.allGroupIDs ?? mergeHR?.data?.groups?.map((group) => group.id); - if (!availableGroupIDs) { - return [...selectedGroupIDs]; - } - return selectedGroupIDs.filter((groupID) => availableGroupIDs.includes(groupID)); -} - -/** - * The subset of getValidMergeHRGroupIDs with no renderable row — a group the HR system still has, but that - * Merge sent back without a name or type. The selector shows these with a fallback label so the admin can - * still see and deselect them, instead of silently dropping or silently preserving them on save. - */ -function getNonRenderableMergeHRGroupIDs(policy?: OnyxEntry): string[] { - const availableGroups = policy?.connections?.merge_hris?.data?.groups; - if (!availableGroups) { - return []; - } - return getValidMergeHRGroupIDs(policy).filter((groupID) => !availableGroups.some((group) => group.id === groupID)); -} - /** Returns display info for the HR provider currently connected to the policy (Gusto, Zenefits, or Merge HR), or null if none are connected. */ function getConnectedHRProvider(policy?: OnyxEntry): HRProviderInfo | null { if (isGustoConnected(policy)) { @@ -213,7 +167,7 @@ function shouldShowHRConnectionError(policy: OnyxEntry, isSyncInProgress return true; } if (connectedProvider.connectionName === CONST.POLICY.CONNECTIONS.NAME.MERGE_HR) { - return hasMergeSyncError(policy, CONST.POLICY.CONNECTIONS.NAME.MERGE_HR) || hasStaleMergeHRGroups(policy); + return hasMergeSyncError(policy, CONST.POLICY.CONNECTIONS.NAME.MERGE_HR); } return hasSynchronizationErrorMessage(policy, connectedProvider.connectionName, isSyncInProgress); } @@ -223,9 +177,6 @@ export { getHRApprovalMode, getHRAdvancedModeFinalApprover, getHRFinalApprover, - getNonRenderableMergeHRGroupIDs, - getValidMergeHRGroupIDs, - hasStaleMergeHRGroups, isAnyHRConnected, isAnyHRReadOnlyWorkflowMode, isGustoConnected, diff --git a/src/libs/merge/MergeUtils.ts b/src/libs/merge/MergeUtils.ts index 77be4c4942c1..52b72f3dfe1d 100644 --- a/src/libs/merge/MergeUtils.ts +++ b/src/libs/merge/MergeUtils.ts @@ -38,13 +38,9 @@ function hasMergeAuthenticationError(policy: OnyxEntry, connectionName: /** * Returns true when the last sync of the given Merge connection ended in an error the admin has to resolve — either * the integration needs to be reconnected, or the sync itself failed. - * - * A failure the connection's own settings caused is excluded: the settings may already have been corrected, and the - * next sync only reports back once it runs, so callers surface those from the current settings instead. */ function hasMergeSyncError(policy: OnyxEntry, connectionName: MergeConnectionName): boolean { - const lastSync = policy?.connections?.[connectionName]?.lastSync; - return hasMergeAuthenticationError(policy, connectionName) || (lastSync?.syncStatus === CONST.MERGE.SYNC_STATUS.FAILED && !lastSync?.isConfigurationError); + return hasMergeAuthenticationError(policy, connectionName) || policy?.connections?.[connectionName]?.lastSync?.syncStatus === CONST.MERGE.SYNC_STATUS.FAILED; } /** Returns the given Merge connection's finalApprover when it is in basic or advanced (manager) approval mode, or null otherwise. */ diff --git a/src/libs/prepareRequestPayload/index.native.ts b/src/libs/prepareRequestPayload/index.native.ts index 4334d3794187..eb613f45289b 100644 --- a/src/libs/prepareRequestPayload/index.native.ts +++ b/src/libs/prepareRequestPayload/index.native.ts @@ -46,12 +46,6 @@ const prepareRequestPayload: PrepareRequestPayload = (command, data, initiatedOf return Promise.resolve(); } - // The assertion is FormData's signature being narrower than what request data can hold. - const appendValueAsIs = () => { - validateFormDataParameter(command, key, value); - formData.append(key, value as string | Blob); - }; - if (key === 'receipt') { const {source, name, type, receiptTraceId} = value as Omit & Pick; @@ -61,12 +55,13 @@ const prepareRequestPayload: PrepareRequestPayload = (command, data, initiatedOf return Promise.resolve(); } - return ReceiptStorage.locate(source).then((localUri) => { - if (!localUri) { + const localUri = ReceiptStorage.resolve(source) ?? source; + + return checkFileExistsWithReason(localUri).then(({exists, error}) => { + if (!exists) { const transactionID = typeof data.transactionID === 'string' ? data.transactionID : undefined; - const resolvedUri = ReceiptStorage.resolve(source) ?? source; - return Promise.all([checkFileExistsWithReason(resolvedUri), getReceiptsFolderState()]).then(([{error}, receiptsFolder]) => { - logReceiptDropped({receiptTraceId, transactionID, command, source, localUri: resolvedUri, fileName: name, statError: error, receiptsFolder}); + return getReceiptsFolderState().then((receiptsFolder) => { + logReceiptDropped({receiptTraceId, transactionID, command, source, localUri, fileName: name, statError: error, receiptsFolder}); }); } const receiptFormData = { @@ -78,10 +73,6 @@ const prepareRequestPayload: PrepareRequestPayload = (command, data, initiatedOf formData.append(key, receiptFormData as File); }); } - - if (name) { - return ReceiptStorage.settle(name).then(appendValueAsIs); - } } if (key === 'file' && initiatedOffline) { @@ -104,7 +95,8 @@ const prepareRequestPayload: PrepareRequestPayload = (command, data, initiatedOf }); } - appendValueAsIs(); + validateFormDataParameter(command, key, value); + formData.append(key, value as string | Blob); return Promise.resolve(); }); diff --git a/src/libs/sanitizeLogParams.ts b/src/libs/sanitizeLogParams.ts index 9fac07156ae8..6949f6aa8330 100644 --- a/src/libs/sanitizeLogParams.ts +++ b/src/libs/sanitizeLogParams.ts @@ -14,7 +14,6 @@ const SENSITIVE_KEYS = new Set([ 'oldDotCurrentEncryptedAuthToken', 'oldDotAutoGeneratedPassword', 'apiKey', - 'currentAuthToken', 'clientSecret', 'cardNumber', 'cardCVV', diff --git a/src/pages/DynamicReportDetailsPage/ReportDetailsContent.tsx b/src/pages/DynamicReportDetailsPage/ReportDetailsContent.tsx deleted file mode 100644 index 73d6703860a9..000000000000 --- a/src/pages/DynamicReportDetailsPage/ReportDetailsContent.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; - -import useDynamicBackPath from '@hooks/useDynamicBackPath'; -import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import getBase62ReportID from '@libs/getBase62ReportID'; -import Navigation from '@libs/Navigation/Navigation'; -import Permissions from '@libs/Permissions'; -import { - isFinancialReportsForBusinesses as isFinancialReportsForBusinessesUtil, - isInvoiceReport as isInvoiceReportUtil, - isMoneyRequest as isMoneyRequestUtil, - isMoneyRequestReport as isMoneyRequestReportUtil, - isSelfDM as isSelfDMUtil, -} from '@libs/ReportUtils'; - -import {getReportPrivateNote} from '@userActions/Report'; - -import ONYXKEYS from '@src/ONYXKEYS'; -import {DYNAMIC_ROUTES} from '@src/ROUTES'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; - -import React, {useEffect} from 'react'; -import {View} from 'react-native'; - -import ReportDetailsActions from './ReportDetailsActions'; -import ReportDetailsAvatar from './ReportDetailsAvatar'; -import ReportDetailsDescription from './ReportDetailsDescription'; -import ReportDetailsNameSection from './ReportDetailsNameSection'; -import ReportDetailsPromotedActions from './ReportDetailsPromotedActions'; -import ReportDetailsTitleSection from './ReportDetailsTitleSection'; - -type ReportDetailsContentProps = { - reportID: string; -}; - -function ReportDetailsContent({reportID}: ReportDetailsContentProps) { - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`); - const {translate} = useLocalize(); - const {isOffline} = useNetwork(); - const styles = useThemeStyles(); - const navigateBackFromReportDetailsPath = useDynamicBackPath(DYNAMIC_ROUTES.REPORT_DETAILS.path); - const isMoneyRequestReport = isMoneyRequestReportUtil(report); - const isMoneyRequest = isMoneyRequestUtil(report); - const isInvoiceReport = isInvoiceReportUtil(report); - const isFinancialReportsForBusinesses = isFinancialReportsForBusinessesUtil(report); - const isSelfDM = isSelfDMUtil(report); - const isExpenseReport = isMoneyRequestReport || isInvoiceReport || isMoneyRequest; - const base62ReportID = getBase62ReportID(Number(reportID)); - - const isPrivateNotesFetchTriggered = reportLoadingState?.isLoadingPrivateNotes !== undefined; - - useEffect(() => { - // Do not fetch private notes if the feature is disabled, isLoadingPrivateNotes is already defined, the network is offline, or if the report is a self DM. - if (!Permissions.canUsePrivateNotes() || isPrivateNotesFetchTriggered || isOffline || isSelfDM) { - return; - } - - getReportPrivateNote(report?.reportID); - }, [report?.reportID, isOffline, isPrivateNotesFetchTriggered, isSelfDM]); - - return ( - - - Navigation.goBack(navigateBackFromReportDetailsPath)} - /> - - - - - {isExpenseReport ? : } - - - - {isFinancialReportsForBusinesses && ( - <> - - - - )} - - - - - - - - ); -} - -export default ReportDetailsContent; diff --git a/src/pages/DynamicReportDetailsPage/ReportDetailsMenuItems.tsx b/src/pages/DynamicReportDetailsPage/ReportDetailsMenuItems.tsx index 203701d48d02..e7b71f94b650 100644 --- a/src/pages/DynamicReportDetailsPage/ReportDetailsMenuItems.tsx +++ b/src/pages/DynamicReportDetailsPage/ReportDetailsMenuItems.tsx @@ -1,11 +1,30 @@ +import MenuItem from '@components/MenuItem'; + +import useActivePolicy from '@hooks/useActivePolicy'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLastWorkspaceNumber from '@hooks/useLastWorkspaceNumber'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useParentReportAction from '@hooks/useParentReportAction'; +import usePreferredPolicy from '@hooks/usePreferredPolicy'; import useReportIsArchived from '@hooks/useReportIsArchived'; -import {isArchivedNonExpenseReport, isSelfDM as isSelfDMUtil, isTrackExpenseReportNew as isTrackExpenseReportUtil} from '@libs/ReportUtils'; +import {generateDefaultWorkspaceName} from '@libs/actions/Policy/Policy'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import Permissions from '@libs/Permissions'; +import {getTrackExpenseActionableWhisper} from '@libs/ReportActionsUtils'; +import {getOriginalReportID, isArchivedNonExpenseReport, isSelfDM as isSelfDMUtil, isTrackExpenseReportNew as isTrackExpenseReportUtil} from '@libs/ReportUtils'; +import {getOriginalTransactionWithSplitInfo, isPerDiemRequest, isTimeRequest} from '@libs/TransactionUtils'; + +import {createDraftTransactionAndNavigateToParticipantSelector} from '@userActions/IOU/StartExpenseFlows'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {billingRestrictionPolicySelector, createFilteredPoliciesInfoSelector, createHasWorkspaceToSubmitToSelector} from '@selectors/Policy'; +import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import React from 'react'; import type {ReportDetailsRequestData} from './types'; @@ -18,7 +37,6 @@ import ReportDetailsMenuMarkAsIncompleteItem from './ReportDetailsMenuMarkAsInco import ReportDetailsMenuMembersOrInviteItem from './ReportDetailsMenuMembersOrInviteItem'; import ReportDetailsMenuPrivateNotesItem from './ReportDetailsMenuPrivateNotesItem'; import ReportDetailsMenuSettingsItem from './ReportDetailsMenuSettingsItem'; -import ReportDetailsMenuTrackExpenseItems from './ReportDetailsMenuTrackExpenseItems'; type ReportDetailsMenuItemsProps = { reportID: string; @@ -26,15 +44,44 @@ type ReportDetailsMenuItemsProps = { }; function ReportDetailsMenuItems({reportID, requestData}: ReportDetailsMenuItemsProps) { + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const {isRestrictedToPreferredPolicy, preferredPolicyID} = usePreferredPolicy(); + const activePolicy = useActivePolicy(); + const lastWorkspaceNumber = useLastWorkspaceNumber(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Send', 'Folder', 'UserPlus']); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); + const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); + const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); const parentReportAction = useParentReportAction(report); + const [reportActionsForOriginalReportID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`); + // The report from which a tracked expense would be submitted/categorized/shared, and its actions - + // createDraftTransactionAndNavigateToParticipantSelector uses them to find the linked track-expense action + const actionReportID = getOriginalReportID(reportID, parentReportAction, reportActionsForOriginalReportID, isOffline); + const [actionReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${actionReportID}`); + + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const currentUserAccountID = currentUserPersonalDetails?.accountID; + const currentUserEmail = currentUserPersonalDetails?.email; + const currentUserLogin = currentUserPersonalDetails?.login; + const currentUserDisplayName = currentUserPersonalDetails?.displayName; + const currentUserLocalCurrencyCode = currentUserPersonalDetails?.localCurrencyCode; + const filteredPoliciesInfoSelector = createFilteredPoliciesInfoSelector(currentUserEmail); + const [filteredPoliciesInfo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: filteredPoliciesInfoSelector}); + const [preferredPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(preferredPolicyID)}`, {selector: billingRestrictionPolicySelector}); const isSelfDM = isSelfDMUtil(report); const isTrackExpenseReport = isTrackExpenseReportUtil(report, parentReport, parentReportAction); const isReportArchived = useReportIsArchived(report?.reportID); const isArchivedRoom = isArchivedNonExpenseReport(report, isReportArchived); + const hasWorkspaceToSubmitToSelector = createHasWorkspaceToSubmitToSelector(currentUserLogin); + const [hasWorkspaceToSubmitTo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: hasWorkspaceToSubmitToSelector}); if (isSelfDM) { return null; @@ -44,17 +91,147 @@ function ReportDetailsMenuItems({reportID, requestData}: ReportDetailsMenuItemsP return null; } + let trackExpenseItems: React.ReactNode = null; + if (isTrackExpenseReport && requestData && !requestData.isDeletedParentAction) { + const {iouTransactionID, moneyRequestReport, moneyRequestReportActions, iouTransaction, iouOriginalTransaction} = requestData; + const whisperAction = getTrackExpenseActionableWhisper(iouTransactionID, moneyRequestReport?.reportID, moneyRequestReportActions); + const actionableWhisperReportActionID = whisperAction?.reportActionID; + const currentUserLocalCurrency = currentUserLocalCurrencyCode ?? CONST.CURRENCY.USD; + const {isExpenseSplit: isSelfDMExpenseSplit} = getOriginalTransactionWithSplitInfo(iouTransaction, iouOriginalTransaction); + + let submitItems: React.ReactNode = null; + // Hide the "Submit it to someone" option for self-DM split expenses when the user isn't a member of any workspace. + if (!isSelfDMExpenseSplit || hasWorkspaceToSubmitTo) { + const baseSubmitParams = { + reportID: actionReportID, + reportActions: actionReportActions, + reportActionID: actionableWhisperReportActionID, + introSelected, + draftTransactionIDs, + activePolicy, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + restrictedPreferredPolicy: isRestrictedToPreferredPolicy ? preferredPolicy : undefined, + transaction: iouTransaction, + currentUserAccountID, + currentUserEmail: currentUserEmail ?? '', + currentUserLocalCurrency, + filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, + firstPolicy: filteredPoliciesInfo?.firstPolicy, + }; + // "Submit to someone" splits into two destinations here too, matching the track-expense whisper: + // submit to an individual ("a friend") or a submit-enabled workspace ("my employer"). + const defaultWorkspaceName = generateDefaultWorkspaceName(currentUserEmail ?? '', currentUserDisplayName, lastWorkspaceNumber, translate); + + submitItems = ( + <> + {/* Self-DM split expenses can only be submitted to a workspace, so the "a friend" destination is omitted here + just like it is on the track-expense whisper. A DM is not a valid destination for per diem and time expenses, + nor for a user restricted to one workspace. */} + {!isSelfDMExpenseSplit && !isRestrictedToPreferredPolicy && !isPerDiemRequest(iouTransaction) && !isTimeRequest(iouTransaction) && ( + { + createDraftTransactionAndNavigateToParticipantSelector({ + ...baseSubmitParams, + actionName: CONST.IOU.ACTION.SUBMIT, + submitDestination: CONST.IOU.SUBMIT_DESTINATION.FRIEND, + defaultWorkspaceName, + }); + }} + isAnonymousAction={false} + shouldShowRightIcon + /> + )} + { + createDraftTransactionAndNavigateToParticipantSelector({ + ...baseSubmitParams, + actionName: CONST.IOU.ACTION.SUBMIT, + submitDestination: CONST.IOU.SUBMIT_DESTINATION.EMPLOYER, + defaultWorkspaceName, + }); + }} + isAnonymousAction={false} + shouldShowRightIcon + /> + + ); + } + + trackExpenseItems = ( + <> + {submitItems} + {Permissions.canUseTrackFlows() && ( + <> + { + createDraftTransactionAndNavigateToParticipantSelector({ + reportID: actionReportID, + reportActions: actionReportActions, + actionName: CONST.IOU.ACTION.CATEGORIZE, + reportActionID: actionableWhisperReportActionID, + introSelected, + draftTransactionIDs, + activePolicy, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + transaction: iouTransaction, + currentUserAccountID, + currentUserEmail: currentUserEmail ?? '', + currentUserLocalCurrency, + filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, + firstPolicy: filteredPoliciesInfo?.firstPolicy, + }); + }} + isAnonymousAction={false} + shouldShowRightIcon + /> + { + createDraftTransactionAndNavigateToParticipantSelector({ + reportID: actionReportID, + reportActions: actionReportActions, + actionName: CONST.IOU.ACTION.SHARE, + reportActionID: actionableWhisperReportActionID, + introSelected, + draftTransactionIDs, + activePolicy, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + transaction: iouTransaction, + currentUserAccountID, + currentUserEmail: currentUserEmail ?? '', + currentUserLocalCurrency, + filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, + firstPolicy: filteredPoliciesInfo?.firstPolicy, + }); + }} + isAnonymousAction={false} + shouldShowRightIcon + /> + + )} + + ); + } + return ( <> - {isTrackExpenseReport && !!requestData && !requestData.isDeletedParentAction && ( - - )} + {trackExpenseItems} diff --git a/src/pages/DynamicReportDetailsPage/ReportDetailsMenuTrackExpenseItems.tsx b/src/pages/DynamicReportDetailsPage/ReportDetailsMenuTrackExpenseItems.tsx deleted file mode 100644 index fd396f5f23dd..000000000000 --- a/src/pages/DynamicReportDetailsPage/ReportDetailsMenuTrackExpenseItems.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import MenuItem from '@components/MenuItem'; - -import useActivePolicy from '@hooks/useActivePolicy'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useLastWorkspaceNumber from '@hooks/useLastWorkspaceNumber'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import useOnyx from '@hooks/useOnyx'; -import useParentReportAction from '@hooks/useParentReportAction'; -import usePreferredPolicy from '@hooks/usePreferredPolicy'; - -import {generateDefaultWorkspaceName} from '@libs/actions/Policy/Policy'; -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import Permissions from '@libs/Permissions'; -import {getTrackExpenseActionableWhisper} from '@libs/ReportActionsUtils'; -import {getOriginalReportID} from '@libs/ReportUtils'; -import {getOriginalTransactionWithSplitInfo, isPerDiemRequest, isTimeRequest} from '@libs/TransactionUtils'; - -import {createDraftTransactionAndNavigateToParticipantSelector} from '@userActions/IOU/StartExpenseFlows'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -import {billingRestrictionPolicySelector, createFilteredPoliciesInfoSelector, createHasWorkspaceToSubmitToSelector} from '@selectors/Policy'; -import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; -import React from 'react'; - -import type {ReportDetailsRequestData} from './types'; - -type ReportDetailsMenuTrackExpenseItemsProps = { - reportID: string; - requestData: ReportDetailsRequestData; -}; - -function ReportDetailsMenuTrackExpenseItems({reportID, requestData}: ReportDetailsMenuTrackExpenseItemsProps) { - const {translate} = useLocalize(); - const {isOffline} = useNetwork(); - const {isRestrictedToPreferredPolicy, preferredPolicyID} = usePreferredPolicy(); - const activePolicy = useActivePolicy(); - const lastWorkspaceNumber = useLastWorkspaceNumber(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Send', 'Folder', 'UserPlus']); - - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); - const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); - const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); - - const parentReportAction = useParentReportAction(report); - - const [reportActionsForOriginalReportID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`); - // The report from which a tracked expense would be submitted/categorized/shared, and its actions - - // createDraftTransactionAndNavigateToParticipantSelector uses them to find the linked track-expense action - const actionReportID = getOriginalReportID(reportID, parentReportAction, reportActionsForOriginalReportID, isOffline); - const [actionReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${actionReportID}`); - - const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const currentUserAccountID = currentUserPersonalDetails?.accountID; - const currentUserEmail = currentUserPersonalDetails?.email; - const currentUserLogin = currentUserPersonalDetails?.login; - const currentUserDisplayName = currentUserPersonalDetails?.displayName; - const currentUserLocalCurrencyCode = currentUserPersonalDetails?.localCurrencyCode; - const filteredPoliciesInfoSelector = createFilteredPoliciesInfoSelector(currentUserEmail); - const [filteredPoliciesInfo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: filteredPoliciesInfoSelector}); - const [preferredPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(preferredPolicyID)}`, {selector: billingRestrictionPolicySelector}); - const hasWorkspaceToSubmitToSelector = createHasWorkspaceToSubmitToSelector(currentUserLogin); - const [hasWorkspaceToSubmitTo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: hasWorkspaceToSubmitToSelector}); - - const {iouTransactionID, moneyRequestReport, moneyRequestReportActions, iouTransaction, iouOriginalTransaction} = requestData; - const whisperAction = getTrackExpenseActionableWhisper(iouTransactionID, moneyRequestReport?.reportID, moneyRequestReportActions); - const actionableWhisperReportActionID = whisperAction?.reportActionID; - const currentUserLocalCurrency = currentUserLocalCurrencyCode ?? CONST.CURRENCY.USD; - const {isExpenseSplit: isSelfDMExpenseSplit} = getOriginalTransactionWithSplitInfo(iouTransaction, iouOriginalTransaction); - - let submitItems: React.ReactNode = null; - // Hide the "Submit it to someone" option for self-DM split expenses when the user isn't a member of any workspace. - if (!isSelfDMExpenseSplit || hasWorkspaceToSubmitTo) { - const baseSubmitParams = { - reportID: actionReportID, - reportActions: actionReportActions, - reportActionID: actionableWhisperReportActionID, - introSelected, - draftTransactionIDs, - activePolicy, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - restrictedPreferredPolicy: isRestrictedToPreferredPolicy ? preferredPolicy : undefined, - transaction: iouTransaction, - currentUserAccountID, - currentUserEmail: currentUserEmail ?? '', - currentUserLocalCurrency, - filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, - firstPolicy: filteredPoliciesInfo?.firstPolicy, - }; - // "Submit to someone" splits into two destinations here too, matching the track-expense whisper: - // submit to an individual ("a friend") or a submit-enabled workspace ("my employer"). - const defaultWorkspaceName = generateDefaultWorkspaceName(currentUserEmail ?? '', currentUserDisplayName, lastWorkspaceNumber, translate); - - submitItems = ( - <> - {/* Self-DM split expenses can only be submitted to a workspace, so the "a friend" destination is omitted here - just like it is on the track-expense whisper. A DM is not a valid destination for per diem and time expenses, - nor for a user restricted to one workspace. */} - {!isSelfDMExpenseSplit && !isRestrictedToPreferredPolicy && !isPerDiemRequest(iouTransaction) && !isTimeRequest(iouTransaction) && ( - { - createDraftTransactionAndNavigateToParticipantSelector({ - ...baseSubmitParams, - actionName: CONST.IOU.ACTION.SUBMIT, - submitDestination: CONST.IOU.SUBMIT_DESTINATION.FRIEND, - defaultWorkspaceName, - }); - }} - isAnonymousAction={false} - shouldShowRightIcon - /> - )} - { - createDraftTransactionAndNavigateToParticipantSelector({ - ...baseSubmitParams, - actionName: CONST.IOU.ACTION.SUBMIT, - submitDestination: CONST.IOU.SUBMIT_DESTINATION.EMPLOYER, - defaultWorkspaceName, - }); - }} - isAnonymousAction={false} - shouldShowRightIcon - /> - - ); - } - - return ( - <> - {submitItems} - {Permissions.canUseTrackFlows() && ( - <> - { - createDraftTransactionAndNavigateToParticipantSelector({ - reportID: actionReportID, - reportActions: actionReportActions, - actionName: CONST.IOU.ACTION.CATEGORIZE, - reportActionID: actionableWhisperReportActionID, - introSelected, - draftTransactionIDs, - activePolicy, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - transaction: iouTransaction, - currentUserAccountID, - currentUserEmail: currentUserEmail ?? '', - currentUserLocalCurrency, - filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, - firstPolicy: filteredPoliciesInfo?.firstPolicy, - }); - }} - isAnonymousAction={false} - shouldShowRightIcon - /> - { - createDraftTransactionAndNavigateToParticipantSelector({ - reportID: actionReportID, - reportActions: actionReportActions, - actionName: CONST.IOU.ACTION.SHARE, - reportActionID: actionableWhisperReportActionID, - introSelected, - draftTransactionIDs, - activePolicy, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - transaction: iouTransaction, - currentUserAccountID, - currentUserEmail: currentUserEmail ?? '', - currentUserLocalCurrency, - filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, - firstPolicy: filteredPoliciesInfo?.firstPolicy, - }); - }} - isAnonymousAction={false} - shouldShowRightIcon - /> - - )} - - ); -} - -export default ReportDetailsMenuTrackExpenseItems; diff --git a/src/pages/DynamicReportDetailsPage/index.tsx b/src/pages/DynamicReportDetailsPage/index.tsx index ec3c096548b4..c0a48a894224 100644 --- a/src/pages/DynamicReportDetailsPage/index.tsx +++ b/src/pages/DynamicReportDetailsPage/index.tsx @@ -1,24 +1,115 @@ -import ReportOrNotFoundGuard from '@components/ReportOrNotFoundGuard'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import getBase62ReportID from '@libs/getBase62ReportID'; +import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportDetailsNavigatorParamList} from '@libs/Navigation/types'; +import Permissions from '@libs/Permissions'; +import { + isFinancialReportsForBusinesses as isFinancialReportsForBusinessesUtil, + isInvoiceReport as isInvoiceReportUtil, + isMoneyRequest as isMoneyRequestUtil, + isMoneyRequestReport as isMoneyRequestReportUtil, + isSelfDM as isSelfDMUtil, +} from '@libs/ReportUtils'; + +import type {WithReportOrNotFoundProps} from '@pages/inbox/report/withReportOrNotFound'; +import withReportOrNotFound from '@pages/inbox/report/withReportOrNotFound'; + +import {getReportPrivateNote} from '@userActions/Report'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +import React, {useEffect} from 'react'; +import {View} from 'react-native'; + +import ReportDetailsActions from './ReportDetailsActions'; +import ReportDetailsAvatar from './ReportDetailsAvatar'; +import ReportDetailsDescription from './ReportDetailsDescription'; +import ReportDetailsNameSection from './ReportDetailsNameSection'; +import ReportDetailsPromotedActions from './ReportDetailsPromotedActions'; +import ReportDetailsTitleSection from './ReportDetailsTitleSection'; -import React from 'react'; +type DynamicReportDetailsPageProps = WithReportOrNotFoundProps & PlatformStackScreenProps; -import ReportDetailsContent from './ReportDetailsContent'; +function DynamicReportDetailsPage({report, reportLoadingState}: DynamicReportDetailsPageProps) { + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const styles = useThemeStyles(); + const navigateBackFromReportDetailsPath = useDynamicBackPath(DYNAMIC_ROUTES.REPORT_DETAILS.path); + const isMoneyRequestReport = isMoneyRequestReportUtil(report); + const isMoneyRequest = isMoneyRequestUtil(report); + const isInvoiceReport = isInvoiceReportUtil(report); + const isFinancialReportsForBusinesses = isFinancialReportsForBusinessesUtil(report); + const isSelfDM = isSelfDMUtil(report); + const isExpenseReport = isMoneyRequestReport || isInvoiceReport || isMoneyRequest; + const base62ReportID = getBase62ReportID(Number(report.reportID)); -type DynamicReportDetailsPageProps = PlatformStackScreenProps; + const isPrivateNotesFetchTriggered = reportLoadingState?.isLoadingPrivateNotes !== undefined; -function DynamicReportDetailsPage({route}: DynamicReportDetailsPageProps) { - const reportID = route.params.reportID; + useEffect(() => { + // Do not fetch private notes if the feature is disabled, isLoadingPrivateNotes is already defined, the network is offline, or if the report is a self DM. + if (!Permissions.canUsePrivateNotes() || isPrivateNotesFetchTriggered || isOffline || isSelfDM) { + return; + } + + getReportPrivateNote(report?.reportID); + }, [report?.reportID, isOffline, isPrivateNotesFetchTriggered, isSelfDM]); return ( - - - + + + Navigation.goBack(navigateBackFromReportDetailsPath)} + /> + + + + + {isExpenseReport ? : } + + + + {isFinancialReportsForBusinesses && ( + <> + + + + )} + + + + + + + ); } -export default DynamicReportDetailsPage; +export default withReportOrNotFound()(DynamicReportDetailsPage); diff --git a/src/pages/LogInWithShortLivedAuthTokenPage.tsx b/src/pages/LogInWithShortLivedAuthTokenPage.tsx index 606b54917c0f..3a7d93beffc8 100644 --- a/src/pages/LogInWithShortLivedAuthTokenPage.tsx +++ b/src/pages/LogInWithShortLivedAuthTokenPage.tsx @@ -26,7 +26,6 @@ type LogInWithShortLivedAuthTokenPageProps = PlatformStackScreenProps { - Log.warn('Unable to sign in with shortLivedAuthToken', {error: signInError}); - }); + signInWithShortLivedAuthToken(token, isSAML, isSAML ? lastVisitedPath : undefined, credentials?.login); // For SAML sign-ins, navigate to HOME explicitly since the SAML flow // doesn't use exitTo deep link routing. For non-SAML flows, let the // navigation system handle exitTo routing naturally via setUpPoliciesAndNavigate. diff --git a/src/pages/LogOutPreviousUserPage.tsx b/src/pages/LogOutPreviousUserPage.tsx index ab192de485c8..0bf187abac7b 100644 --- a/src/pages/LogOutPreviousUserPage.tsx +++ b/src/pages/LogOutPreviousUserPage.tsx @@ -1,22 +1,17 @@ import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import {useInitialURLState} from '@components/InitialURLContextProvider'; -import {ModalActions} from '@components/Modal/Global/ModalContext'; -import useConfirmModal from '@hooks/useConfirmModal'; -import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import Log from '@libs/Log'; -import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath'; -import navigationRef from '@libs/Navigation/navigationRef'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {getLastShortAuthToken} from '@libs/Network/NetworkStore'; -import {getEmailFromTransitionURL, isLoggingInAsDelegate as isLoggingInAsDelegateSessionUtils, isLoggingInAsNewUser as isLoggingInAsNewUserSessionUtils} from '@libs/SessionUtils'; +import {isLoggingInAsDelegate as isLoggingInAsDelegateSessionUtils, isLoggingInAsNewUser as isLoggingInAsNewUserSessionUtils} from '@libs/SessionUtils'; import Navigation from '@navigation/Navigation'; import type {AuthScreensParamList} from '@navigation/types'; -import {isAnonymousUser, isDelegateSession, signInWithShortLivedAuthToken, signInWithSupportAuthToken, signOutAndRedirectToSignIn} from '@userActions/Session'; +import {isDelegateSession, signInWithShortLivedAuthToken, signInWithSupportAuthToken, signOutAndRedirectToSignIn} from '@userActions/Session'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; @@ -25,9 +20,7 @@ import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import React, {useEffect, useState} from 'react'; - -import NotFoundPage from './ErrorPage/NotFoundPage'; +import React, {useEffect} from 'react'; type LogOutPreviousUserPageProps = PlatformStackScreenProps; @@ -36,99 +29,26 @@ type LogOutPreviousUserPageProps = PlatformStackScreenProps { - if (!initialURL) { - return; - } - const sessionEmail = session?.email; - const transitionURL = CONFIG.IS_HYBRID_APP ? `${CONST.DEEPLINK_BASE_URL}${initialURL}` : initialURL; - const isLoggingInAsNewUser = isLoggingInAsNewUserSessionUtils(transitionURL, sessionEmail); + const transitionURL = CONFIG.IS_HYBRID_APP ? `${CONST.DEEPLINK_BASE_URL}${initialURL ?? ''}` : initialURL; + const isLoggingInAsNewUser = isLoggingInAsNewUserSessionUtils(transitionURL ?? undefined, sessionEmail); const isSupportalLogin = authTokenType === CONST.AUTH_TOKEN_TYPES.SUPPORT; if (isLoggingInAsNewUser) { - if (isSupportalLogin) { - Log.info('[LogOutPreviousUserPage] Signing out for a transition to another user', false, { - isLinkNamingDelegator: isLoggingInAsDelegateSessionUtils(transitionURL ?? undefined), - isDelegateSession: isDelegateSession(session), - isSupportalLogin, - }); - // We don't want to close react-native app in this particular case. - signOutAndRedirectToSignIn(false, isSupportalLogin, true, undefined, CONST.SIGN_OUT_REASON.LOGIN_AS_NEW_USER); - return; - } - - if (isAnonymousUser(session)) { - // We don't want to close react-native app in this particular case. - Navigation.isNavigationReady().then(() => { - if (lastVisitedPath) { - try { - // Rebuilt like a cold start restore of this path, so the sign-in modal opens over the last public room instead of a blank loader. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - navigationRef.resetRoot({...getAdaptedStateFromPath(lastVisitedPath as Route), stale: true}); - } catch (error) { - // A path saved by an older build may no longer exist. - Log.warn('Unable to restore the last visited path for an anonymous user', {error}); - Navigation.goBack(); - } - } else { - // We must call goBack() to remove the /transition route from history - Navigation.goBack(); - } - Log.info('[LogOutPreviousUserPage] Signing out for a transition to another user', false, { - isLinkNamingDelegator: isLoggingInAsDelegateSessionUtils(transitionURL ?? undefined), - isDelegateSession: isDelegateSession(session), - isSupportalLogin, - }); - signOutAndRedirectToSignIn(false, isSupportalLogin, true, undefined, CONST.SIGN_OUT_REASON.LOGIN_AS_NEW_USER); - }); - return; - } - - const linkEmail = getEmailFromTransitionURL(transitionURL); - - if (!linkEmail) { - showConfirmModal({ - title: translate('deeplinkWrapper.notValid'), - prompt: translate('deeplinkWrapper.sessionMismatch'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }).then(() => { - Navigation.isNavigationReady().then(() => { - Navigation.goBack(ROUTES.HOME); - }); - }); - return; - } - - showConfirmModal({ - title: translate('deeplinkWrapper.switchAccount.title'), - prompt: translate('deeplinkWrapper.switchAccount.prompt', {newEmail: linkEmail, currentEmail: sessionEmail ?? ''}), - confirmText: translate('deeplinkWrapper.switchAccount.confirm'), - cancelText: translate('deeplinkWrapper.switchAccount.staySignedIn'), - }).then((result) => { - if (result.action !== ModalActions.CONFIRM) { - setHasCancelledSwitch(true); - return; - } - Log.info('[LogOutPreviousUserPage] Signing out for a transition to another user', false, { - isLinkNamingDelegator: isLoggingInAsDelegateSessionUtils(transitionURL ?? undefined), - isDelegateSession: isDelegateSession(session), - isSupportalLogin, - }); - // We don't want to close react-native app in this particular case. - signOutAndRedirectToSignIn(false, isSupportalLogin, true, undefined, CONST.SIGN_OUT_REASON.LOGIN_AS_NEW_USER); + Log.info('[LogOutPreviousUserPage] Signing out for a transition to another user', false, { + isLinkNamingDelegator: isLoggingInAsDelegateSessionUtils(transitionURL ?? undefined), + isDelegateSession: isDelegateSession(session), + isSupportalLogin, }); + // We don't want to close react-native app in this particular case. + signOutAndRedirectToSignIn(false, isSupportalLogin, true, undefined, CONST.SIGN_OUT_REASON.LOGIN_AS_NEW_USER); return; } @@ -146,7 +66,7 @@ function LogOutPreviousUserPage({route}: LogOutPreviousUserPageProps) { }); return; } - const isLoggingInAsDelegate = isLoggingInAsDelegateSessionUtils(transitionURL); + const isLoggingInAsDelegate = isLoggingInAsDelegateSessionUtils(transitionURL ?? undefined); if (isLoggingInAsDelegate) { return; @@ -154,41 +74,16 @@ function LogOutPreviousUserPage({route}: LogOutPreviousUserPageProps) { // Even if the user was already authenticated in NewDot, we need to reauthenticate them with shortLivedAuthToken, // because the old authToken stored in Onyx may be invalid. - signInWithShortLivedAuthToken(shortLivedAuthToken, session?.authToken, false, undefined, undefined) - .then((response) => { - if (response?.type !== CONST.ERROR_TYPE.SESSION_MISMATCH) { - return; - } - showConfirmModal({ - title: translate('deeplinkWrapper.notValid'), - prompt: translate('deeplinkWrapper.sessionMismatch'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }).then(() => { - // Go to the home page once the user dismisses this. The exitTo navigation below only runs when !CONFIG.IS_HYBRID_APP, so nothing else leaves /transition on HybridApp. - Navigation.isNavigationReady().then(() => { - Navigation.goBack(ROUTES.HOME); - }); - }); - }) - .catch((error) => Log.warn('Unable to sign in with shortLivedAuthToken', {error})); + signInWithShortLivedAuthToken(shortLivedAuthToken, false, undefined, undefined); // We only want to run this effect once on mount (when the page first loads after transitioning from OldDot) // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialURL]); useEffect(() => { - if (hasCancelledSwitch) { - Navigation.isNavigationReady().then(() => { - Navigation.goBack(ROUTES.HOME); - }); - return; - } - const sessionEmail = session?.email; const transitionURL = CONFIG.IS_HYBRID_APP ? `${CONST.DEEPLINK_BASE_URL}${initialURL ?? ''}` : initialURL; const isLoggingInAsNewUser = isLoggingInAsNewUserSessionUtils(transitionURL ?? undefined, sessionEmail); - // We don't want to navigate to the exitTo route when creating a new workspace from a deep link, // because we already handle creating the optimistic policy and navigating to it in App.setUpPoliciesAndNavigate, // which is already called when AuthScreens mounts. @@ -203,14 +98,7 @@ function LogOutPreviousUserPage({route}: LogOutPreviousUserPageProps) { }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [initialURL, isAccountLoading, hasCancelledSwitch]); - - // Linking.getInitialURL() only ever resolves the URL that launched the app. - // If it's still unset once it has settled, this mount came from tapping a link inside the app (e.g. a link in a message), which this page can't process. - // Show a not-found page instead of hanging here. - if (!isLoadingInitialURL && !initialURL) { - return ; - } + }, [initialURL, isAccountLoading]); return ; } diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx index 40858a9131c5..435e2fbaf273 100644 --- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx +++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx @@ -27,7 +27,7 @@ import {expensifyLoginsSelector, isCurrentUserValidated} from '@libs/UserUtils'; import variables from '@styles/variables'; import {completeOnboarding} from '@userActions/Report'; -import {clearOnboardingMergeAccountBlocked, setOnboardingErrorMessage, setOnboardingPurposeSelected} from '@userActions/Welcome'; +import {setOnboardingErrorMessage, setOnboardingPurposeSelected} from '@userActions/Welcome'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -132,9 +132,6 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro // validation, so it still needs to add one; an unvalidated private-domain one already has one and // only needs to validate it. if (choice === CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE) { - // A merge blocked earlier in onboarding leaves isMergingAccountBlocked set, which would make the work - // email step reopen in its blocked state instead of showing the form. - clearOnboardingMergeAccountBlocked(); const isCurrentPrimaryPublicDomain = PUBLIC_DOMAINS_SET.has(session?.email?.split('@').at(1)?.toLowerCase() ?? ''); if (isValidated && !isCurrentPrimaryPublicDomain) { Navigation.navigate(ROUTES.ONBOARDING_WORKSPACES.getRoute(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute())); diff --git a/src/pages/SetDefaultWorkspacePage.tsx b/src/pages/SetDefaultWorkspacePage.tsx index 306e58a9e913..05feaeebc17f 100644 --- a/src/pages/SetDefaultWorkspacePage.tsx +++ b/src/pages/SetDefaultWorkspacePage.tsx @@ -16,6 +16,7 @@ import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceList from '@hooks/useWorkspaceList'; +import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {canRoleCreateExpenses, isGroupPolicy} from '@libs/PolicyUtils'; @@ -55,13 +56,17 @@ function SetDefaultWorkspacePage({route}: SetDefaultWorkspacePageProps) { if (!selectedPolicyID) { return; } + if (!navigateTo) { + Log.hmmm(`[SetDefaultWorkspacePage] navigateTo is undefined. Cannot navigate after setting default workspace to ${selectedPolicyID}`); + return; + } + const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedPolicyID}`]; // eslint-disable-next-line rulesdir/no-default-id-values setNameValuePair(ONYXKEYS.NVP_ACTIVE_POLICY_ID, selectedPolicyID, activePolicyID ?? ''); - // Without a destination, the caller handles what comes next once it is back in focus. - if (navigateTo && policy?.areCategoriesEnabled) { + if (policy?.areCategoriesEnabled) { Navigation.navigate(navigateTo); return; } diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index 64c84801309f..9fe85deb7a6e 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -1,12 +1,12 @@ import useOnyx from '@hooks/useOnyx'; import useOriginalReportID from '@hooks/useOriginalReportID'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import Log from '@libs/Log'; import {chatIncludesConcierge} from '@libs/ReportUtils'; import {useReportActionActiveEdit} from '@pages/inbox/report/ReportActionEditMessageContext'; -import useShouldEditInComposer from '@pages/inbox/report/useShouldEditInComposer'; import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; @@ -44,7 +44,7 @@ type ComposerProviderProps = { }; function ComposerProvider({children, reportID}: ComposerProviderProps) { - const shouldEditInComposer = useShouldEditInComposer(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); const [initialModalState] = useOnyx(ONYXKEYS.MODAL); @@ -82,7 +82,7 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { const [didResetComposerHeightWhileEditing, setDidResetComposerHeightWhileEditing] = useState(false); - const isEditingInComposer = shouldEditInComposer && editingState !== 'off' && !didResetComposerHeightWhileEditing; + const isEditingInComposer = shouldUseNarrowLayout && editingState !== 'off' && !didResetComposerHeightWhileEditing; const effectiveDraft = isEditingInComposer ? editingMessage : draftComment; const {debouncedCommentMaxLengthValidation, exceededMaxLength, isExceedingMaxLength, isTaskTitle} = useDebouncedCommentMaxLengthValidation({ diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 128ef3727bc5..9f73d1198880 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -40,7 +40,6 @@ import {useReportActionActiveEditActions} from '@pages/inbox/report/ReportAction import useDebouncedSaveDraft from '@pages/inbox/report/useDebouncedSaveDraft'; import useDebouncedSaveReportActionDraft from '@pages/inbox/report/useDebouncedSaveReportActionDraft'; import useDraftMessageVideoAttributeCache from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; -import useShouldEditInComposer from '@pages/inbox/report/useShouldEditInComposer'; import {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; import type {OnEmojiSelected} from '@userActions/EmojiPickerAction'; @@ -216,7 +215,6 @@ function ComposerWithSuggestions({ const emojisPresentBefore = useRef([]); const isInSidePanel = useIsInSidePanel(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const shouldEditInComposer = useShouldEditInComposer(); const composerRef = useRef(null); @@ -535,7 +533,7 @@ function ComposerWithSuggestions({ } commentRef.current = newCommentConverted; - if (editingState === CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING && shouldEditInComposer) { + if (editingState === CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING && shouldUseNarrowLayout) { setEditingMessage(newCommentConverted); if (shouldDebounceSaveComment) { debouncedSaveReportActionDraft(editingReportID ?? reportID, editingReportAction, reportActions, newCommentConverted); @@ -566,7 +564,7 @@ function ComposerWithSuggestions({ setText, onValueChange, editingState, - shouldEditInComposer, + shouldUseNarrowLayout, suggestionsRef, setCurrentEditMessageSelection, setEditingMessage, diff --git a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts index 2885746d4634..c740bd597cec 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts @@ -1,6 +1,6 @@ import type {ComposerRef, TextSelection} from '@components/Composer/types'; -import useShouldEditInComposer from '@pages/inbox/report/useShouldEditInComposer'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import CONST from '@src/CONST'; @@ -35,7 +35,7 @@ type UseEditComposerToggleProps = { * and to update the selection of the composer when the editing state is toggled on. */ function useEditComposerToggle({selection, composerRef, onFocus, onValueChange, onSelectionChange}: UseEditComposerToggleProps) { - const shouldEditInComposer = useShouldEditInComposer(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const {isEditingInComposer, editingState, editingReportActionID, editingMessage, currentEditMessageSelection, draftComment} = useComposerEditState(); const {setDidResetComposerHeightWhileEditing} = useComposerEditActions(); @@ -43,7 +43,7 @@ function useEditComposerToggle({selection, composerRef, onFocus, onValueChange, const isEditing = editingState !== CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.OFF; const wasEditingRef = useRef(isEditing); - const wasEditingInComposerRef = useRef(shouldEditInComposer); + const wasEditingInComposerRef = useRef(shouldUseNarrowLayout); const previousDraftSelectionRef = useRef(null); const previousEditingReportActionIDRef = useRef(null); const previousTextRef = useRef(null); @@ -100,7 +100,7 @@ function useEditComposerToggle({selection, composerRef, onFocus, onValueChange, } wasEditingRef.current = false; - wasEditingInComposerRef.current = shouldEditInComposer; + wasEditingInComposerRef.current = shouldUseNarrowLayout; previousDraftSelectionRef.current = null; return; } @@ -111,9 +111,9 @@ function useEditComposerToggle({selection, composerRef, onFocus, onValueChange, previousDraftSelectionRef.current = selection; wasEditingRef.current = true; - wasEditingInComposerRef.current = shouldEditInComposer; + wasEditingInComposerRef.current = shouldUseNarrowLayout; - if (!shouldEditInComposer) { + if (!shouldUseNarrowLayout) { // Wide layout – another editor handles the edit, keep composer draft as-is. return; } @@ -124,7 +124,7 @@ function useEditComposerToggle({selection, composerRef, onFocus, onValueChange, } // Editing is ongoing and layout toggled from wide to narrow. - if (shouldEditInComposer && !wasEditingInComposerRef.current) { + if (shouldUseNarrowLayout && !wasEditingInComposerRef.current) { wasEditingInComposerRef.current = true; // We just moved from wide to narrow while editing – start editing in the composer. applyComposerValue(editingMessage ?? '', {isEditingInComposer: true}); @@ -132,14 +132,14 @@ function useEditComposerToggle({selection, composerRef, onFocus, onValueChange, } // Editing is ongoing and layout toggled from narrow to wide. - if (!shouldEditInComposer && wasEditingInComposerRef.current) { + if (!shouldUseNarrowLayout && wasEditingInComposerRef.current) { wasEditingInComposerRef.current = false; applyComposerValue(draftComment ?? ''); return; } // The editing report action and message changed - if (shouldEditInComposer && editingReportActionID !== previousEditingReportActionIDRef.current) { + if (shouldUseNarrowLayout && editingReportActionID !== previousEditingReportActionIDRef.current) { applyComposerValue(editingMessage ?? '', {isEditingInComposer: true, shouldForceNativeValueUpdate: true}); previousEditingReportActionIDRef.current = editingReportActionID; } @@ -154,7 +154,7 @@ function useEditComposerToggle({selection, composerRef, onFocus, onValueChange, onFocus, selection, setDidResetComposerHeightWhileEditing, - shouldEditInComposer, + shouldUseNarrowLayout, draftComment, onValueChange, ]); diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index ec9e5073c49a..da0dcc09a78d 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -50,8 +50,6 @@ import { isActionOfType, isCreatedTaskReportAction, isDeletedParentAction as isDeletedParentActionUtils, - isDeletedReportPreviewWithError, - getVisibleReportActionErrors, isMessageDeleted, isMoneyRequestAction, isPendingRemove, @@ -110,7 +108,6 @@ import ReportActionItemFrame from './ReportActionItemFrame'; import ReportActionItemThread from './ReportActionItemThread'; import SearchActionHeader from './SearchActionHeader'; import TripSummary from './TripSummary'; -import useShouldEditInComposer from './useShouldEditInComposer'; import WhisperBanner from './WhisperBanner'; type ReportActionItemProps = { @@ -226,7 +223,6 @@ function ReportActionItem({ const [actorDisplayName] = usePersonalDetail(action.actorAccountID, displayNameOrDefaultSelector(translate, formatPhoneNumber)); const {showConfirmModal} = useConfirmModal(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const shouldEditInComposer = useShouldEditInComposer(); const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -250,7 +246,7 @@ function ReportActionItem({ const isDeletedParentAction = isDeletedParentActionUtils(action); const draftMessage = editingReportAction && action && editingReportAction.reportActionID === action.reportActionID ? (editingMessage ?? undefined) : undefined; const hasDraft = draftMessage !== undefined; - const isEditingInline = !shouldEditInComposer && hasDraft; + const isEditingInline = !shouldUseNarrowLayout && hasDraft; // IOUDetails only exists when we are sending money const isSendingMoney = isMoneyRequestAction(action) && getOriginalMessage(action)?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && getOriginalMessage(action)?.IOUDetails; @@ -411,25 +407,13 @@ function ReportActionItem({ const disabledActions = !canWriteInReport(report) ? RestrictedReadOnlyContextMenuActions : []; - // An error the payer cannot see must not disable the action, or the preview loses its context menu for no visible reason. - const visibleActionErrors = getVisibleReportActionErrors(action); - - const hasActionErrors = !isEmptyValueObject(visibleActionErrors); + const hasActionErrors = !isEmptyValueObject(action.errors); // Receipt upload errors should still allow the context menu so the user can access "Delete expense" - const hasOnlyReceiptErrors = hasActionErrors && Object.values(visibleActionErrors ?? {}).every((error) => error === null || isReceiptError(error)); + const hasOnlyReceiptErrors = hasActionErrors && Object.values(action.errors ?? {}).every((error) => error === null || isReceiptError(error)); const isContextMenuDisabled = hasDraft || (hasActionErrors && !hasOnlyReceiptErrors) || !shouldDisplayContextMenuValue; - const latestActionErrors = getLatestErrorMessageField({...action, errors: visibleActionErrors} as OnyxDataWithErrors); - - // Once the report is deleted there is nothing to retry, so say what happened instead of "try again later". - let displayedActionErrors = latestActionErrors; - if (isDeletedReportPreviewWithError(action)) { - const payFailedMessage = translate('iou.error.payFailedExpenseDeleted'); - displayedActionErrors = mapValues(latestActionErrors, () => payFailedMessage); - } - /** * Show the ReportActionContextMenu modal popover. * @@ -630,7 +614,7 @@ function ReportActionItem({ hasDraft ? undefined : (action.pendingAction ?? (action.isOptimisticAction ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : undefined)) } shouldHideOnDelete={!isDeletedParentAction} - errors={(linkedTransactionRouteError ?? !isOnSearch) ? displayedActionErrors : {}} + errors={(linkedTransactionRouteError ?? !isOnSearch) ? getLatestErrorMessageField(action as OnyxDataWithErrors) : {}} errorRowStyles={[styles.ml10, styles.mr2]} needsOffscreenAlphaCompositing={isMoneyRequestAction(action)} shouldDisableStrikeThrough diff --git a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx index 9935ff214a26..a78f4a3421e1 100644 --- a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx +++ b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx @@ -44,7 +44,6 @@ import { isCardIssuedAction, isCreatedTaskReportAction, isCreatedSupportTicketReportAction, - isDeletedReportPreviewWithError, isIOURequestReportAction, isMemberChangeAction, isMoneyRequestAction, @@ -213,8 +212,7 @@ function ActionContentRouter({ /> ); } - // This preview has no report left to show, so render the placeholder instead of pointing at a report that is gone. - if (action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && (isClosedExpenseReportWithNoExpenses || isDeletedReportPreviewWithError(action))) { + if (action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && isClosedExpenseReportWithNoExpenses) { return ${translate('parentReportAction.deletedReport')}`} />; } if (action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { diff --git a/src/pages/inbox/report/actionContents/ChatMessageContent.tsx b/src/pages/inbox/report/actionContents/ChatMessageContent.tsx index 2f8025424666..5998ca623eee 100644 --- a/src/pages/inbox/report/actionContents/ChatMessageContent.tsx +++ b/src/pages/inbox/report/actionContents/ChatMessageContent.tsx @@ -5,6 +5,7 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {parseFollowupsFromHtml} from '@libs/ReportActionFollowupUtils'; @@ -19,7 +20,6 @@ import { import ReportActionItemMessage from '@pages/inbox/report/ReportActionItemMessage'; import ReportActionItemMessageEdit from '@pages/inbox/report/ReportActionItemMessageEdit'; -import useShouldEditInComposer from '@pages/inbox/report/useShouldEditInComposer'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -60,8 +60,8 @@ function ChatMessageContent({ const {translate} = useLocalize(); const styles = useThemeStyles(); - const shouldEditInComposer = useShouldEditInComposer(); - const isEditingInline = !shouldEditInComposer && draftMessage !== undefined; + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const isEditingInline = !shouldUseNarrowLayout && draftMessage !== undefined; const mentionReportContextValue = {currentReportID: reportID, exactlyMatch: true}; const attachmentContextValue = isOnSearch ? {type: CONST.ATTACHMENT_TYPE.SEARCH} : {reportID, type: CONST.ATTACHMENT_TYPE.REPORT}; diff --git a/src/pages/inbox/report/useReportDeepLinkOnOpen.ts b/src/pages/inbox/report/useReportDeepLinkOnOpen.ts deleted file mode 100644 index c8abfbf5994c..000000000000 --- a/src/pages/inbox/report/useReportDeepLinkOnOpen.ts +++ /dev/null @@ -1,49 +0,0 @@ -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useOnyx from '@hooks/useOnyx'; - -import {openReport} from '@libs/actions/Report'; - -import ONYXKEYS from '@src/ONYXKEYS'; - -import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; -import {useEffect} from 'react'; - -type UseReportDeepLinkOnOpenParams = { - reportID: string | undefined; - isReportLoaded: boolean; - shouldFetchReport: boolean; -}; - -/** - * When accessing certain report-dependant pages (e.g. Task Title) by deeplink, the OpenReport API is not called, - * So we need to call OpenReport API here to make sure the report data is loaded if it exists on the Server - */ -function useReportDeepLinkOnOpen({reportID, isReportLoaded, shouldFetchReport}: UseReportDeepLinkOnOpenParams) { - const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); - const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); - const [hasReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {selector: Boolean}); - const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); - - useEffect(() => { - if (isReportLoaded || !shouldFetchReport) { - // If the report is not required or is already loaded, we don't need to call the API - return; - } - - openReport({ - reportID, - introSelected, - conciergeChat, - hasReportActions, - currentUserAccountID, - isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed, - hasCompletedGuidedSetupFlow: guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, - }); - // OpenReport should only fire when the fetch decision or report identity changes; the other args are read at call time. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [shouldFetchReport, isReportLoaded, reportID, currentUserAccountID]); -} - -export default useReportDeepLinkOnOpen; diff --git a/src/pages/inbox/report/useShouldEditInComposer.ts b/src/pages/inbox/report/useShouldEditInComposer.ts deleted file mode 100644 index d7e40760961a..000000000000 --- a/src/pages/inbox/report/useShouldEditInComposer.ts +++ /dev/null @@ -1,14 +0,0 @@ -import useResponsiveLayout from '@hooks/useResponsiveLayout'; - -/** - * Whether message edits happen in the bottom composer (#90516) instead of the inline editor. - * This depends only on the screen size, so reports opened in the RHP on a wide screen still edit inline. - */ -function useShouldEditInComposer() { - // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout, because shouldUseNarrowLayout is also true in the RHP on wide screens - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth} = useResponsiveLayout(); - return isSmallScreenWidth; -} - -export default useShouldEditInComposer; diff --git a/src/pages/inbox/report/useShouldShowComposerForActiveEditDraft.ts b/src/pages/inbox/report/useShouldShowComposerForActiveEditDraft.ts index 87f2cc134f22..4ecd2988b1a4 100644 --- a/src/pages/inbox/report/useShouldShowComposerForActiveEditDraft.ts +++ b/src/pages/inbox/report/useShouldShowComposerForActiveEditDraft.ts @@ -1,11 +1,12 @@ +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + import {useReportActionActiveEdit} from './ReportActionEditMessageContext'; -import useShouldEditInComposer from './useShouldEditInComposer'; /** Narrow-screen edits use the bottom composer (#90516); mount it when a draft exists even if posting is admin-only. */ function useShouldShowComposerForActiveEditDraft() { - const shouldEditInComposer = useShouldEditInComposer(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const {editingReportActionID} = useReportActionActiveEdit(); - return shouldEditInComposer && editingReportActionID !== null; + return shouldUseNarrowLayout && editingReportActionID !== null; } export default useShouldShowComposerForActiveEditDraft; diff --git a/src/pages/inbox/report/withReportOrNotFound.tsx b/src/pages/inbox/report/withReportOrNotFound.tsx index cd2fc0b1fef8..706644db9efd 100644 --- a/src/pages/inbox/report/withReportOrNotFound.tsx +++ b/src/pages/inbox/report/withReportOrNotFound.tsx @@ -1,13 +1,14 @@ import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; -import {getReportOrNotFoundDecision} from '@components/ReportOrNotFoundGuard'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useReportIsArchived from '@hooks/useReportIsArchived'; +import {openReport} from '@libs/actions/Report'; import getComponentDisplayName from '@libs/getComponentDisplayName'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import {hasExpensifyGuidesEmails} from '@libs/ReportUtils'; +import {canAccessReport, hasExpensifyGuidesEmails} from '@libs/ReportUtils'; import type { ParticipantsNavigatorParamList, @@ -32,9 +33,8 @@ import type {ComponentType} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {useIsFocused} from '@react-navigation/native'; -import React from 'react'; - -import useReportDeepLinkOnOpen from './useReportDeepLinkOnOpen'; +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; +import React, {useEffect} from 'react'; type WithReportOrNotFoundOnyxProps = { report: OnyxTypes.Report; @@ -79,12 +79,18 @@ export default function (shouldRequireReportID = true): ; - } - - if (decision === 'notFound') { - return ; + // When accessing certain report-dependant pages (e.g. Task Title) by deeplink, the OpenReport API is not called, + // So we need to call OpenReport API here to make sure the report data is loaded if it exists on the Server + useEffect(() => { + if (isReportLoaded || !shouldFetchReport) { + // If the report is not required or is already loaded, we don't need to call the API + return; + } + + openReport({ + reportID, + introSelected, + conciergeChat, + hasReportActions, + currentUserAccountID, + isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed, + hasCompletedGuidedSetupFlow: guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shouldFetchReport, isReportLoaded, reportID, currentUserAccountID]); + + if (shouldRequireReportID || isReportIdInRoute) { + const shouldShowFullScreenLoadingIndicator = !isReportLoaded && (isLoadingReportData !== false || shouldFetchReport); + const shouldShowNotFoundPage = !isReportLoaded || !canAccessReport(report, isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), hasGuidesEmails, isReportArchived); + + // If the content was shown, but it's not anymore, that means the report was deleted, and we are probably navigating out of this screen. + // Return null for this case to avoid rendering FullScreenLoadingIndicator or NotFoundPage when animating transition. + // We also suppress the NotFound page while a delete-transaction navigation is in flight (e.g. deleting an invoice + // navigates back to the invoice room without synchronously removing focus from this details RHP), mirroring ReportNotFoundGuard. + if (shouldShowNotFoundPage && contentShown.current && (!isFocused || !!deleteTransactionNavigateBackUrl)) { + return null; + } + + if (shouldShowFullScreenLoadingIndicator) { + return ; + } + + if (shouldShowNotFoundPage) { + return ; + } } if (!contentShown.current) { diff --git a/src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx b/src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx index e19d22939ae8..cebe3dca5442 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx @@ -59,7 +59,7 @@ type DynamicIOURequestStepUpgradeProps = PlatformStackScreenProps { if (isMultiScanEnabled) { @@ -173,13 +171,7 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou const path = getReceiptsUploadFolderPath(); - const shouldUpgradeToPhoto = canUpgradeReceiptQuality && !isMultiScanEnabled && !shouldTakePhoto({flash, hasFlash, isInLandscapeMode}); - - // The snapshot goes first so its request reaches the native queue ahead of the still. On iOS it - // reads the most recent video frame, which a photo capture can interrupt. - const receiptCapture = captureReceipt(camera.current, {flash, hasFlash, isPlatformMuted, path, isInLandscapeMode}); - - receiptCapture + captureReceipt(camera.current, {flash, hasFlash, isPlatformMuted, path, isInLandscapeMode}) .then((photo: PhotoFile) => { endSpanWithAttributes(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE, { [CONST.TELEMETRY.ATTRIBUTE_PHOTO_WIDTH]: photo.width, @@ -204,10 +196,6 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou type: 'image/jpeg', }; - if (shouldUpgradeToPhoto) { - upgradeReceiptWithPhoto(durableName); - } - onCapture(cameraFile, source); }) .catch((error: string) => { @@ -215,12 +203,7 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou maybeCancelShutterSpan(); showCameraAlert(); Log.warn('Error taking photo', error); - discardPendingPhoto(); }); - - if (shouldUpgradeToPhoto) { - startPhotoCapture(camera.current); - } }; // Wait for camera permission status to render @@ -263,17 +246,12 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou blinkStyle={blinkStyle} isAttachmentPickerActive={isAttachmentPickerActive} didCapturePhoto={didCapturePhoto} - hasPendingPhotoCapture={hasPendingPhotoCapture} onInitialized={handleCameraInitialized} shouldShowFlashButton={canUseMultiScan} cameraPermissionStatus={cameraPermissionStatus} flash={flash} hasFlash={hasFlash} setFlash={setFlash} - // "preview" turns every capture to match the screen, the way the preview is drawn. The default "device" follows the - // phone's physical orientation instead, so a phone held flat over a receipt keeps its last landscape reading and the - // photo comes out sideways. - outputOrientation="preview" /> )} diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts index 44285d9f4d56..4bfae6924dfc 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts @@ -25,9 +25,6 @@ type CameraProps = { /** Called when the user taps the multi-scan submit button on the preview ribbon. Used by native and mobile web. */ onMultiScanSubmit?: () => void; - - /** Whether the shutter may take a full-resolution photo to upgrade the fast snapshot. Defaults to true; off for replace-receipt and multi-scan. */ - canUpgradeReceiptQuality?: boolean; }; export default CameraProps; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx index 101e8a6a6f74..fe860d7e9317 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx @@ -16,7 +16,7 @@ import type {LayoutChangeEvent, ViewStyle} from 'react-native'; import type {GestureType} from 'react-native-gesture-handler'; import type {PermissionStatus} from 'react-native-permissions'; import type {AnimatedStyle} from 'react-native-reanimated'; -import type {Camera, CameraDevice, CameraDeviceFormat, CameraProps} from 'react-native-vision-camera'; +import type {Camera, CameraDevice, CameraDeviceFormat} from 'react-native-vision-camera'; import React from 'react'; import {StyleSheet, View} from 'react-native'; @@ -63,9 +63,6 @@ type CameraViewportProps = { /** Whether a photo has been captured (forces camera inactive) */ didCapturePhoto?: boolean; - /** Whether a full-resolution capture is still running; keeps the camera session active even after didCapturePhoto so the capture is not cancelled */ - hasPendingPhotoCapture?: boolean; - /** Callback fired when the camera finishes initializing */ onInitialized?: () => void; @@ -88,9 +85,6 @@ type CameraViewportProps = { setFlash: (updater: (prev: boolean) => boolean) => void; - /** Whether photos follow the device or the preview orientation, defaults to "device" */ - outputOrientation?: CameraProps['outputOrientation']; - /** Extra content rendered below the viewfinder, inside the camera view */ children?: ReactNode; }; @@ -108,7 +102,6 @@ function CameraViewport({ blinkStyle, isAttachmentPickerActive, didCapturePhoto = false, - hasPendingPhotoCapture = false, onInitialized, onLayout, shouldShowFlashButton, @@ -117,7 +110,6 @@ function CameraViewport({ flash, hasFlash, setFlash, - outputOrientation = 'device', children, }: CameraViewportProps) { const theme = useTheme(); @@ -138,10 +130,8 @@ function CameraViewport({ style={styles.flex1} zoom={device.neutralZoom} photo - outputOrientation={outputOrientation} cameraTabIndex={1} - forceInactive={isAttachmentPickerActive || (didCapturePhoto && !hasPendingPhotoCapture)} - shouldStayActiveWhenBlurred={hasPendingPhotoCapture} + forceInactive={isAttachmentPickerActive || didCapturePhoto} onInitialized={onInitialized} onLayout={onLayout} // Use TextureView on Android to fix partially blank images for takeSnapshot() diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/Camera.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/Camera.tsx index 5f89c563823f..1c32134f7e9a 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/Camera.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/Camera.tsx @@ -10,12 +10,9 @@ import type {NavigationAwareCameraNativeProps} from './types'; const IS_VIDEO_REQUIRED_FOR_SNAPSHOT = Platform.OS === 'ios'; // Wraps a camera that will only be active when the tab is focused or as soon as it starts to become focused. -function Camera({cameraTabIndex, ref, forceInactive = false, shouldStayActiveWhenBlurred = false, ...props}: NavigationAwareCameraNativeProps) { +function Camera({cameraTabIndex, ref, forceInactive = false, ...props}: NavigationAwareCameraNativeProps) { const isFocused = useIsFocused(); - - // The scan screen blurs as soon as a capture navigates away, and closing the session would cancel a - // capture that is still running. - const isCameraActive = (isFocused || shouldStayActiveWhenBlurred) && !forceInactive; + const isCameraActive = isFocused && !forceInactive; return ( & { cameraTabIndex: number; ref?: ForwardedRef; forceInactive?: boolean; - shouldStayActiveWhenBlurred?: boolean; }; export type {NavigationAwareCameraProps, NavigationAwareCameraNativeProps}; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx index 00c256817ced..0812375fac80 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx @@ -123,7 +123,6 @@ function ScanEditReceipt({report, transactionID, backTo, isEditing}: ScanEditRec onPicked={validateFiles} onAttachmentPickerStatusChange={setIsLoaderVisible} isReplacingReceipt - canUpgradeReceiptQuality={false} /> ); diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx index da62ad57d6a2..2e688317087a 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx @@ -406,7 +406,6 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, onAttachmentPickerStatusChange={setIsLoaderVisible} onMultiScanSubmit={submitMultiScan} shouldAcceptMultipleFiles - canUpgradeReceiptQuality={false} /> ); diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts b/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts deleted file mode 100644 index a3a389751940..000000000000 --- a/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts +++ /dev/null @@ -1,316 +0,0 @@ -/** - * Captures a full-resolution photo alongside the fast snapshot and, once a receipt exists, swaps the - * higher-quality photo in behind it. Owns capture deadlines, rotation, and upgrade telemetry. - */ -import Log from '@libs/Log'; -import ReceiptStorage from '@libs/ReceiptStorage'; -import {finish as finishUpgrade, isClaimedForRead as wasReceiptClaimed, recordUpgrade, start as startUpgrade} from '@libs/ReceiptStorage/receiptUpgrades'; -import {endSpanWithAttributes, getSpan, startSpan} from '@libs/telemetry/activeSpans'; - -import rotatePhotoToUpright from '@pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright'; - -import CONST from '@src/CONST'; - -import type {Camera, PhotoFile} from 'react-native-vision-camera'; - -import {useEffect, useRef, useState} from 'react'; -import RNFS from 'react-native-fs'; - -const PHOTO_CAPTURE_TIMEOUT_MS = 3000; - -const ROTATE_TIMEOUT_MS = 5000; - -function discardPhoto(path: string) { - ReceiptStorage.discard(path).catch((error: unknown) => { - Log.warn('[PhotoUpgrade] could not delete the abandoned photo', {error: error instanceof Error ? error.message : String(error)}); - }); -} - -function discardWhenItLands(pending: PendingPhoto) { - pending.promise - .then((photo) => { - if (!photo) { - return; - } - discardPhoto(photo.path); - }) - .catch(() => {}); -} - -class UpgradeStepError extends Error { - step: string; - - hasTimedOut: boolean; - - constructor(step: string, message: string, hasTimedOut: boolean) { - super(`[PhotoUpgrade] ${step} ${message}`); - this.step = step; - this.hasTimedOut = hasTimedOut; - } -} - -function withDeadline(promise: Promise, capMs: number, step: string, discardLateResult?: (value: T) => void): Promise { - let cap: ReturnType; - let hasTimedOut = false; - const deadline = new Promise((_resolve, reject) => { - cap = setTimeout(() => { - hasTimedOut = true; - reject(new UpgradeStepError(step, `did not finish within ${capMs}ms`, true)); - }, capMs); - }); - - if (discardLateResult) { - promise - .then((value) => { - if (!hasTimedOut) { - return; - } - discardLateResult(value); - }) - .catch(() => {}); - } - - return Promise.race([promise, deadline]).finally(() => clearTimeout(cap)); -} - -type PendingPhoto = { - promise: Promise; - - discard: () => void; - - startedAt: number; - - getFailureOutcome: () => string; -}; - -function toUpgradeSpanId(durableName: string) { - return `${CONST.TELEMETRY.SPAN_RECEIPT_UPGRADE}_${durableName}`; -} - -function abandonPending(pending: PendingPhoto) { - pending.discard(); - discardWhenItLands(pending); -} - -function usePhotoUpgrade() { - const [hasPendingPhotoCapture, setHasPendingPhotoCapture] = useState(false); - const pendingPhotoRef = useRef(undefined); - - // `hasPendingPhotoCapture` holds the vision-camera session open: closing it mid-capture cancels the - // photo with "Camera is closed." - const captureCountRef = useRef(0); - - const photosInFlightRef = useRef(0); - - useEffect( - () => () => { - const pending = pendingPhotoRef.current; - if (!pending) { - return; - } - pendingPhotoRef.current = undefined; - abandonPending(pending); - }, - [], - ); - - const releaseSession = () => { - captureCountRef.current -= 1; - if (captureCountRef.current > 0) { - return; - } - setHasPendingPhotoCapture(false); - }; - - const startPhotoCapture = (camera: Camera) => { - const orphaned = pendingPhotoRef.current; - pendingPhotoRef.current = undefined; - if (orphaned) { - abandonPending(orphaned); - } - - // Abandoning a capture does not stop it: `discard` only marks the result for deletion, and - // vision-camera cannot cancel a `takePhoto`. A second one started over a capture still running - // makes them contend, and the next `takeSnapshot` pays for it. 6.6s measured against an 80ms - // baseline. A receipt keeping its snapshot is the cheaper loss. - if (photosInFlightRef.current > 0) { - Log.info('[PhotoUpgrade] a full-resolution capture is still running, so this receipt keeps its snapshot'); - getSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE)?.setAttributes({ - [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_ATTEMPTED]: false, - [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.STACKED_CAPTURE_SKIPPED, - }); - return; - } - - getSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE)?.setAttributes({[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_ATTEMPTED]: true}); - - const startedAt = Date.now(); - let failureOutcome: string = CONST.TELEMETRY.UPGRADE_OUTCOME.CAPTURE_FAILED; - let isDiscarded = false; - - const nativeCapture = camera.takePhoto({flash: 'off', enableShutterSound: false, path: RNFS.TemporaryDirectoryPath}); - - photosInFlightRef.current += 1; - const releasePhotoSlot = () => { - photosInFlightRef.current -= 1; - }; - nativeCapture.then(releasePhotoSlot, releasePhotoSlot); - - const capture = nativeCapture.then( - (photo) => { - if (!isDiscarded) { - return photo; - } - discardPhoto(photo.path); - return undefined; - }, - (error: unknown) => { - Log.warn('[PhotoUpgrade] full-resolution capture failed', {error: error instanceof Error ? error.message : String(error)}); - failureOutcome = CONST.TELEMETRY.UPGRADE_OUTCOME.CAPTURE_FAILED; - return undefined; - }, - ); - - let deadlineTimeout: ReturnType; - const deadline = new Promise((resolve) => { - deadlineTimeout = setTimeout(() => { - isDiscarded = true; - failureOutcome = CONST.TELEMETRY.UPGRADE_OUTCOME.CAPTURE_TIMED_OUT; - resolve(undefined); - }, PHOTO_CAPTURE_TIMEOUT_MS); - }); - - const promise = Promise.race([capture, deadline]).then((photo) => { - clearTimeout(deadlineTimeout); - releaseSession(); - - if (!photo) { - Log.info('[PhotoUpgrade] no photo to upgrade with, keeping the snapshot'); - return undefined; - } - - return photo; - }); - - pendingPhotoRef.current = { - promise, - discard: () => { - isDiscarded = true; - }, - startedAt, - getFailureOutcome: () => failureOutcome, - }; - captureCountRef.current += 1; - setHasPendingPhotoCapture(true); - }; - - const upgradeReceiptWithPhoto = (durableName: string) => { - const pending = pendingPhotoRef.current; - if (!pending) { - Log.info('[PhotoUpgrade] no pending photo for this receipt, so it keeps its snapshot', false, {durableName}); - return; - } - pendingPhotoRef.current = undefined; - - const spanId = toUpgradeSpanId(durableName); - let hasReportedOutcome = false; - const reportOutcome = (outcome: string, attributes: Record = {}) => { - if (hasReportedOutcome) { - return; - } - hasReportedOutcome = true; - endSpanWithAttributes(spanId, {[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: outcome, ...attributes}); - }; - startSpan(spanId, { - name: CONST.TELEMETRY.SPAN_RECEIPT_UPGRADE, - op: CONST.TELEMETRY.SPAN_RECEIPT_UPGRADE, - startTime: pending.startedAt, - attributes: { - [CONST.TELEMETRY.ATTRIBUTE_PLATFORM]: CONST.TELEMETRY.SPAN_PLATFORM.NATIVE, - }, - }); - - startUpgrade(durableName); - - pending.promise - .then((photo) => { - if (!photo) { - reportOutcome(pending.getFailureOutcome()); - return undefined; - } - - const rotation = rotatePhotoToUpright(photo.path, photo.orientation).catch((error: unknown) => { - throw new UpgradeStepError('rotate', `failed: ${error instanceof Error ? error.message : String(error)}`, false); - }); - return withDeadline(rotation, ROTATE_TIMEOUT_MS, 'rotate', (latePath) => { - if (!latePath) { - return; - } - discardPhoto(latePath); - }).then((uprightPath) => { - if (wasReceiptClaimed(durableName)) { - Log.info('[PhotoUpgrade] keeping the snapshot, upgrade claimed for upload before the swap started', false, {durableName}); - reportOutcome(CONST.TELEMETRY.UPGRADE_OUTCOME.CLAIMED_FOR_UPLOAD); - discardPhoto(photo.path); - if (uprightPath) { - discardPhoto(uprightPath); - } - return undefined; - } - - return ReceiptStorage.overwrite(durableName, uprightPath ?? photo.path, () => wasReceiptClaimed(durableName)) - .then(() => { - Log.info('[PhotoUpgrade] receipt upgraded to the full-resolution photo', false, { - width: photo.width, - height: photo.height, - wasRotated: !!uprightPath, - }); - reportOutcome(CONST.TELEMETRY.UPGRADE_OUTCOME.UPGRADED, { - [CONST.TELEMETRY.ATTRIBUTE_PHOTO_WIDTH]: photo.width, - [CONST.TELEMETRY.ATTRIBUTE_PHOTO_HEIGHT]: photo.height, - }); - recordUpgrade(durableName); - - if (!uprightPath) { - return; - } - discardPhoto(photo.path); - }) - .catch((error: unknown) => { - if (uprightPath) { - discardPhoto(uprightPath); - } - throw error; - }); - }); - }) - .catch((error: unknown) => { - const wasClaimed = wasReceiptClaimed(durableName); - const reason = error instanceof Error ? error.message : String(error); - Log.warn(`[PhotoUpgrade] keeping the snapshot, upgrade ${wasClaimed ? 'claimed for upload' : 'failed'}`, {error: reason}); - let outcome: string = CONST.TELEMETRY.UPGRADE_OUTCOME.SWAP_FAILED; - if (wasClaimed) { - outcome = CONST.TELEMETRY.UPGRADE_OUTCOME.CLAIMED_FOR_UPLOAD; - } else if (error instanceof UpgradeStepError && error.step === 'rotate') { - outcome = error.hasTimedOut ? CONST.TELEMETRY.UPGRADE_OUTCOME.ROTATE_TIMED_OUT : CONST.TELEMETRY.UPGRADE_OUTCOME.ROTATE_FAILED; - } - reportOutcome(outcome); - pending.discard(); - discardWhenItLands(pending); - }) - .finally(() => finishUpgrade(durableName)); - }; - - const discardPendingPhoto = () => { - const pending = pendingPhotoRef.current; - if (!pending) { - return; - } - pendingPhotoRef.current = undefined; - abandonPending(pending); - }; - - return {hasPendingPhotoCapture, startPhotoCapture, upgradeReceiptWithPhoto, discardPendingPhoto}; -} - -export default usePhotoUpgrade; diff --git a/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts deleted file mode 100644 index 6af92dbb4aa8..000000000000 --- a/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Rotates a captured still so the receipt reads upright, reconciling the decoded frame's orientation - * with VisionCamera's display orientation. Returns undefined when the photo is already upright. - */ -import {JPEG_QUALITY} from '@libs/fileDownload/FileUtils'; -import getPhotoSource from '@libs/fileDownload/getPhotoSource'; -import Log from '@libs/Log'; - -import type {Orientation} from 'react-native-vision-camera'; - -import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'; -import ImageSize from 'react-native-image-size'; - -type PhotoSize = { - width: number; - height: number; - rotation?: number; -}; - -function getUprightRotation({width, height, rotation = 0}: PhotoSize, orientation?: Orientation): number | undefined { - // The image loader applies the file's rotation metadata while decoding, so measure the frame the way it - // hands it over: a 1920x1440 photo whose metadata asks for 90 decodes as 1440x1920. - const isSideways = rotation === 90 || rotation === 270; - const decodedWidth = isSideways ? height : width; - const decodedHeight = isSideways ? width : height; - - // VisionCamera's `PhotoFile.orientation` is the display orientation relative to the sensor, as a - // counter-clockwise angle (see its `RotationHelper`): a portrait capture off a landscape sensor reports - // `landscape-left`, so 270 counter-clockwise, so 90 clockwise. The opposite hold turns the other way. - const quarterTurn = orientation === 'landscape-right' ? 270 : 90; - return decodedWidth > decodedHeight ? quarterTurn : undefined; -} - -/** The garbage collector can't see the native memory a 12 MP bitmap holds, so free it as soon as the photo is saved. */ -function releaseQuietly(releasable: {release: () => void}) { - try { - releasable.release(); - } catch (error) { - Log.warn('[PhotoUpgrade] could not release a native image', {error: error instanceof Error ? error.message : String(error)}); - } -} - -function rotatePhotoToUpright(stillPath: string, orientation?: Orientation): Promise { - const sourceUri = getPhotoSource(stillPath); - const startedAt = Date.now(); - - return ImageSize.getSize(sourceUri).then((imageSize) => { - const angle = getUprightRotation(imageSize, orientation); - if (angle === undefined) { - return undefined; - } - - const context = ImageManipulator.manipulate(sourceUri).rotate(angle); - return context - .renderAsync() - .then((image) => image.saveAsync({compress: JPEG_QUALITY, format: SaveFormat.JPEG}).finally(() => releaseQuietly(image))) - .then((result) => { - Log.info('[PhotoUpgrade] rotated the full-resolution photo', false, { - durationMs: Date.now() - startedAt, - angle, - exifRotation: imageSize.rotation, - source: `${imageSize.width}x${imageSize.height}`, - }); - return result.uri; - }) - .finally(() => releaseQuietly(context)); - }); -} - -export default rotatePhotoToUpright; -export {getUprightRotation}; diff --git a/src/pages/signin/SAMLSignInPage/index.native.tsx b/src/pages/signin/SAMLSignInPage/index.native.tsx index 88e3904da478..ca50ec0f5513 100644 --- a/src/pages/signin/SAMLSignInPage/index.native.tsx +++ b/src/pages/signin/SAMLSignInPage/index.native.tsx @@ -27,7 +27,6 @@ import handleSAMLLoginError from './handleSAMLLoginError'; function SAMLSignInPage() { const [credentials] = useOnyx(ONYXKEYS.CREDENTIALS); - const [session] = useOnyx(ONYXKEYS.SESSION); const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH); const [showNavigation, shouldShowNavigation] = useState(true); const [SAMLUrl, setSAMLUrl] = useState(''); @@ -107,9 +106,7 @@ function SAMLSignInPage() { // A forced re-auth leaves account.isLoading true until sign-in, so the token alone decides here. if (credentials?.login && shortLivedAuthToken) { Log.info('SAMLSignInPage - Successfully received shortLivedAuthToken. Signing in...'); - signInWithShortLivedAuthToken(shortLivedAuthToken, session?.authToken, true, lastVisitedPath, credentials?.login).catch((error) => { - Log.hmmm('SAMLSignInPage - Failed to sign in with shortLivedAuthToken', {error}); - }); + signInWithShortLivedAuthToken(shortLivedAuthToken, true, lastVisitedPath, credentials?.login); return; } @@ -126,7 +123,7 @@ function SAMLSignInPage() { Navigation.navigate(ROUTES.HOME); }); }, - [credentials?.login, lastVisitedPath, translate, session?.authToken], + [credentials?.login, lastVisitedPath, translate], ); useEffect(() => { diff --git a/src/pages/signin/SignUpWelcomeForm.tsx b/src/pages/signin/SignUpWelcomeForm.tsx index 4eb97d299881..e14e958eeafe 100644 --- a/src/pages/signin/SignUpWelcomeForm.tsx +++ b/src/pages/signin/SignUpWelcomeForm.tsx @@ -29,7 +29,6 @@ function SignUpWelcomeForm() { const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [login] = useOnyx(ONYXKEYS.CREDENTIALS, {selector: (credentials) => credentials?.login}); const [preferredLocale] = useOnyx(ONYXKEYS.NVP_PREFERRED_LOCALE); - const [marketingAttribution] = useOnyx(ONYXKEYS.MARKETING_ATTRIBUTION); const serverErrorText = useMemo(() => (account ? getLatestErrorMessage(account) : ''), [account]); const isPhoneSignup = Str.isSMSLogin(login ?? ''); const [hasSMSMarketingConsent, setHasSMSMarketingConsent] = useState(false); @@ -54,7 +53,7 @@ function SignUpWelcomeForm() { size={CONST.BUTTON_SIZE.LARGE} isLoading={account?.isLoading} onPress={() => { - signUpUser(login, preferredLocale, isPhoneSignup ? hasSMSMarketingConsent : undefined, marketingAttribution); + signUpUser(login, preferredLocale, isPhoneSignup ? hasSMSMarketingConsent : undefined); setReadyToShowAuthScreens(true); }} style={[styles.mb2]} diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index fbd5a0c4c18d..2f252c1f6a59 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -10,7 +10,6 @@ import ScrollView from '@components/ScrollView'; import useCardFeedErrors from '@hooks/useCardFeedErrors'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useGetReceiptPartnersIntegrationData from '@hooks/useGetReceiptPartnersIntegrationData'; -import useHasApprovalWorkflowWithNonMemberApprover from '@hooks/useHasApprovalWorkflowWithNonMemberApprover'; import useIsWorkspacesTabFocused from '@hooks/useIsWorkspacesTabFocused'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -94,7 +93,6 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const workspaceAccountID = useWorkspaceAccountID(policyID); const {shouldShowEnterCredentialsError} = useGetReceiptPartnersIntegrationData(policyID); const {shouldShowRbrForWorkspaceAccountID} = useCardFeedErrors(); - const hasApprovalWorkflowWithNonMemberApprover = useHasApprovalWorkflowWithNonMemberApprover(policyID); const expensifyIcons = useMemoizedLazyExpensifyIcons([ 'Building', 'CalendarSolid', @@ -192,7 +190,6 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac previousPendingFields: prevPendingFields, shouldShowEnterCredentialsError, shouldShowRBR, - hasApprovalWorkflowWithNonMemberApprover, isVendorMatchingBetaEnabled: isBetaEnabled(CONST.BETAS.VENDOR_MATCHING), isRecruitingBetaEnabled: isBetaEnabled(CONST.BETAS.MERGE_ATS), convertToDisplayString, diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 8ae87a59bb21..5007d6d8e833 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -175,7 +175,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const prevAccountIDs = usePrevious(accountIDs); const invitedEmails = useMemo(() => Object.keys(invitedEmailsToAccountIDsDraft ?? {}), [invitedEmailsToAccountIDsDraft]); - const ownerDetails = personalDetails?.[policy?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID]; + const ownerDetails = personalDetails?.[policy?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? ({} as PersonalDetails); const {approvalWorkflows} = useMemo( () => convertPolicyEmployeesToApprovalWorkflows({ @@ -250,19 +250,21 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const hasApprovers = selectedEmployees.some((email) => isPolicyApprover(policy, email)); if (hasApprovers) { - // Fall back to the employeeList email and policy owner, so submitters are still reassigned when personal - // details aren't loaded. Skipping it would leave them submitting to someone no longer on the workspace. - const ownerEmail = ownerDetails?.login ?? policy?.owner; + const ownerEmail = ownerDetails.login; let currentWorkflows = approvalWorkflows; for (const login of selectedEmployees) { - if (!isPolicyApprover(policy, login) || !ownerEmail) { + if (!isPolicyApprover(policy, login)) { continue; } + const accountID = policyMemberEmailsToAccountIDs[login]; + const removedApprover = personalDetails?.[accountID]; + if (!removedApprover?.login || !ownerEmail) { + continue; + } const updatedWorkflows = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: currentWorkflows, - removedApproverEmail: login, - ownerEmail, + removedApprover, ownerDetails, }); currentWorkflows = updatedWorkflows.filter((workflow) => !workflow.removeApprovalWorkflow); diff --git a/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx b/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx index b2f0f9e7ac20..f90a2b8798ae 100644 --- a/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx +++ b/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx @@ -7,42 +7,34 @@ import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; import Text from '@components/Text'; -import useActiveServer from '@hooks/useActiveServer'; import {useCompanyCardBankIcons} from '@hooks/useCompanyCardIcons'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {addNewCompanyCardsFeed} from '@libs/actions/CompanyCards'; import {isPlaidSupportedCountry} from '@libs/CardUtils'; -import Navigation from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; import {setAddNewCompanyCardStepAndData} from '@userActions/CompanyCards'; -import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type {CardFeedProvider, CombinedCardFeeds} from '@src/types/onyx/CardFeeds'; +import type {CardFeedProvider} from '@src/types/onyx/CardFeeds'; import type {StyleProp, ViewStyle} from 'react-native'; import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; -type CardTypeSelection = CardFeedProvider | typeof CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK; - type AvailableCompanyCardTypes = { translate: LocaleContextProps['translate']; - typeSelected?: CardTypeSelection; + typeSelected?: CardFeedProvider; styles: StyleProp; companyCardBankIcons: ReturnType; - shouldShowMockFeed: boolean; }; -function getAvailableCompanyCardTypes({translate, typeSelected, styles, companyCardBankIcons, shouldShowMockFeed}: AvailableCompanyCardTypes) { +function getAvailableCompanyCardTypes({translate, typeSelected, styles, companyCardBankIcons}: AvailableCompanyCardTypes) { const defaultCards = [ { value: CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD, @@ -74,13 +66,7 @@ function getAvailableCompanyCardTypes({translate, typeSelected, styles, companyC }, ]; - const cards: Array<{ - value: CardTypeSelection; - text: string; - keyForList: CardTypeSelection; - isSelected: boolean; - leftElement: React.JSX.Element; - }> = [ + return [ { value: CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX, text: translate('workspace.companyCards.addNewCard.cardProviders.gl1025'), @@ -97,51 +83,21 @@ function getAvailableCompanyCardTypes({translate, typeSelected, styles, companyC }, ...defaultCards, ]; - - if (shouldShowMockFeed) { - cards.push({ - value: CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK, - text: CONST.COMPANY_CARDS.CARD_TYPE_NAMES.MOCK_COMMERCIAL, - keyForList: CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK, - isSelected: typeSelected === CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK, - leftElement: ( - - ), - }); - } - - return cards; } -type CardTypeStepProps = { - policyID?: string; - cardFeeds?: CombinedCardFeeds; - workspaceAccountID: number; -}; - -function CardTypeStep({policyID, cardFeeds, workspaceAccountID}: CardTypeStepProps) { +function CardTypeStep() { const {translate} = useLocalize(); const styles = useThemeStyles(); const companyCardBankIcons = useCompanyCardBankIcons(); const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD); - const [lastSelectedFeed] = useOnyx(`${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyID}`); - const {activeServer} = useActiveServer(); - const [localTypeSelected, setLocalTypeSelected] = useState(); + const [localTypeSelected, setLocalTypeSelected] = useState(); const typeSelected = localTypeSelected ?? addNewCard?.data?.feedType; const [isError, setIsError] = useState(false); - // Gate on the API the requests actually reach, since a dev build talks to production by default. - const shouldShowMockFeed = activeServer !== CONST.SERVER.PRODUCTION || CONFIG.IS_USING_LOCAL_WEB; const data = getAvailableCompanyCardTypes({ translate, typeSelected, styles: styles.mr3, companyCardBankIcons, - shouldShowMockFeed, }); const {bankName, selectedBank, feedType} = addNewCard?.data ?? {}; const isOtherBankSelected = selectedBank === CONST.COMPANY_CARDS.BANKS.OTHER; @@ -149,11 +105,8 @@ function CardTypeStep({policyID, cardFeeds, workspaceAccountID}: CardTypeStepPro const doesCountrySupportPlaid = isPlaidSupportedCountry(addNewCard?.data?.selectedCountry); const submit = useCallback(() => { - if (!typeSelected || (typeSelected === CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK && !shouldShowMockFeed)) { + if (!typeSelected) { setIsError(true); - } else if (typeSelected === CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK) { - addNewCompanyCardsFeed(policyID, workspaceAccountID, typeSelected, {}, cardFeeds, lastSelectedFeed); - Navigation.goBack(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID)); } else { setAddNewCompanyCardStepAndData({ step: CONST.COMPANY_CARDS.STEP.CARD_INSTRUCTIONS, @@ -164,7 +117,7 @@ function CardTypeStep({policyID, cardFeeds, workspaceAccountID}: CardTypeStepPro isEditing: false, }); } - }, [bankName, cardFeeds, isNewCardTypeSelected, isOtherBankSelected, lastSelectedFeed, policyID, shouldShowMockFeed, typeSelected, workspaceAccountID]); + }, [bankName, isNewCardTypeSelected, isOtherBankSelected, typeSelected]); const handleBackButtonPress = () => { if (isOtherBankSelected) { diff --git a/src/pages/workspace/companyCards/addNew/DynamicAddNewCardPage.tsx b/src/pages/workspace/companyCards/addNew/DynamicAddNewCardPage.tsx index 5cf5ba6f28f8..0ba5c55bd597 100644 --- a/src/pages/workspace/companyCards/addNew/DynamicAddNewCardPage.tsx +++ b/src/pages/workspace/companyCards/addNew/DynamicAddNewCardPage.tsx @@ -123,13 +123,7 @@ function DynamicAddNewCardPage({policy}: WithPolicyAndFullscreenLoadingProps) { CurrentStep = ; break; case CONST.COMPANY_CARDS.STEP.CARD_TYPE: - CurrentStep = ( - - ); + CurrentStep = ; break; case CONST.COMPANY_CARDS.STEP.BANK_CONNECTION: CurrentStep = ; diff --git a/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx b/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx index 3a80429454f6..4b49d7560645 100644 --- a/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx +++ b/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx @@ -198,16 +198,12 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm return result.filter((item): item is NonNullable => !!item); })(); - // Merchant rules live under Rules, so they are copied only along with it. The Rules row is hidden when the source has no workspace-level rules, and merchant rules can then be copied on their own. - const hasRulesItem = items.some((item) => item.value === 'rules'); - const effectiveSelectedItems = hasRulesItem && !selectedItems.includes('rules') ? selectedItems.filter((value) => value !== 'codingRules') : selectedItems; - const featuresToCopy: ListItem[] = items.map((option) => { const alternateText = option?.alternateText ? option.alternateText.trim().replaceAll(/,$/g, '') : undefined; return { text: option.translation, keyForList: option.value, - isSelected: effectiveSelectedItems.includes(option.value), + isSelected: selectedItems.includes(option.value), alternateText, }; }); @@ -231,25 +227,25 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm policyID: policy.id, targetPolicyID: duplicateWorkspace.policyID, welcomeNote: `${translate('workspace.duplicateWorkspace.welcomeNote')} ${duplicateWorkspace.name}`, - policyCategories: effectiveSelectedItems.includes('categories') ? policyCategories : undefined, + policyCategories: selectedItems.includes('categories') ? policyCategories : undefined, personalDetailsByLogins: employeePersonalDetails, parts: { - people: effectiveSelectedItems.includes('members'), - reports: effectiveSelectedItems.includes('reports'), - connections: effectiveSelectedItems.includes('accounting'), - categories: effectiveSelectedItems.includes('categories'), - tags: effectiveSelectedItems.includes('tags'), - taxes: effectiveSelectedItems.includes('taxes'), - perDiem: effectiveSelectedItems.includes('perDiem'), - reimbursements: effectiveSelectedItems.includes('invoices'), - expenses: effectiveSelectedItems.includes('rules'), - distance: effectiveSelectedItems.includes('distanceRates'), - invoices: effectiveSelectedItems.includes('invoices'), - invoiceFields: effectiveSelectedItems.includes('invoices'), - exportLayouts: effectiveSelectedItems.includes('workflows'), - overview: effectiveSelectedItems.includes('overview'), - travel: effectiveSelectedItems.includes('travel'), - codingRules: effectiveSelectedItems.includes('codingRules'), + people: selectedItems.includes('members'), + reports: selectedItems.includes('reports'), + connections: selectedItems.includes('accounting'), + categories: selectedItems.includes('categories'), + tags: selectedItems.includes('tags'), + taxes: selectedItems.includes('taxes'), + perDiem: selectedItems.includes('perDiem'), + reimbursements: selectedItems.includes('invoices'), + expenses: selectedItems.includes('rules'), + distance: selectedItems.includes('distanceRates'), + invoices: selectedItems.includes('invoices'), + invoiceFields: selectedItems.includes('invoices'), + exportLayouts: selectedItems.includes('workflows'), + overview: selectedItems.includes('overview'), + travel: selectedItems.includes('travel'), + codingRules: selectedItems.includes('codingRules'), }, file: duplicatedWorkspaceAvatar, localCurrency: currentUserPersonalDetails?.localCurrencyCode ?? CONST.CURRENCY.USD, @@ -261,7 +257,7 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm const duplicateWorkspaceName = duplicateWorkspace?.name; const duplicateWorkspacePolicyID = duplicateWorkspace?.policyID; const onConfirmSelectList = () => { - if (!totalMembers || totalMembers < 2 || !effectiveSelectedItems.includes('members')) { + if (!totalMembers || totalMembers < 2 || !selectedItems.includes('members')) { confirmDuplicate(); return; } @@ -291,7 +287,7 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm }; const toggleAllItems = () => { - if (effectiveSelectedItems.length === items.length) { + if (selectedItems.length === items.length) { setSelectedItems([]); } else { setSelectedItems(items.map((i) => i.value)); @@ -300,18 +296,15 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm const updateSelectedItems = (listItem: ListItem) => { if (listItem.isSelected) { - const deselectedItems = listItem.keyForList === 'rules' ? ['rules', 'codingRules'] : [listItem.keyForList]; - setSelectedItems(selectedItems.filter((i) => !deselectedItems.includes(i))); + setSelectedItems(selectedItems.filter((i) => i !== listItem.keyForList)); return; } const newItem = items.find((i) => i.value === listItem.keyForList)?.value; - if (!newItem) { - return; - } - const newItems = newItem === 'codingRules' && hasRulesItem ? [newItem, 'rules'] : [newItem]; - setSelectedItems(Array.from(new Set([...selectedItems, ...newItems]))); + if (newItem) { + setSelectedItems([...selectedItems, newItem]); + } }; // When the component mounts, if there is a new avatar, see if the image can be read from the disk. If not, redirect the user to the starting step of the flow. @@ -346,7 +339,7 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const isSelectAllChecked = effectiveSelectedItems.length > 0 && effectiveSelectedItems.length === items.length; + const isSelectAllChecked = selectedItems.length > 0 && selectedItems.length === items.length; const confirmButtonOptions: ConfirmButtonOptions = { showButton: true, @@ -370,7 +363,7 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm 0 && effectiveSelectedItems.length !== items.length} + isIndeterminate={selectedItems.length > 0 && selectedItems.length !== items.length} onPress={toggleAllItems} disabled={items.length === 0} shouldSelectOnPressEnter diff --git a/src/pages/workspace/getWorkspaceMenuItems.ts b/src/pages/workspace/getWorkspaceMenuItems.ts index 3ec908c36625..ef6bb5fb0f0c 100644 --- a/src/pages/workspace/getWorkspaceMenuItems.ts +++ b/src/pages/workspace/getWorkspaceMenuItems.ts @@ -109,8 +109,6 @@ type GetWorkspaceMenuItemsParams = { shouldShowEnterCredentialsError?: boolean; /** Whether the company cards row should show an error indicator. */ shouldShowRBR?: boolean; - /** Whether a workflow the user can fix has an approver who is no longer a workspace member, which shows an error on the Workflows row. */ - hasApprovalWorkflowWithNonMemberApprover?: boolean; /** Whether the vendor matching beta is enabled. */ isVendorMatchingBetaEnabled?: boolean; /** Whether the Merge ATS beta gating the Recruiting feature is enabled. */ @@ -130,7 +128,6 @@ function getWorkspaceMenuItems({ previousPendingFields, shouldShowEnterCredentialsError = false, shouldShowRBR = false, - hasApprovalWorkflowWithNonMemberApprover = false, isVendorMatchingBetaEnabled = false, isRecruitingBetaEnabled = false, convertToDisplayString, @@ -356,7 +353,7 @@ function getWorkspaceMenuItems({ translationKey: 'workspace.common.workflows', icon: icons.Workflows, getRoute: () => ROUTES.WORKSPACE_WORKFLOWS.getRoute(policyID), - brickRoadIndicator: !isEmptyObject(policy?.errorFields?.reimburser ?? {}) || hasApprovalWorkflowWithNonMemberApprover ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + brickRoadIndicator: !isEmptyObject(policy?.errorFields?.reimburser ?? {}) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, screenName: SCREENS.WORKSPACE.WORKFLOWS, sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.WORKFLOWS, highlighted: highlightedPolicyFeature === CONST.POLICY.MORE_FEATURES.ARE_WORKFLOWS_ENABLED, diff --git a/src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx b/src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx index a5d40240b47d..61928054b960 100644 --- a/src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx +++ b/src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx @@ -1,4 +1,3 @@ -import BlockingView from '@components/BlockingViews/BlockingView'; import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -8,13 +7,11 @@ import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectL import type {ListItem} from '@components/SelectionList/ListItem/types'; import Text from '@components/Text'; -import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateMergeHRGroups} from '@libs/actions/connections/merge/HR'; -import {getNonRenderableMergeHRGroupIDs, getValidMergeHRGroupIDs} from '@libs/merge/HRUtils'; import {isMergeConnected} from '@libs/merge/MergeUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -23,12 +20,10 @@ import tokenizedSearch from '@libs/tokenizedSearch'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import variables from '@styles/variables'; - import CONST from '@src/CONST'; import type SCREENS from '@src/SCREENS'; -import React, {useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; type MergeHRGroupsPageProps = PlatformStackScreenProps; @@ -45,66 +40,50 @@ function MergeHRGroupsPage({ }: MergeHRGroupsPageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const illustrations = useMemoizedLazyIllustrations(['Telescope']); const policy = usePolicy(policyID); const availableGroups = policy?.connections?.merge_hris?.data?.groups ?? []; const currentGroups = policy?.connections?.merge_hris?.config?.groups; - const nonRenderableGroupIDs = getNonRenderableMergeHRGroupIDs(policy); - const hasAnyGroups = availableGroups.length > 0 || nonRenderableGroupIDs.length > 0; - // null until the admin touches a checkbox, so the selection keeps tracking policy data (which may still be - // loading on a cold open) instead of freezing on whatever was available at mount. - const [manualSelection, setManualSelection] = useState | null>(null); - const selectedIds = manualSelection ?? new Set(getValidMergeHRGroupIDs(policy)); + const [selectedIds, setSelectedIds] = useState>(() => new Set(currentGroups ?? [])); const [searchText, setSearchText] = useState(''); const filteredGroups = tokenizedSearch(availableGroups, searchText, (group) => [group.name, group.type]); - // Groups the HR system still has but that Merge sent back without a name or type are labelled with their ID, - // so that ID is the only thing there is to search them by. - const filteredNonRenderableGroupIDs = tokenizedSearch(nonRenderableGroupIDs, searchText, (groupID) => [groupID]); - - const listData: GroupListItem[] = [ - ...filteredGroups.map((group) => ({ - text: group.name, - alternateText: group.type.charAt(0).toUpperCase() + group.type.slice(1).toLowerCase(), - keyForList: group.id, - value: group.id, - isSelected: selectedIds.has(group.id), - itemStyle: styles.pv4, - })), - ...filteredNonRenderableGroupIDs.map((groupID) => ({ - text: translate('workspace.hr.mergeHR.groups.unnamedGroup', groupID), - keyForList: groupID, - value: groupID, - isSelected: selectedIds.has(groupID), - itemStyle: styles.pv4, - })), - ]; - const visibleGroupIDs = [...filteredGroups.map((group) => group.id), ...filteredNonRenderableGroupIDs]; + const listData: GroupListItem[] = filteredGroups.map((group) => ({ + text: group.name, + alternateText: group.type.charAt(0).toUpperCase() + group.type.slice(1).toLowerCase(), + keyForList: group.id, + value: group.id, + isSelected: selectedIds.has(group.id), + itemStyle: styles.pv4, + })); const toggleItem = (item: GroupListItem) => { - const next = new Set(selectedIds); - if (next.has(item.value)) { - next.delete(item.value); - } else { - next.add(item.value); - } - setManualSelection(next); + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(item.value)) { + next.delete(item.value); + } else { + next.add(item.value); + } + return next; + }); }; const toggleSelectAll = () => { - const next = new Set(selectedIds); - const allVisibleSelected = visibleGroupIDs.length > 0 && visibleGroupIDs.every((groupID) => next.has(groupID)); - for (const groupID of visibleGroupIDs) { - if (allVisibleSelected) { - next.delete(groupID); - } else { - next.add(groupID); + setSelectedIds((prev) => { + const next = new Set(prev); + const allVisibleSelected = filteredGroups.length > 0 && filteredGroups.every((group) => next.has(group.id)); + for (const group of filteredGroups) { + if (allVisibleSelected) { + next.delete(group.id); + } else { + next.add(group.id); + } } - } - setManualSelection(next); + return next; + }); }; const handleSave = () => { @@ -112,20 +91,6 @@ function MergeHRGroupsPage({ Navigation.goBack(); }; - const listEmptyContent = useMemo( - () => ( - - ), - [illustrations.Telescope, translate, styles.pb10], - ); - return ( 0 ? toggleSelectAll : undefined} - listEmptyContent={listEmptyContent} - shouldShowListEmptyContent={!hasAnyGroups} + onSelectAll={toggleSelectAll} textInputOptions={{ label: translate('common.search'), value: searchText, onChangeText: setSearchText, - headerMessage: listData.length === 0 && hasAnyGroups ? translate('common.noResultsFound') : undefined, style: {containerStyle: styles.pb5}, }} style={{listHeaderSelectAllTextStyle: styles.textLabelSupporting}} @@ -166,6 +128,7 @@ function MergeHRGroupsPage({ size={CONST.BUTTON_SIZE.LARGE} variant={CONST.BUTTON_VARIANT.SUCCESS} onPress={handleSave} + isDisabled={selectedIds.size === 0} > {translate('common.save')} diff --git a/src/pages/workspace/hr/utils.ts b/src/pages/workspace/hr/utils.ts index ec1d08137f5f..2637e3d7e992 100644 --- a/src/pages/workspace/hr/utils.ts +++ b/src/pages/workspace/hr/utils.ts @@ -6,9 +6,9 @@ import getGustoSetupLink from '@libs/actions/connections/Gusto'; import {getMergeSetupLink} from '@libs/actions/connections/merge'; import getZenefitsSetupLink from '@libs/actions/connections/Zenefits'; import {formatList} from '@libs/Localize'; -import {getConnectedHRProvider, getHRApprovalMode, hasStaleMergeHRGroups, isMergeHRCompleteSetupNeeded} from '@libs/merge/HRUtils'; +import {getConnectedHRProvider, getHRApprovalMode, isMergeHRCompleteSetupNeeded} from '@libs/merge/HRUtils'; import type {HRConnectionName} from '@libs/merge/HRUtils'; -import {getMergeFinalApproverDisplayName, hasMergeSyncError} from '@libs/merge/MergeUtils'; +import {getMergeFinalApproverDisplayName} from '@libs/merge/MergeUtils'; import {getIntegrationLastSuccessfulDate} from '@libs/PolicyUtils'; import type {MergeProviderCardDescriptor, MergeProviderConfigRow} from '@pages/workspace/merge/types'; @@ -46,7 +46,7 @@ function getMergeHRSyncState(policy: OnyxEntry) { return { isSyncInProgress, isInitialSyncInProgress: isSyncInProgress && lastSync?.syncType === CONST.MERGE.SYNC_TYPE.INITIAL, - hasError: hasMergeSyncError(policy, CONST.POLICY.CONNECTIONS.NAME.MERGE_HR), + hasError: lastSync?.syncStatus === CONST.MERGE.SYNC_STATUS.FAILED, syncStageInProgress: undefined, successfulDate: lastSync?.successfulDate, }; @@ -257,7 +257,6 @@ function getHRCards({ const state = getHRCardState({policy, connectionName: mergeConnectionName, connectionSyncProgress, getLocalDateFromDatetime, mergeSlug: slug}); const mergeConfig = state.isConnected ? policy?.connections?.merge_hris?.config : undefined; const needsSetup = state.isConnected && !state.needsReconnect && isMergeHRCompleteSetupNeeded(policy); - const hasStaleGroups = state.isConnected && !state.needsReconnect && !needsSetup && hasStaleMergeHRGroups(policy); const groupsRoute = ROUTES.WORKSPACE_HR_MERGE_GROUPS.getRoute(policyID); // In Custom approval the connection sets no approvers, so it has no final approver either @@ -285,7 +284,6 @@ function getHRCards({ route: groupsRoute, pendingAction: mergeConfig?.pendingFields?.groups, errors: mergeConfig?.errorFields?.groups, - hasInvalidValue: hasStaleGroups, }, { field: 'approvalMode', @@ -308,7 +306,6 @@ function getHRCards({ setupLink: getMergeSetupLink(policyID, slug, CONST.MERGE.CATEGORY.HRIS), ...(state.isConnected ? state : disconnectedState), completeSetupRoute: needsSetup ? groupsRoute : undefined, - staleGroupsRoute: hasStaleGroups ? groupsRoute : undefined, configRows, }); } diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index fc14ab53f9e1..8df2f6712c50 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -154,7 +154,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const canWriteMembers = canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.MEMBERS); const canManageSelectedMemberRole = canMemberAssignRole(policy, currentUserLogin, member?.role); const canRemoveSelectedMember = canWriteMembers && !isSelectedMemberOwner && !isSelectedMemberCurrentUser && canMemberManageMemberWithRole(policy, currentUserLogin, member?.role); - const ownerDetails = personalDetails?.[policy?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID]; + const ownerDetails = personalDetails?.[policy?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? ({} as PersonalDetails); const policyOwnerDisplayName = temporaryGetDisplayNameOrDefault({passedPersonalDetails: ownerDetails, translate, formatPhoneNumber}) ?? policy?.owner ?? ''; const {cardList: assignableCards, ...workspaceCards} = getAllCardsForWorkspace(workspaceAccountID, cardList, cardFeeds, expensifyCardSettings); const isSMSLogin = Str.isSMSLogin(memberLogin); @@ -242,11 +242,11 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM }; const removeUser = () => { - // Fall back to the policy owner, so submitters are still reassigned when personal details aren't loaded. - const ownerEmail = ownerDetails?.login ?? policy?.owner; + const ownerEmail = ownerDetails?.login; + const removedApprover = personalDetails?.[accountID]; // If the user is not an approver, proceed with member removal - if (!memberLogin || !isPolicyApprover(policy, memberLogin) || !ownerEmail) { + if (!isPolicyApprover(policy, memberLogin) || !removedApprover?.login || !ownerEmail) { removeMemberAndCloseModal(); return; } @@ -254,8 +254,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM // Update approval workflows after approver removal const updatedWorkflows = updateWorkflowDataOnApproverRemoval({ approvalWorkflows, - removedApproverEmail: memberLogin, - ownerEmail, + removedApprover, ownerDetails, }); diff --git a/src/pages/workspace/merge/MergeProviderCard.tsx b/src/pages/workspace/merge/MergeProviderCard.tsx index 2805e8637ec7..8e6e93b918f3 100644 --- a/src/pages/workspace/merge/MergeProviderCard.tsx +++ b/src/pages/workspace/merge/MergeProviderCard.tsx @@ -69,8 +69,6 @@ function MergeProviderCard({card, policy, handleConnect, canWriteMoreFeatures, s connectionDescription = translate('workspace.merge.lastSync', datetimeToRelative(card.successfulDate)); } - // Checked in this order: reconnect, then a stale selection, then any other sync failure - a stale - // selection is reported here even if the same sync also failed for an unrelated reason. let lastSyncErrorMessage: ReactNode | undefined; if (card.needsReconnect) { lastSyncErrorMessage = ( @@ -86,25 +84,6 @@ function MergeProviderCard({card, policy, handleConnect, canWriteMoreFeatures, s )} ); - } else if (card.staleGroupsRoute) { - const staleGroupsRoute = card.staleGroupsRoute; - lastSyncErrorMessage = ( - <> - {`${translate('workspace.hr.mergeHR.groups.staleSelectionError', card.displayName)} `} - { - if (!canWriteMoreFeatures) { - showReadOnlyModal(); - return; - } - Navigation.navigate(staleGroupsRoute); - }} - > - {translate('workspace.hr.mergeHR.groups.updateSelectionLink')} - - - ); } else if (card.hasError) { const genericError = translate('workspace.merge.syncError', card.displayName); lastSyncErrorMessage = card.lastSyncErrorMessage ? `${genericError} ("${card.lastSyncErrorMessage}")` : genericError; @@ -245,7 +224,7 @@ function MergeProviderCard({card, policy, handleConnect, canWriteMoreFeatures, s numberOfLinesTitle={row.numberOfLinesTitle} style={styles.sectionMenuItemTopDescription} shouldShowRightIcon={canWriteMoreFeatures} - brickRoadIndicator={row.errors || row.hasInvalidValue ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + brickRoadIndicator={row.errors ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} onPress={() => Navigation.navigate(row.route)} interactive={canWriteMoreFeatures} /> diff --git a/src/pages/workspace/merge/types.ts b/src/pages/workspace/merge/types.ts index a33d617858fa..62300d5bd4c7 100644 --- a/src/pages/workspace/merge/types.ts +++ b/src/pages/workspace/merge/types.ts @@ -43,9 +43,6 @@ type MergeProviderConfigRow = { /** Errors for this field when the last update failed. */ errors?: Errors | null; - - /** Whether the saved value itself is no longer valid, as opposed to the last update having failed. */ - hasInvalidValue?: boolean; }; /** Everything `MergeProviderCard` needs to render one integration, on either the HR or the Recruiting page. */ @@ -80,9 +77,6 @@ type MergeProviderCardDescriptor = { /** Navigation route to the post-connect setup RHP. Set only while the admin still needs to finish setup. */ completeSetupRoute?: Route; - /** Navigation route to the group selector. Set only while the saved Merge HR group selection points at groups the HR system no longer has. */ - staleGroupsRoute?: Route; - /** ISO date string of the last successful sync, used for "last synced" display. */ successfulDate?: string; diff --git a/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx b/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx index 3808dbaeaf04..7c5b3e18b25b 100644 --- a/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx +++ b/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx @@ -19,7 +19,7 @@ import Navigation from '@libs/Navigation/Navigation'; import {isControlPolicy} from '@libs/PolicyUtils'; import sortAlphabetically from '@libs/sortAlphabetically'; import {getDefaultAvatarURL} from '@libs/UserAvatarUtils'; -import {getApprovalLimitDescription, getNonMemberApproverError} from '@libs/WorkflowUtils'; +import {getApprovalLimitDescription} from '@libs/WorkflowUtils'; import CONST from '@src/CONST'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; @@ -114,12 +114,6 @@ function ApprovalWorkflowEditor({approvalWorkflow, removeApprovalWorkflow, polic const previousApprover = approvalWorkflow.approvers.slice(0, approverIndex).findLast(Boolean); const error = approvalWorkflow?.errors?.[`approver-${approverIndex}`]; - // Flag a route to a non-member as soon as the editor opens, rather than only after a Save attempt - const nonMemberApproverError = getNonMemberApproverError(approver, approvalWorkflow.isDefault); - if (!error && nonMemberApproverError) { - return translate(nonMemberApproverError); - } - if (!error) { return; } @@ -137,7 +131,7 @@ function ApprovalWorkflowEditor({approvalWorkflow, removeApprovalWorkflow, polic return translate(error); }, - [approvalWorkflow.approvers, approvalWorkflow.errors, approvalWorkflow.isDefault, translate, formatPhoneNumber], + [approvalWorkflow.approvers, approvalWorkflow.errors, translate, formatPhoneNumber], ); const editApprover = useCallback( diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx index 9bac15237e98..29c98a1db071 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx @@ -29,7 +29,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import type {Approver} from '@src/types/onyx/ApprovalWorkflow'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import {useNavigationState} from '@react-navigation/native'; @@ -56,18 +55,13 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa // Keep the removed approver visible until navigation finishes. // Without this temporary state, clearing the approver immediately causes the empty state to flash // while this screen is still mounted during the dismiss animation. - const [removingApprover, setRemovingApprover] = useState(); + const [removingApproverEmail, setRemovingApproverEmail] = useState(); const isChangeApproverRoute = route.name === SCREENS.WORKSPACE.WORKFLOWS_APPROVALS_APPROVER_CHANGE; const isInitialCreationFlow = approvalWorkflow?.action === CONST.APPROVAL_WORKFLOW.ACTION.CREATE && approvalWorkflow?.isInitialFlow; const currentApprover = approvalWorkflow?.approvers[approverIndex]; - const visibleSelectedApprover = removingApprover ?? currentApprover; - const visibleSelectedApproverEmail = visibleSelectedApprover?.email; - - // An approver who left the workspace isn't on employeeList, so they get their own selected row the admin can tap - // to remove them. It only exists while they hold this slot, so once removed or replaced they can't be picked again. - const nonMemberApprover = visibleSelectedApprover?.isNotWorkspaceMember ? visibleSelectedApprover : undefined; - const nonMemberApproverAccountID = nonMemberApprover ? personalDetailsByEmail?.[nonMemberApprover.email]?.accountID : undefined; + const selectedApproverEmail = currentApprover?.email; + const visibleSelectedApproverEmail = removingApproverEmail ?? selectedApproverEmail; const employeeList = policy?.employeeList; const approversFromWorkflow = approvalWorkflow?.approvers; @@ -83,7 +77,7 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa const membersEmail = approvalWorkflow?.members?.map((member) => member.email); - const approvers = Object.values(employeeList) + return Object.values(employeeList) .map((employee): SelectionListApprover | null => { const email = employee.email; @@ -140,20 +134,6 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa }; }) .filter((approver): approver is SelectionListApprover => !!approver); - - if (nonMemberApprover && !approvers.some((approver) => approver.login === nonMemberApprover.email)) { - approvers.push({ - text: nonMemberApprover.displayName, - alternateText: nonMemberApprover.email, - keyForList: nonMemberApprover.email, - isSelected: true, - login: nonMemberApprover.email, - value: nonMemberApprover.email, - icons: [{source: nonMemberApprover.avatar ?? icons.FallbackAvatar, type: CONST.ICON_TYPE_AVATAR, name: nonMemberApprover.displayName, id: nonMemberApproverAccountID}], - }); - } - - return approvers; }, [ isApprovalWorkflowLoading, employeeList, @@ -170,11 +150,9 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa personalDetails, icons.FallbackAvatar, shouldFilterOutExpensifyTeam, - nonMemberApprover, - nonMemberApproverAccountID, ]); - const shouldShowListEmptyContent = !!approvalWorkflow && !isApprovalWorkflowLoading && !removingApprover; + const shouldShowListEmptyContent = !!approvalWorkflow && !isApprovalWorkflowLoading && !removingApproverEmail; const goBack = useCallback(() => { let backToRoute; @@ -197,7 +175,7 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa const isRemovingApprover = approvers.length === 0; if (isRemovingApprover) { - setRemovingApprover(visibleSelectedApprover); + setRemovingApproverEmail(visibleSelectedApproverEmail); clearApprovalWorkflowApprover({approverIndex, currentApprovalWorkflow: approvalWorkflow}); if (isChangeApproverRoute && approvalWorkflow?.action === CONST.APPROVAL_WORKFLOW.ACTION.EDIT) { // Don't compare params — see goBack above. @@ -250,7 +228,7 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa personalDetailsByEmail, isChangeApproverRoute, firstApprover, - visibleSelectedApprover, + visibleSelectedApproverEmail, ], ); diff --git a/src/selectors/Session.ts b/src/selectors/Session.ts index 95ffe111e926..c1798bac7d6d 100644 --- a/src/selectors/Session.ts +++ b/src/selectors/Session.ts @@ -15,7 +15,4 @@ const sessionEmailAndAccountIDSelector = (session: OnyxEntry) => ({emai const authTokenSelector = (session: OnyxEntry) => session?.authToken; -/** Whether the user is signed in to a real account, which excludes the anonymous sessions used to view public rooms */ -const hasNonAnonymousSessionSelector = (session: OnyxEntry) => !!session?.authToken && session.authTokenType !== CONST.AUTH_TOKEN_TYPES.ANONYMOUS; - -export {emailSelector, accountIDSelector, sessionEmailAndAccountIDSelector, authTokenSelector, hasNonAnonymousSessionSelector, isSupportalSessionSelector, isDelegateSessionSelector}; +export {emailSelector, accountIDSelector, sessionEmailAndAccountIDSelector, authTokenSelector, isSupportalSessionSelector, isDelegateSessionSelector}; diff --git a/src/selectors/TableColumnWidths.ts b/src/selectors/TableColumnWidths.ts deleted file mode 100644 index 3ca8486ffff3..000000000000 --- a/src/selectors/TableColumnWidths.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type {TableColumnWidths} from '@src/types/onyx'; - -import type {OnyxEntry} from 'react-native-onyx'; - -/** One table's stored widths. Narrowing keeps a drag in one table from re-rendering every other table. */ -const tableColumnWidthsSelector = (columnResizingID: string) => (tableColumnWidths: OnyxEntry) => tableColumnWidths?.[columnResizingID]; - -// eslint-disable-next-line import/prefer-default-export -- additional selectors may be added here -export {tableColumnWidthsSelector}; diff --git a/src/setup/index.ts b/src/setup/index.ts index c6967ca7bffc..6d57358a5d4c 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -3,7 +3,6 @@ import finishCloudflareSignInFromURL from '@libs/CloudflareAccess/finishSignInFr import intlPolyfill from '@libs/IntlPolyfill'; import registerMiddlewares from '@libs/Middleware/register'; import {startMainQueue} from '@libs/Network'; -import ReceiptStorage from '@libs/ReceiptStorage'; import registerReportActionsPagination from '@libs/registerReportActionsPagination'; import {setDeviceID} from '@userActions/Device'; @@ -111,8 +110,6 @@ export default function () { cleanupPreMountedDraftReports(); - ReceiptStorage.sweepLeftovers(); - // The carousel's sibling list belongs to the screen that seeded it, and that ownership lives in module state // which dies with the JS runtime. A list that survives in storage is therefore orphaned the moment the app // reloads: no mounted screen can refresh or release it, and an expense opened straight from a deeplink would diff --git a/src/styles/index.ts b/src/styles/index.ts index 5dc78ee6118c..2eac028b4aa8 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -2790,10 +2790,6 @@ const staticStyles = (theme: ThemeColors) => borderColor: 'transparent', }, - tableColumnResizeIndicator: { - backgroundColor: theme.iconMenu, - }, - tableRowHeightCompact: { minHeight: variables.tableRowHeightCompact, }, diff --git a/src/styles/utils/cursor/index.native.ts b/src/styles/utils/cursor/index.native.ts index 720dc68d58e2..67b788f0f87e 100644 --- a/src/styles/utils/cursor/index.native.ts +++ b/src/styles/utils/cursor/index.native.ts @@ -14,7 +14,6 @@ const cursor: CursorStyles = { cursorInitial: {}, cursorText: {}, cursorEwResize: {}, - cursorColResize: {}, cursorNsResize: {}, cursorNeswResize: {}, cursorNwseResize: {}, diff --git a/src/styles/utils/cursor/index.ts b/src/styles/utils/cursor/index.ts index bbabc801fb1d..7576f5063cba 100644 --- a/src/styles/utils/cursor/index.ts +++ b/src/styles/utils/cursor/index.ts @@ -47,10 +47,6 @@ const cursor: CursorStyles = { cursorEwResize: { cursor: 'ew-resize' as ViewStyle['cursor'], }, - cursorColResize: { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- "col-resize" is a valid web cursor that react-native's CursorValue type doesn't include - cursor: 'col-resize' as ViewStyle['cursor'], - }, cursorNsResize: { cursor: 'ns-resize' as ViewStyle['cursor'], }, diff --git a/src/styles/utils/cursor/types.ts b/src/styles/utils/cursor/types.ts index d62ed4c54bd3..066cd7407a43 100644 --- a/src/styles/utils/cursor/types.ts +++ b/src/styles/utils/cursor/types.ts @@ -14,7 +14,6 @@ type CursorStylesKeys = | 'cursorInitial' | 'cursorText' | 'cursorEwResize' - | 'cursorColResize' | 'cursorNsResize' | 'cursorNeswResize' | 'cursorNwseResize'; diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index de4cdb4259a5..895135311b65 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -15,7 +15,7 @@ import type {Dimensions} from '@src/types/utils/Layout'; import type Nullable from '@src/types/utils/Nullable'; // eslint-disable-next-line no-restricted-imports -import type {AnimatableNumericValue, Animated, ColorValue, DimensionValue, ImageStyle, PressableStateCallbackType, StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {AnimatableNumericValue, Animated, ColorValue, ImageStyle, PressableStateCallbackType, StyleProp, TextStyle, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {EdgeInsets} from 'react-native-safe-area-context'; import type {ValueOf} from 'type-fest'; @@ -514,12 +514,11 @@ function getZoomSizingStyle({imageSize, containerSize, zoomScale, isZoomed, isLo } /** - * Returns a style with width set to the specified number, or on web to a CSS expression like `calc()` + * Returns a style with width set to the specified number */ -function getWidthStyle(width: number | string): ViewStyle & ImageStyle { +function getWidthStyle(width: number): ViewStyle & ImageStyle { return { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- `DimensionValue` can't type CSS expressions like `calc()`, which react-native-web passes through - width: width as DimensionValue, + width, }; } @@ -914,12 +913,11 @@ function getMinimumHeight(minHeight: number): ViewStyle { } /** - * Get minimum width as style, or on web a CSS expression like `calc()` + * Get minimum width as style */ -function getMinimumWidth(minWidth: number | string): ViewStyle & ImageStyle { +function getMinimumWidth(minWidth: number): ViewStyle & ImageStyle { return { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- `DimensionValue` can't type CSS expressions like `calc()`, which react-native-web passes through - minWidth: minWidth as DimensionValue, + minWidth, }; } diff --git a/src/types/onyx/ApprovalWorkflow.ts b/src/types/onyx/ApprovalWorkflow.ts index f53602daed55..321dff5c3251 100644 --- a/src/types/onyx/ApprovalWorkflow.ts +++ b/src/types/onyx/ApprovalWorkflow.ts @@ -63,18 +63,6 @@ type Approver = { */ isCircularReference?: boolean; - /** - * Is this approver no longer a workspace member. Members can still submit to them when they were removed without - * their submitters being reassigned, so the workflow is shown with an error until an admin fixes it. - */ - isNotWorkspaceMember?: boolean; - - /** - * Is this approver's `overLimitForwardsTo` no longer a workspace member. Reports over the approval limit still go - * to them, so the workflow is shown with an error until an admin picks a new additional approver. - */ - isOverLimitForwardsToNotWorkspaceMember?: boolean; - /** * If report total is above this limit, the report will be forwarded to 'overLimitForwardsTo' instead of 'forwardsTo' */ diff --git a/src/types/onyx/MarketingAttribution.ts b/src/types/onyx/MarketingAttribution.ts deleted file mode 100644 index 858f90d66b12..000000000000 --- a/src/types/onyx/MarketingAttribution.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -/** - * Marketing attribution captured from the landing URL (UTM params, Google Ads device and network, and Google Ads click IDs). - * Keys match the request parameter names the backend reads during signup so they can be spread - * directly into the SignUpUser request. - */ -type MarketingAttribution = { - /** The campaign source (e.g. google, reddit) */ - utm_source?: string; - - /** The marketing medium (e.g. cpc, email) */ - utm_medium?: string; - - /** The specific campaign name */ - utm_campaign?: string; - - /** The paid keyword that triggered the ad */ - utm_term?: string; - - /** The campaign content used to differentiate ads */ - utm_content?: string; - - /** Google Ads device the click came from (c for computer, m for mobile, t for tablet) */ - device?: string; - - /** Google Ads network the click came from (e.g. g for Google Search, s for Search Partners, d for Display) */ - network?: string; - - /** Google Ads click ID */ - gclid?: string; - - /** Google Ads click ID for iOS app traffic, used when gclid isn't available */ - gbraid?: string; - - /** Google Ads click ID for iOS web traffic, used when gclid isn't available */ - wbraid?: string; -}; - -/** The attribution as persisted in Onyx. The capture time is only used locally to expire it and is never sent to the server. */ -type StoredMarketingAttribution = MarketingAttribution & { - /** When the attribution was captured from the landing URL, in ms since epoch */ - capturedAt?: number; -}; - -export default MarketingAttribution; -export type {StoredMarketingAttribution}; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 4374bbe84539..3507b4bd631e 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -336,9 +336,6 @@ type MergeConnectionLastSync = ConnectionLastSync & { /** Timestamps of the last few manual ("Sync now") syncs, used for blocking manual syncs client-side once the daily limit is reached */ manualSyncTimestamps?: string[]; - - /** If the connection's last sync failed because of its own settings, so the admin fixes the settings rather than reconnecting */ - isConfigurationError?: boolean; }; /** @@ -3128,9 +3125,6 @@ type MergeHRGroup = { type MergeHRConnectionData = { /** Groups available to import employees from. Distinct from `config.groups`, which is the admin's selection. */ groups?: MergeHRGroup[]; - - /** IDs of every group the HR system has, including ones missing a name/type and so absent from `groups`. Used to tell a deleted group apart from one that just can't render. */ - allGroupIDs?: string[]; }; /** Merge HR connection config */ diff --git a/src/types/onyx/TableColumnWidths.ts b/src/types/onyx/TableColumnWidths.ts deleted file mode 100644 index 20a8be4812df..000000000000 --- a/src/types/onyx/TableColumnWidths.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** User-dragged widths by column key. Absent columns are sized from content, so only touched columns get stored. */ -type ColumnWidthOverrides = Record; - -/** Dragged table column widths, keyed by `columnResizingID`. */ -type TableColumnWidths = Record; - -export default TableColumnWidths; -export type {ColumnWidthOverrides}; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 55ecf1ec316e..bd3775139863 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -118,8 +118,6 @@ import type Login from './Login'; import type {Login as NewLogin} from './Logins'; import type Logins from './Logins'; import type MapboxAccessToken from './MapboxAccessToken'; -import type MarketingAttribution from './MarketingAttribution'; -import type {StoredMarketingAttribution} from './MarketingAttribution'; import type MerchantRuleSuggestion from './MerchantRuleSuggestion'; import type MergeTransaction from './MergeTransaction'; import type Modal from './Modal'; @@ -207,7 +205,6 @@ import type StripeCustomerID from './StripeCustomerID'; import type SuggestedAgent from './SuggestedAgent'; import type SuggestedAgentRule from './SuggestedAgentRule'; import type SupportalPermissionDenied from './SupportalPermissionDenied'; -import type TableColumnWidths from './TableColumnWidths'; import type Task from './Task'; import type Transaction from './Transaction'; import type TransactionPending3DSReview from './TransactionPending3DSReview'; @@ -312,8 +309,6 @@ export type { PasskeyTransport, PendingContactAction, MapboxAccessToken, - MarketingAttribution, - StoredMarketingAttribution, Modal, Network, OnyxInputOrEntry, @@ -381,7 +376,6 @@ export type { DomainSecurityGroupMembership, SelectedTabRequest, Session, - TableColumnWidths, Task, TaxRate, TaxRates, diff --git a/tests/actions/IOUTest/PayMoneyRequestTest.ts b/tests/actions/IOUTest/PayMoneyRequestTest.ts index fadb94342190..fd8b2e7243ef 100644 --- a/tests/actions/IOUTest/PayMoneyRequestTest.ts +++ b/tests/actions/IOUTest/PayMoneyRequestTest.ts @@ -778,64 +778,6 @@ describe('actions/IOU/PayMoneyRequest', () => { mockFetch?.resume?.(); }); - it('puts the payment failure error on the chat report preview, not only inside the expense report', async () => { - // Given an outstanding IOU report that is previewed in the chat the payer is looking at - const chatReport = { - ...createRandomReport(20, undefined), - lastReadTime: DateUtils.getDBTime(), - lastVisibleActionCreated: DateUtils.getDBTime(), - }; - const iouReport = { - ...createRandomReport(21, undefined), - chatType: undefined, - type: CONST.REPORT.TYPE.IOU, - chatReportID: chatReport.reportID, - total: 10, - }; - const reportPreviewAction: ReportAction = { - ...createRandomReportAction(22), - actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, - originalMessage: {linkedReportID: iouReport.reportID}, - }; - const chatReportActions: ReportActions = {[reportPreviewAction.reportActionID]: reportPreviewAction}; - - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, chatReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, iouReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, chatReportActions); - await waitForBatchedUpdates(); - - // When the server rejects the payment, which is what happens to a payment queued offline against an - // expense the requestor deleted in the meantime - mockFetch?.fail?.(); - payMoneyRequest({ - isASAPSubmitBetaEnabled: false, - conciergeChat: undefined, - paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, - chatReport, - iouReport, - introSelected: undefined, - currentUserAccountID: CARLOS_ACCOUNT_ID, - currentUserLogin: CARLOS_EMAIL, - isSelfTourViewed: false, - userBillingGracePeriodEnds: undefined, - amountOwed: 0, - chatReportPolicy: chatReportPolicyFromChat(chatReport), - chatReportActions, - delegateAccountID: undefined, - isTrackIntentUser: false, - getCurrencyDecimals: getCurrencyDecimalsLocal, - rules: undefined, - }); - await waitForBatchedUpdates(); - - // Then the error is on the preview in the chat. The pay action also carries it, but that action lives in - // the expense report, which the payer can no longer reach once it has been deleted. - const updatedChatActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}` as const); - expect(updatedChatActions?.[reportPreviewAction.reportActionID]?.errors).toBeTruthy(); - - mockFetch?.succeed?.(); - }); - describe('delegateAccountID forwarding', () => { it('sets delegateAccountID on the pay IOU action when delegateAccountID is provided', async () => { const DELEGATE_ACCOUNT_ID = 999; diff --git a/tests/actions/MarketingAttributionTest.ts b/tests/actions/MarketingAttributionTest.ts deleted file mode 100644 index 3e721da69e8c..000000000000 --- a/tests/actions/MarketingAttributionTest.ts +++ /dev/null @@ -1,266 +0,0 @@ -import {act, renderHook} from '@testing-library/react-native'; - -import useSaveMarketingAttribution from '@hooks/useSaveMarketingAttribution'; - -import {captureMarketingAttributionFromURL, getFreshMarketingAttribution, saveMarketingAttribution} from '@libs/actions/MarketingAttribution'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {StoredMarketingAttribution} from '@src/types/onyx'; - -import type {OnyxEntry} from 'react-native-onyx'; - -import React from 'react'; -/* eslint-disable @typescript-eslint/naming-convention */ -import Onyx from 'react-native-onyx'; - -import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; -import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; - -const anyNumber: unknown = expect.any(Number); - -function setLandingURL(search: string) { - window.history.replaceState({}, '', `/${search}`); -} - -function getStoredAttribution(): Promise> { - return new Promise((resolve) => { - const connection = Onyx.connect({ - key: ONYXKEYS.MARKETING_ATTRIBUTION, - callback: (value) => { - Onyx.disconnect(connection); - resolve(value); - }, - }); - }); -} - -describe('MarketingAttribution', () => { - beforeAll(() => { - Onyx.init({keys: ONYXKEYS}); - }); - - beforeEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdates(); - }); - - afterAll(() => { - setLandingURL(''); - }); - - describe('captureMarketingAttributionFromURL', () => { - it('returns the UTM params, Google Ads params and click IDs from the landing URL', () => { - // Given a landing URL from a Google ad - setLandingURL( - '?utm_source=google&utm_medium=cpc&utm_campaign=123&utm_content=456-789&utm_term=expense%20app-e&device=m&network=g&gclid=testGclid&gbraid=testGbraid&wbraid=testWbraid', - ); - - // When the attribution is captured, then every param is returned under its request param name - expect(captureMarketingAttributionFromURL()).toEqual({ - utm_source: 'google', - utm_medium: 'cpc', - utm_campaign: '123', - utm_content: '456-789', - utm_term: 'expense app-e', - device: 'm', - network: 'g', - gclid: 'testGclid', - gbraid: 'testGbraid', - wbraid: 'testWbraid', - }); - }); - - it('ignores params that are not marketing attribution', () => { - // Given a landing URL with unrelated params - setLandingURL('?utm_source=reddit&exitTo=settings&fbclid=testFbclid'); - - // When the attribution is captured, then only the attribution param is returned - expect(captureMarketingAttributionFromURL()).toEqual({utm_source: 'reddit'}); - }); - - it('returns undefined when the URL carries no params', () => { - // Given a landing URL without attribution params - setLandingURL('?exitTo=settings'); - - // When the attribution is captured, then nothing is returned - expect(captureMarketingAttributionFromURL()).toBeUndefined(); - }); - - it('does not write to Onyx', async () => { - // Given a landing URL with UTM params - setLandingURL('?utm_source=google'); - - // When the attribution is captured - captureMarketingAttributionFromURL(); - await waitForBatchedUpdates(); - - // Then nothing is stored - expect(await getStoredAttribution()).toBeUndefined(); - }); - }); - - describe('saveMarketingAttribution', () => { - it('replaces the stored attribution instead of merging into it', async () => { - // Given attribution stored from an earlier Google ad click - await Onyx.set(ONYXKEYS.MARKETING_ATTRIBUTION, {utm_source: 'google', utm_medium: 'cpc', gclid: 'testGclid', device: 'm'}); - - // When attribution from a later Reddit ad is saved - saveMarketingAttribution({utm_source: 'reddit', utm_campaign: 'spring'}, false); - await waitForBatchedUpdates(); - - // Then only the Reddit values are kept, so values from the two clicks are not combined - expect(await getStoredAttribution()).toEqual({utm_source: 'reddit', utm_campaign: 'spring', capturedAt: anyNumber}); - }); - - it('keeps the stored attribution when nothing was captured', async () => { - // Given attribution stored from an earlier ad click - await Onyx.set(ONYXKEYS.MARKETING_ATTRIBUTION, {utm_source: 'google', gclid: 'testGclid'}); - - // When a load without attribution params is saved - saveMarketingAttribution(undefined, false); - await waitForBatchedUpdates(); - - // Then the stored attribution is kept - expect(await getStoredAttribution()).toEqual({utm_source: 'google', gclid: 'testGclid'}); - }); - - it('stamps the capture time on the saved attribution', async () => { - const before = Date.now(); - saveMarketingAttribution({utm_source: 'google'}, false); - await waitForBatchedUpdates(); - - const stored = await getStoredAttribution(); - expect(stored?.capturedAt).toBeGreaterThanOrEqual(before); - }); - - it('does nothing when the user has a session', async () => { - // When attribution is saved for a signed-in user - saveMarketingAttribution({utm_source: 'google'}, true); - await waitForBatchedUpdates(); - - // Then the key stays unset - expect(await getStoredAttribution()).toBeUndefined(); - }); - }); - - describe('getFreshMarketingAttribution', () => { - const day = 24 * 60 * 60 * 1000; - - it('returns fresh attribution without the capture time', async () => { - // Given attribution captured 89 days ago - await Onyx.set(ONYXKEYS.MARKETING_ATTRIBUTION, {utm_source: 'google', gclid: 'testGclid', capturedAt: Date.now() - 89 * day}); - - // Then it is returned for the request, without capturedAt, and stays stored - expect(getFreshMarketingAttribution(await getStoredAttribution())).toEqual({utm_source: 'google', gclid: 'testGclid'}); - await waitForBatchedUpdates(); - expect(await getStoredAttribution()).toBeDefined(); - }); - - it('ignores and clears attribution older than 90 days', async () => { - // Given attribution captured 91 days ago - await Onyx.set(ONYXKEYS.MARKETING_ATTRIBUTION, {utm_source: 'google', gclid: 'testGclid', capturedAt: Date.now() - 91 * day}); - - // Then nothing is returned and the key is cleared - expect(getFreshMarketingAttribution(await getStoredAttribution())).toBeUndefined(); - await waitForBatchedUpdates(); - expect(await getStoredAttribution()).toBeUndefined(); - }); - - it('treats legacy attribution stored without a capture time as expired', async () => { - await Onyx.set(ONYXKEYS.MARKETING_ATTRIBUTION, {utm_source: 'google', gclid: 'testGclid'}); - - expect(getFreshMarketingAttribution(await getStoredAttribution())).toBeUndefined(); - await waitForBatchedUpdates(); - expect(await getStoredAttribution()).toBeUndefined(); - }); - - it('returns undefined when nothing is stored', () => { - expect(getFreshMarketingAttribution(undefined)).toBeUndefined(); - }); - }); - - describe('useSaveMarketingAttribution', () => { - it('stores the attribution from the landing URL when the user has no session', async () => { - // Given no session and a landing URL with UTM params - setLandingURL('?utm_source=google&utm_medium=cpc'); - - // When the hook renders and the session has loaded - renderHook(() => useSaveMarketingAttribution()); - await waitForBatchedUpdatesWithAct(); - - // Then the attribution is written - expect(await getStoredAttribution()).toEqual({utm_source: 'google', utm_medium: 'cpc', capturedAt: anyNumber}); - }); - - it('does not store the attribution when the user already has a session', async () => { - // Given a signed-in user and a landing URL with UTM params - await Onyx.set(ONYXKEYS.SESSION, {authToken: 'testAuthToken', email: 'test@test.com'}); - setLandingURL('?utm_source=google&utm_medium=cpc'); - - // When the hook renders and the session has loaded - renderHook(() => useSaveMarketingAttribution()); - await waitForBatchedUpdatesWithAct(); - - // Then the key stays unset - expect(await getStoredAttribution()).toBeUndefined(); - }); - - it('stores the attribution when the user only has an anonymous session from viewing a public room', async () => { - // Given an anonymous session and a landing URL with Google Ads params - await Onyx.set(ONYXKEYS.SESSION, {authToken: 'anonymousAuthToken', authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS, email: 'anon-123@guest.expensify.com'}); - setLandingURL('?gclid=testGclid&utm_source=google'); - - // When the hook renders and the session has loaded - renderHook(() => useSaveMarketingAttribution()); - await waitForBatchedUpdatesWithAct(); - - // Then the attribution is written, since the anonymous user can still sign up from the room - expect(await getStoredAttribution()).toEqual({gclid: 'testGclid', utm_source: 'google', capturedAt: anyNumber}); - }); - - it('reads the URL on the first render only', async () => { - // Given a landing URL with UTM params that the router later strips - setLandingURL('?utm_source=google'); - const {rerender} = renderHook(() => useSaveMarketingAttribution()); - setLandingURL(''); - rerender({}); - await waitForBatchedUpdatesWithAct(); - - // Then the attribution from the landing URL is stored - expect(await getStoredAttribution()).toEqual({utm_source: 'google', capturedAt: anyNumber}); - }); - - it('saves the attribution only once, even after the session changes', async () => { - // Given attribution saved on landing - setLandingURL('?utm_source=google'); - renderHook(() => useSaveMarketingAttribution()); - await waitForBatchedUpdatesWithAct(); - - // When signup clears it and the user later signs in and out - await act(async () => { - await Onyx.set(ONYXKEYS.MARKETING_ATTRIBUTION, null); - await Onyx.set(ONYXKEYS.SESSION, {authToken: 'testAuthToken'}); - }); - await act(async () => { - await Onyx.set(ONYXKEYS.SESSION, {}); - }); - await waitForBatchedUpdatesWithAct(); - - // Then the attribution is not written again - expect(await getStoredAttribution()).toBeUndefined(); - }); - - it('works under StrictMode', async () => { - // Given a landing URL with UTM params - setLandingURL('?utm_source=google&gclid=testGclid'); - - // When the hook renders under StrictMode, which double-invokes initializers and effects - renderHook(() => useSaveMarketingAttribution(), {wrapper: React.StrictMode}); - await waitForBatchedUpdatesWithAct(); - - // Then the attribution is stored - expect(await getStoredAttribution()).toEqual({utm_source: 'google', gclid: 'testGclid', capturedAt: anyNumber}); - }); - }); -}); diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index 8b0692260434..7e01cf80ef93 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -733,61 +733,6 @@ describe('actions/Policy', () => { await waitForBatchedUpdates(); }); - it('duplicate workspace without the rules and codingRules options copies no merchant rules', async () => { - // Given a source workspace with a merchant rule - const fakePolicy = createRandomPolicy(16, CONST.POLICY.TYPE.CORPORATE); - const sourceRule: Rule = { - scope: CONST.RULES.SCOPE.POLICY, - scopeID: fakePolicy.id, - triggers: indexMap(CONST.RULES.TRIGGERS.CREATE_TRANSACTION), - filters: {left: CONST.RULES.EXPENSE_DEFAULT.FIELD.MERCHANT, operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, right: 'Acme'}, - actions: indexMap({name: CONST.RULES.ACTIONS.SET, field: CONST.RULES.EXPENSE_DEFAULT.FIELD.CATEGORY, value: 'Travel'}), - }; - // The request stays paused so the assertions read the optimistic data, which is where a wrongly copied rule would appear. - mockFetch?.pause?.(); - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); - await waitForBatchedUpdates(); - - const policyID = Policy.generatePolicyID(); - - // When the workspace is duplicated with both the Rules and Merchant rules options unselected - Policy.duplicateWorkspace(fakePolicy, { - currentUserAccountID: ESH_ACCOUNT_ID, - currentUserEmail: ESH_EMAIL, - policyName: 'No Rules Workspace', - policyID: fakePolicy.id, - targetPolicyID: policyID, - welcomeNote: 'Join my policy', - parts: { - people: false, - reports: false, - connections: false, - categories: false, - tags: false, - taxes: false, - perDiem: false, - reimbursements: false, - expenses: false, - distance: false, - invoices: false, - exportLayouts: false, - codingRules: false, - }, - localCurrency: 'USD', - rules: {[`${ONYXKEYS.COLLECTION.RULE}sourceRule`]: sourceRule}, - }); - await waitForBatchedUpdates(); - - // Then no merchant rule is scoped to the duplicate and the Rules feature stays off, because the admin chose not to copy either - const duplicatedRules = Object.values((await getRulesCollection()) ?? {}).filter((rule) => rule?.scopeID === policyID); - expect(duplicatedRules).toHaveLength(0); - const policy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); - expect(policy?.areRulesEnabled).toBe(false); - - await mockFetch?.resume?.(); - await waitForBatchedUpdates(); - }); - it('duplicate workspace with 3+ members creates optimistic announce chat using currentUserAccountID', async () => { await Onyx.set(ONYXKEYS.SESSION, {email: ESH_EMAIL, accountID: ESH_ACCOUNT_ID}); const basePolicy = createRandomPolicy(16, CONST.POLICY.TYPE.TEAM); diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 7a6a36a460fd..dd21ee8098b7 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -10696,115 +10696,6 @@ describe('actions/Report', () => { emailList: `${TEST_USER_LOGIN},passed@test.com`, }); }); - - it('should remove the optimistic thread instead of keeping a createChat error when OpenReport fails', async () => { - // Given an IOU report with an expense action that the user has no thread for yet - const mockFetch = TestHelper.createGlobalFetchMock(); - global.fetch = mockFetch; - - const parentReport: OnyxTypes.Report = { - ...createRandomReport(700, undefined), - reportID: '700', - type: CONST.REPORT.TYPE.IOU, - }; - const reportAction: OnyxTypes.ReportAction = { - ...createRandomReportAction(7), - reportActionID: 'action-700', - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - }; - - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${parentReport.reportID}`, parentReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReport.reportID}`, {[reportAction.reportActionID]: reportAction}); - await waitForBatchedUpdates(); - - // When the thread is created while the OpenReport request is still in flight - mockFetch.pause(); - const result = Report.createTransactionThreadReport({ - introSelected: TEST_INTRO_SELECTED, - conciergeChat: undefined, - currentUserLogin: TEST_USER_LOGIN, - currentUserAccountID: TEST_USER_ACCOUNT_ID, - personalDetails: undefined, - iouReport: parentReport, - iouReportAction: reportAction, - }); - await waitForBatchedUpdates(); - - if (!result) { - throw new Error('Expected a transaction thread report to be created'); - } - - // Then the thread exists optimistically and the parent action points at it - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${result.reportID}` as const)).toBeTruthy(); - expect((await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReport.reportID}` as const))?.[reportAction.reportActionID]?.childReportID).toBe(result.reportID); - - // When OpenReport then fails, e.g. because the expense was deleted while the request sat in the offline queue - mockFetch.fail(); - await mockFetch.resume(); - await waitForNetworkPromises(); - await waitForBatchedUpdates(); - - // Then the thread is rolled back rather than left in Onyx with a createChat error the user cannot dismiss, - // which is what force-displayed it in the LHN as an empty row with a "Fix" badge - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${result.reportID}` as const)).toBeFalsy(); - // The optimistic CREATED action goes, but the key is merged rather than nulled so anything the user - // queued in the thread, e.g. an offline comment, is not destroyed with it. - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${result.reportID}` as const)).toEqual({}); - expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${result.reportID}` as const)).toBeFalsy(); - - // And the parent action no longer links to a report that does not exist - const parentActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReport.reportID}` as const); - expect(parentActions?.[reportAction.reportActionID]?.childReportID).toBeUndefined(); - - mockFetch.succeed(); - }); - }); - - describe('openReport', () => { - const TEST_USER_ACCOUNT_ID = 1; - const TEST_USER_LOGIN = 'test@test.com'; - - beforeEach(async () => { - global.fetch = TestHelper.createGlobalFetchMock(); - await TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN); - await waitForBatchedUpdates(); - }); - - it('should keep a user-initiated new chat with a createChat error when OpenReport fails', async () => { - // Given a chat the user themselves asked to create, so the error is actionable and must be kept - const mockFetch = TestHelper.createGlobalFetchMock(); - global.fetch = mockFetch; - - const REPORT_ID = '710'; - const newReportObject: OnyxTypes.Report = { - ...createRandomReport(710, undefined), - reportID: REPORT_ID, - parentReportID: undefined, - parentReportActionID: undefined, - }; - - // When OpenReport fails for that create - mockFetch.fail(); - Report.openReport({ - reportID: REPORT_ID, - introSelected: TEST_INTRO_SELECTED, - conciergeChat: undefined, - participants: [{login: 'other@test.com', accountID: 2}], - newReportObject, - currentUserLogin: TEST_USER_LOGIN, - currentUserAccountID: TEST_USER_ACCOUNT_ID, - hasReportActions: false, - }); - await waitForNetworkPromises(); - await waitForBatchedUpdates(); - - // Then the report is still there and shows the retryable error, unchanged from before this fix - const report = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}` as const); - expect(report).toBeTruthy(); - expect(report?.errorFields?.createChat).toBeTruthy(); - - mockFetch.succeed(); - }); }); describe('resolveActionableMentionWhisper', () => { diff --git a/tests/actions/SessionTest.ts b/tests/actions/SessionTest.ts index 104afa04df90..e57328263a1c 100644 --- a/tests/actions/SessionTest.ts +++ b/tests/actions/SessionTest.ts @@ -7,7 +7,7 @@ import OnyxUpdateManager from '@libs/actions/OnyxUpdateManager'; import {getAll as getAllPersistedRequests} from '@libs/actions/PersistedRequests'; import {initReconnect} from '@libs/actions/Reconnect'; import * as SignInRedirect from '@libs/actions/SignInRedirect'; -import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import asyncOpenURL from '@libs/asyncOpenURL'; import buildOldDotURL from '@libs/buildOldDotURL'; import getPlatform from '@libs/getPlatform'; @@ -29,8 +29,7 @@ import * as SessionUtil from '@src/libs/actions/Session'; import {KEYS_TO_PRESERVE_SUPPORTAL, signOutAndRedirectToSignIn} from '@src/libs/actions/Session'; import * as API from '@src/libs/API'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Account, Credentials, MarketingAttribution, Session} from '@src/types/onyx'; -import type {StoredMarketingAttribution} from '@src/types/onyx/MarketingAttribution'; +import type {Account, Credentials, Session} from '@src/types/onyx'; import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; @@ -892,7 +891,7 @@ describe('Session', () => { callback: (val) => (session = val), }); - SessionUtil.signInWithShortLivedAuthToken('testAuthToken', undefined, true, undefined, undefined); + SessionUtil.signInWithShortLivedAuthToken('testAuthToken', true, undefined, undefined); await waitForBatchedUpdates(); expect(session?.signedInWithSAML).toBe(true); @@ -905,52 +904,41 @@ describe('Session', () => { callback: (val) => (session = val), }); - SessionUtil.signInWithShortLivedAuthToken('testAuthToken', undefined, false, undefined, undefined); + SessionUtil.signInWithShortLivedAuthToken('testAuthToken', false, undefined, undefined); await waitForBatchedUpdates(); expect(session?.signedInWithSAML).toBe(false); }); test('signInWithShortLivedAuthToken sends the token to the API with the SAML auth method', async () => { - const makeRequestSpy = jest.spyOn(API, 'makeRequestWithSideEffects').mockResolvedValue(undefined); + const readSpy = jest.spyOn(API, 'read').mockImplementation(() => {}); - SessionUtil.signInWithShortLivedAuthToken('testAuthToken', undefined, true, undefined, undefined); + SessionUtil.signInWithShortLivedAuthToken('testAuthToken', true, undefined, undefined); await waitForBatchedUpdates(); - const call = makeRequestSpy.mock.calls.at(0); - expect(call?.at(0)).toBe(SIDE_EFFECT_REQUEST_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN); + const call = readSpy.mock.calls.at(0); + expect(call?.at(0)).toBe(READ_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN); expect(call?.at(1)).toEqual(expect.objectContaining({authToken: 'testAuthToken', authMethod: CONST.AUTH_METHOD.SAML, skipReauthentication: true})); - makeRequestSpy.mockRestore(); + readSpy.mockRestore(); }); test('signInWithShortLivedAuthToken sends the token to the API with the short-lived-token auth method when it is not a SAML sign in', async () => { - const makeRequestSpy = jest.spyOn(API, 'makeRequestWithSideEffects').mockResolvedValue(undefined); + const readSpy = jest.spyOn(API, 'read').mockImplementation(() => {}); - SessionUtil.signInWithShortLivedAuthToken('testAuthToken', undefined, false, undefined, undefined); + SessionUtil.signInWithShortLivedAuthToken('testAuthToken', false, undefined, undefined); await waitForBatchedUpdates(); - expect(makeRequestSpy.mock.calls.at(0)?.at(1)).toEqual(expect.objectContaining({authToken: 'testAuthToken', authMethod: CONST.AUTH_METHOD.SHORT_LIVED_AUTH_TOKEN})); + expect(readSpy.mock.calls.at(0)?.at(1)).toEqual(expect.objectContaining({authToken: 'testAuthToken', authMethod: CONST.AUTH_METHOD.SHORT_LIVED_AUTH_TOKEN})); - makeRequestSpy.mockRestore(); - }); - - test('signInWithShortLivedAuthToken sends the current session authToken to the API as currentAuthToken', async () => { - const makeRequestSpy = jest.spyOn(API, 'makeRequestWithSideEffects').mockResolvedValue(undefined); - - SessionUtil.signInWithShortLivedAuthToken('testAuthToken', 'existingSessionAuthToken', false, undefined, undefined); - await waitForBatchedUpdates(); - - expect(makeRequestSpy.mock.calls.at(0)?.at(1)).toEqual(expect.objectContaining({authToken: 'testAuthToken', currentAuthToken: 'existingSessionAuthToken'})); - - makeRequestSpy.mockRestore(); + readSpy.mockRestore(); }); test('signInWithShortLivedAuthToken does not wait on navigation when no exitTo is passed', async () => { const waitForProtectedRoutesSpy = jest.spyOn(Navigation, 'waitForProtectedRoutes').mockResolvedValue(undefined); const resetRootSpy = jest.spyOn(navigationRef, 'resetRoot').mockImplementation(() => {}); - SessionUtil.signInWithShortLivedAuthToken('testAuthToken', undefined, true, undefined, 'user@saml.example.com'); + SessionUtil.signInWithShortLivedAuthToken('testAuthToken', true, undefined, 'user@saml.example.com'); await waitForBatchedUpdates(); expect(waitForProtectedRoutesSpy).not.toHaveBeenCalled(); @@ -965,7 +953,7 @@ describe('Session', () => { jest.spyOn(Navigation, 'waitForProtectedRoutes').mockResolvedValue(undefined); const resetRootSpy = jest.spyOn(navigationRef, 'resetRoot').mockImplementation(() => {}); - SessionUtil.signInWithShortLivedAuthToken('testAuthToken', undefined, true, '/search?q=status:outstanding', 'user@saml.example.com'); + SessionUtil.signInWithShortLivedAuthToken('testAuthToken', true, '/search?q=status:outstanding', 'user@saml.example.com'); await waitForBatchedUpdates(); expect(resetRootSpy).toHaveBeenCalledTimes(1); @@ -983,7 +971,7 @@ describe('Session', () => { jest.spyOn(Navigation, 'waitForProtectedRoutes').mockResolvedValue(undefined); const resetRootSpy = jest.spyOn(navigationRef, 'resetRoot').mockImplementation(() => {}); - SessionUtil.signInWithShortLivedAuthToken('testAuthToken', undefined, true, '/search?q=status:outstanding', undefined); + SessionUtil.signInWithShortLivedAuthToken('testAuthToken', true, '/search?q=status:outstanding', undefined); await waitForBatchedUpdates(); expect(resetRootSpy).not.toHaveBeenCalled(); @@ -996,7 +984,7 @@ describe('Session', () => { jest.spyOn(Navigation, 'waitForProtectedRoutes').mockResolvedValue(undefined); const resetRootSpy = jest.spyOn(navigationRef, 'resetRoot').mockImplementation(() => {}); - SessionUtil.signInWithShortLivedAuthToken('testAuthToken', undefined, true, '/search?q=status:outstanding', 'user@saml.example.com'); + SessionUtil.signInWithShortLivedAuthToken('testAuthToken', true, '/search?q=status:outstanding', 'user@saml.example.com'); await waitForBatchedUpdates(); expect(resetRootSpy).not.toHaveBeenCalled(); @@ -1463,98 +1451,4 @@ describe('Session', () => { writeSpy.mockRestore(); }); }); - - describe('signUpUser', () => { - // eslint-disable-next-line @typescript-eslint/naming-convention - const googleAdAttribution: MarketingAttribution = {utm_source: 'google', device: 'm', network: 'g', gclid: 'testGclid', wbraid: 'testWbraid'}; - const storedAttribution: StoredMarketingAttribution = {...googleAdAttribution, capturedAt: Date.now()}; - - test('sends the captured marketing attribution and clears it on success', async () => { - // Given marketing attribution captured from a Google ad - const writeSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); - - // When the user signs up with it - SessionUtil.signUpUser('new.user@example.com', CONST.LOCALES.EN, undefined, storedAttribution); - await waitForBatchedUpdates(); - - // Then the attribution is sent with the request params - expect(writeSpy).toHaveBeenCalledWith(WRITE_COMMANDS.SIGN_UP_USER, expect.objectContaining({email: 'new.user@example.com', ...googleAdAttribution}), expect.anything()); - expect(writeSpy.mock.calls.at(0)?.[1]).not.toHaveProperty('capturedAt'); - - // And the success data clears it so it isn't sent again - const onyxData = writeSpy.mock.calls.at(0)?.[2]; - expect(onyxData?.successData).toContainEqual({onyxMethod: Onyx.METHOD.SET, key: ONYXKEYS.MARKETING_ATTRIBUTION, value: null}); - writeSpy.mockRestore(); - }); - - test('sends no attribution params when the stored attribution is expired or has no capture time', async () => { - const writeSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); - - SessionUtil.signUpUser('a@example.com', CONST.LOCALES.EN, undefined, {...googleAdAttribution, capturedAt: Date.now() - 91 * 24 * 60 * 60 * 1000}); - SessionUtil.signUpUser('b@example.com', CONST.LOCALES.EN, undefined, googleAdAttribution); - await waitForBatchedUpdates(); - - expect(writeSpy).toHaveBeenCalledTimes(2); - for (const [, params] of writeSpy.mock.calls) { - expect(Object.keys(params ?? {}).sort()).toEqual(['deviceInfo', 'email', 'preferredLocale']); - } - writeSpy.mockRestore(); - }); - - test('sends no attribution params when none were captured', async () => { - // Given no captured marketing attribution - const writeSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); - - // When the user signs up - SessionUtil.signUpUser('new.user@example.com', CONST.LOCALES.EN); - await waitForBatchedUpdates(); - - // Then the request carries only the signup params - const params = writeSpy.mock.calls.at(0)?.[1] ?? {}; - expect(Object.keys(params).sort()).toEqual(['deviceInfo', 'email', 'preferredLocale']); - writeSpy.mockRestore(); - }); - }); - - describe.each([ - {name: 'beginGoogleSignIn', command: WRITE_COMMANDS.SIGN_IN_WITH_GOOGLE, beginSignIn: SessionUtil.beginGoogleSignIn, tokenParam: 'token'}, - {name: 'beginAppleSignIn', command: WRITE_COMMANDS.SIGN_IN_WITH_APPLE, beginSignIn: SessionUtil.beginAppleSignIn, tokenParam: 'idToken'}, - ])('$name', ({command, beginSignIn, tokenParam}) => { - // eslint-disable-next-line @typescript-eslint/naming-convention - const googleAdAttribution: MarketingAttribution = {utm_source: 'google', gclid: 'testGclid'}; - - test('sends the captured marketing attribution and clears it on success', async () => { - // Given marketing attribution captured from a Google ad - const writeSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); - - // When the user signs in with the third party, which can create a new account - beginSignIn('testToken', CONST.LOCALES.EN, {...googleAdAttribution, capturedAt: Date.now()}); - await waitForBatchedUpdates(); - - // Then the attribution is sent with the request params - expect(writeSpy).toHaveBeenCalledWith(command, expect.objectContaining({[tokenParam]: 'testToken', ...googleAdAttribution}), expect.anything()); - expect(writeSpy.mock.calls.at(0)?.[1]).not.toHaveProperty('capturedAt'); - - // And the success data clears it, while keeping the usual sign in success data - const onyxData = writeSpy.mock.calls.at(0)?.[2]; - expect(onyxData?.successData).toContainEqual({onyxMethod: Onyx.METHOD.SET, key: ONYXKEYS.MARKETING_ATTRIBUTION, value: null}); - expect(onyxData?.successData).toContainEqual(expect.objectContaining({key: ONYXKEYS.ACCOUNT})); - expect(onyxData?.failureData).not.toContainEqual(expect.objectContaining({key: ONYXKEYS.MARKETING_ATTRIBUTION})); - writeSpy.mockRestore(); - }); - - test('sends no attribution params when none were captured', async () => { - // Given no captured marketing attribution - const writeSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); - - // When the user signs in with the third party - beginSignIn('testToken', CONST.LOCALES.EN); - await waitForBatchedUpdates(); - - // Then the request carries only the sign in params - const params = writeSpy.mock.calls.at(0)?.[1] ?? {}; - expect(Object.keys(params).sort()).toEqual(['deviceInfo', 'preferredLocale', tokenParam].sort()); - writeSpy.mockRestore(); - }); - }); }); diff --git a/tests/actions/WorkflowTest.ts b/tests/actions/WorkflowTest.ts index 7b3840c4ed93..bed7f571639d 100644 --- a/tests/actions/WorkflowTest.ts +++ b/tests/actions/WorkflowTest.ts @@ -15,7 +15,6 @@ import { setApprovalWorkflowApprover, updateApprovalWorkflow, updateApprovalWorkflowRules, - validateApprovalWorkflow, } from '@src/libs/actions/Workflow'; import {isApprovalWorkflowRule} from '@src/libs/RuleUtils'; import { @@ -191,105 +190,7 @@ describe('actions/Workflow', () => { }); }); - describe('validateApprovalWorkflow', () => { - const removedApprover: Approver = {email: 'removed@example.com', displayName: 'removed@example.com', isNotWorkspaceMember: true}; - - it('should block saving a workflow whose approver is no longer a workspace member', async () => { - // Given a custom workflow whose only approver was removed from the workspace - const approvalWorkflow: ApprovalWorkflowOnyx = { - ...INITIAL_APPROVAL_WORKFLOW, - members: [{email: employee1Email, displayName: employee1Email}], - approvers: [removedApprover], - }; - await Onyx.set(ONYXKEYS.APPROVAL_WORKFLOW, approvalWorkflow); - - // When the admin tries to save it - const isValid = validateApprovalWorkflow(approvalWorkflow); - await waitForBatchedUpdates(); - - // Then the save is blocked and the approver shows the error offering to replace them or delete the workflow - expect(isValid).toBe(false); - const workflowState = await getOnyxValue(ONYXKEYS.APPROVAL_WORKFLOW); - expect(workflowState?.errors?.['approver-0']).toBe('workflowsPage.approverNotWorkspaceMember'); - }); - - it('should not offer deleting the default workflow when its approver is no longer a workspace member', async () => { - // Given the default workflow, whose approver was removed from the workspace - const approvalWorkflow: ApprovalWorkflowOnyx = { - ...INITIAL_APPROVAL_WORKFLOW, - isDefault: true, - approvers: [removedApprover], - }; - await Onyx.set(ONYXKEYS.APPROVAL_WORKFLOW, approvalWorkflow); - - // When the admin tries to save it - const isValid = validateApprovalWorkflow(approvalWorkflow); - await waitForBatchedUpdates(); - - // Then the save is blocked, and since the default workflow can't be deleted the error only asks for a new approver - expect(isValid).toBe(false); - const workflowState = await getOnyxValue(ONYXKEYS.APPROVAL_WORKFLOW); - expect(workflowState?.errors?.['approver-0']).toBe('workflowsPage.defaultWorkflowApproverNotWorkspaceMember'); - }); - - it('should block saving a workflow whose additional approver for reports over the limit is no longer a workspace member', async () => { - // Given a workflow whose approver is a member, but whose reports over the limit go to someone who was removed - const approvalWorkflow: ApprovalWorkflowOnyx = { - ...INITIAL_APPROVAL_WORKFLOW, - members: [{email: employee1Email, displayName: employee1Email}], - approvers: [{email: ownerEmail, displayName: ownerEmail, approvalLimit: 1000, overLimitForwardsTo: 'removed@example.com', isOverLimitForwardsToNotWorkspaceMember: true}], - }; - await Onyx.set(ONYXKEYS.APPROVAL_WORKFLOW, approvalWorkflow); - - // When the admin tries to save it - const isValid = validateApprovalWorkflow(approvalWorkflow); - await waitForBatchedUpdates(); - - // Then the save is blocked and the approver shows the error asking for a new additional approver - expect(isValid).toBe(false); - const workflowState = await getOnyxValue(ONYXKEYS.APPROVAL_WORKFLOW); - expect(workflowState?.errors?.['approver-0']).toBe('workflowsPage.overLimitApproverNotWorkspaceMember'); - }); - }); - describe('setApprovalWorkflowApprover', () => { - it('should clear the over-limit flag once a member is picked as the additional approver', async () => { - // Given an approver whose reports over the limit go to someone no longer on the workspace - const removedEmail = 'removed@example.com'; - const flaggedApprover: Approver = { - email: ownerEmail, - displayName: ownerEmail, - approvalLimit: 1000, - overLimitForwardsTo: removedEmail, - isOverLimitForwardsToNotWorkspaceMember: true, - }; - const currentApprovalWorkflow: ApprovalWorkflowOnyx = {...INITIAL_APPROVAL_WORKFLOW, approvers: [flaggedApprover]}; - await Onyx.set(ONYXKEYS.APPROVAL_WORKFLOW, currentApprovalWorkflow); - const policy = createMock({ - id: generatePolicyID(), - owner: ownerEmail, - employeeList: { - [ownerEmail]: {email: ownerEmail, role: 'admin', submitsTo: ownerEmail}, - [employee1Email]: {email: employee1Email, role: 'user', submitsTo: ownerEmail}, - }, - }); - - // When the admin picks a member as the new additional approver - setApprovalWorkflowApprover({ - approver: {...flaggedApprover, overLimitForwardsTo: employee1Email}, - approverIndex: 0, - policy, - currentApprovalWorkflow, - personalDetailsByEmail: {}, - }); - await waitForBatchedUpdates(); - - // Then the approver is no longer flagged, so the workflow can be saved - const approvalWorkflow = await getApprovalWorkflowState(); - expect(approvalWorkflow?.approvers.at(0)?.overLimitForwardsTo).toBe(employee1Email); - expect(approvalWorkflow?.approvers.at(0)?.isOverLimitForwardsToNotWorkspaceMember).toBeFalsy(); - }); - it('should add an approver at an empty index', async () => { mockFetch.pause(); @@ -878,59 +779,6 @@ describe('actions/Workflow', () => { await mockFetch.resume(); await waitForBatchedUpdates(); }); - - it('should drop to BASIC mode without sending the non-member when a workflow whose approver left the workspace is deleted', async () => { - mockFetch.pause(); - - // Given an ADVANCED policy where employee3 still submits to someone no longer on the workspace - const removedApproverEmail = 'removed@gmail.com'; - const policy = createMock({ - id: '123456789', - name: 'Test Workspace', - role: 'admin', - type: 'corporate', - owner: ownerEmail, - approver: ownerEmail, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - employeeList: { - [ownerEmail]: {email: ownerEmail, forwardsTo: '', role: 'admin', submitsTo: ownerEmail}, - [employee1Email]: {email: employee1Email, forwardsTo: '', role: 'user', submitsTo: ownerEmail}, - [employee3Email]: {email: employee3Email, forwardsTo: '', role: 'user', submitsTo: removedApproverEmail}, - }, - }); - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); - await Onyx.merge(ONYXKEYS.SESSION, {authToken: '123456789'}); - await waitForBatchedUpdates(); - - // When the admin deletes the flagged workflow shown on the Workflows page - const {approvalWorkflows} = convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails: {}, localeCompare: (a: string, b: string) => a.localeCompare(b)}); - const brokenWorkflow = approvalWorkflows.find((workflow) => workflow.approvers.at(0)?.isNotWorkspaceMember); - expect(brokenWorkflow?.members.map((member) => member.email)).toEqual([employee3Email]); - if (!brokenWorkflow) { - return; - } - removeApprovalWorkflow(brokenWorkflow, policy); - await waitForBatchedUpdates(); - - // Then the workspace drops to BASIC, the member's `submitsTo` is cleared so the backend sends them to the - // default approver, and the request carries no entry for the non-member approver - const updatedPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); - expect(updatedPolicy?.approvalMode).toBe(CONST.POLICY.APPROVAL_MODE.BASIC); - expect(updatedPolicy?.employeeList?.[employee3Email]?.submitsTo).toBe(''); - expect(updatedPolicy?.employeeList?.[removedApproverEmail]).toBeUndefined(); - - const requestBody = getFetchMockCalls(WRITE_COMMANDS.REMOVE_WORKSPACE_APPROVAL).at(0)?.[1]?.body; - const sentEmployees = requestBody instanceof FormData ? requestBody.get('employees') : undefined; - expect(typeof sentEmployees === 'string' ? sentEmployees : '').toContain(employee3Email); - expect(typeof sentEmployees === 'string' ? sentEmployees : '').not.toContain(removedApproverEmail); - - // And once the request succeeds, the member is no longer shown as pending - await mockFetch.resume(); - await waitForBatchedUpdates(); - const settledPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); - expect(settledPolicy?.employeeList?.[employee3Email]?.pendingAction).toBeFalsy(); - expect(settledPolicy?.employeeList?.[employee3Email]?.pendingFields).toBeFalsy(); - }); }); describe('updateApprovalWorkflow', () => { diff --git a/tests/perf-test/DynamicReportDetailsPage.perf-test.tsx b/tests/perf-test/DynamicReportDetailsPage.perf-test.tsx index 9fac18b9d013..a52ff68961b5 100644 --- a/tests/perf-test/DynamicReportDetailsPage.perf-test.tsx +++ b/tests/perf-test/DynamicReportDetailsPage.perf-test.tsx @@ -206,10 +206,16 @@ function Wrapper({children}: {children: React.ReactNode}) { return {children}; } +// withReportOrNotFound reads the report from Onyx by the route reportID, so the report prop only satisfies the wrapped props type. function renderPage(report: Report) { return ( ); diff --git a/tests/ui/AuthScreensInitHandlerTest.tsx b/tests/ui/AuthScreensInitHandlerTest.tsx index 8468adea42b4..bf024aebff8c 100644 --- a/tests/ui/AuthScreensInitHandlerTest.tsx +++ b/tests/ui/AuthScreensInitHandlerTest.tsx @@ -266,20 +266,7 @@ describe('AuthScreensInitHandler', () => { expect(mockedSubscribeToUserEvents).not.toHaveBeenCalled(); }); - it('signs out when logging in as a new user via a supportal transition', async () => { - mockedGetCurrentUrl.mockReturnValue(`https://new.expensify.com/${ROUTES.TRANSITION_BETWEEN_APPS}?authTokenType=${CONST.AUTH_TOKEN_TYPES.SUPPORT}`); - mockedIsLoggingInAsNewUser.mockReturnValue(true); - - await Onyx.merge(ONYXKEYS.SESSION, {accountID: TEST_ACCOUNT_ID, email: 'test@test.com'}); - await waitForBatchedUpdates(); - - renderAuthScreensInitHandler(); - await waitForBatchedUpdatesWithAct(); - - expect(signOutAndRedirectToSignIn).toHaveBeenCalledWith(false, true, true, undefined, CONST.SIGN_OUT_REASON.LOGIN_AS_NEW_USER); - }); - - it('does not sign out for a non-supportal transition', async () => { + it('signs out when logging in as new user during transition', async () => { mockedGetCurrentUrl.mockReturnValue(`https://new.expensify.com/${ROUTES.TRANSITION_BETWEEN_APPS}`); mockedIsLoggingInAsNewUser.mockReturnValue(true); @@ -289,8 +276,7 @@ describe('AuthScreensInitHandler', () => { renderAuthScreensInitHandler(); await waitForBatchedUpdatesWithAct(); - // LogOutPreviousUserPage owns this case, gated behind its own confirm modal. - expect(signOutAndRedirectToSignIn).not.toHaveBeenCalled(); + expect(signOutAndRedirectToSignIn).toHaveBeenCalledWith(false, false, true, undefined, CONST.SIGN_OUT_REASON.LOGIN_AS_NEW_USER); }); it('calls openApp when didUserLogInDuringSession returns true', async () => { diff --git a/tests/ui/DynamicIOURequestStepUpgradeTest.tsx b/tests/ui/DynamicIOURequestStepUpgradeTest.tsx deleted file mode 100644 index 22b2b762de61..000000000000 --- a/tests/ui/DynamicIOURequestStepUpgradeTest.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import {act, fireEvent, render, screen} from '@testing-library/react-native'; - -import {CurrencyListContextProvider} from '@components/CurrencyListContextProvider'; -import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; -import {LocaleContextProvider} from '@components/LocaleContextProvider'; -import OnyxListItemProvider from '@components/OnyxListItemProvider'; -import type {WorkspaceConfirmationSubmitFunctionParams} from '@components/WorkspaceConfirmationForm'; - -import Navigation from '@libs/Navigation/Navigation'; - -import DynamicIOURequestStepUpgrade from '@pages/iou/request/step/DynamicIOURequestStepUpgrade'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import SCREENS from '@src/SCREENS'; - -import type * as NativeNavigation from '@react-navigation/native'; - -import React from 'react'; -import Onyx from 'react-native-onyx'; - -import {signInWithTestUser} from '../utils/TestHelper'; -import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; - -jest.mock('@hooks/useConfirmModal', () => () => ({showConfirmModal: jest.fn()})); - -jest.mock('@libs/Navigation/Navigation', () => ({ - navigate: jest.fn(), - goBack: jest.fn(), - getActiveRoute: jest.fn(() => ''), - getActiveRouteWithoutParams: jest.fn(() => ''), - isNavigationReady: jest.fn(() => Promise.resolve()), - setNavigationActionToMicrotaskQueue: jest.fn((callback: () => void) => callback()), -})); - -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: jest.fn(() => ({navigate: jest.fn(), addListener: jest.fn(() => jest.fn())})), - useIsFocused: jest.fn(() => true), - useFocusEffect: jest.fn(), - useRoute: jest.fn(() => ({key: '', name: '', params: {}})), - usePreventRemove: jest.fn(), -})); - -jest.mock('@src/libs/actions/Policy/Policy', () => ({ - ...jest.requireActual>('@src/libs/actions/Policy/Policy'), - createWorkspace: jest.fn(() => ({policyID: 'newPolicyID', policyName: 'Workspace'})), -})); - -// Each step of the upgrade is reduced to the one button that moves it forward. -function mockStepButton(label: string, onPress: () => void) { - const {Pressable, Text} = jest.requireActual void; role?: string}>>>('react-native'); - return ( - - {label} - - ); -} -jest.mock( - '@pages/workspace/upgrade/UpgradeIntro', - () => - ({onUpgrade}: {onUpgrade: () => void}) => - mockStepButton('upgrade', onUpgrade), -); -jest.mock( - '@components/WorkspaceConfirmationForm', - () => - ({onSubmit}: {onSubmit: (params: WorkspaceConfirmationSubmitFunctionParams) => void}) => - mockStepButton('confirm', () => onSubmit({name: 'Workspace', currency: 'USD', makeMeAdmin: false, avatarFile: undefined, policyID: 'newPolicyID'})), -); -jest.mock( - '@pages/workspace/upgrade/UpgradeConfirmation', - () => - ({afterUpgradeAcknowledged}: {afterUpgradeAcknowledged: () => void}) => - mockStepButton('gotIt', afterUpgradeAcknowledged), -); - -const TRANSACTION_ID = 'transaction-1'; -const REPORT_ID = 'report-1'; - -const renderUpgradeStep = (shouldReturnToConfirmation: boolean) => - render( - - - - - - - - - , - ); - -const upgradeAndAcknowledge = async () => { - fireEvent.press(screen.getByText('upgrade')); - await waitForBatchedUpdatesWithAct(); - fireEvent.press(screen.getByText('confirm')); - await waitForBatchedUpdatesWithAct(); - fireEvent.press(screen.getByText('gotIt')); - await waitForBatchedUpdatesWithAct(); -}; - -describe('DynamicIOURequestStepUpgrade', () => { - beforeAll(() => { - Onyx.init({keys: ONYXKEYS}); - }); - - beforeEach(async () => { - jest.clearAllMocks(); - await signInWithTestUser(1, 'test@user.com'); - }); - - afterEach(async () => { - await act(async () => { - await Onyx.clear(); - }); - }); - - it('only goes back to the confirmation form after a category upgrade that asked to return there', async () => { - // Given the category upgrade, opened from a confirmation form that opens its own category list - renderUpgradeStep(true); - - // When the user upgrades and acknowledges it - await upgradeAndAcknowledge(); - - // Then the upgrade screen closes without pushing the full-page Category step, so the form can open its list in place - expect(Navigation.goBack).toHaveBeenCalledTimes(1); - expect(Navigation.navigate).not.toHaveBeenCalled(); - expect(Navigation.setNavigationActionToMicrotaskQueue).not.toHaveBeenCalled(); - }); - - it('opens the full-page Category step after a category upgrade that did not ask to return', async () => { - // Given the category upgrade, opened from a place that relies on the full-page Category step - renderUpgradeStep(false); - - // When the user upgrades and acknowledges it - await upgradeAndAcknowledge(); - - // Then the upgrade screen closes and the Category step opens, as before - expect(Navigation.goBack).toHaveBeenCalledTimes(1); - expect(Navigation.navigate).toHaveBeenCalledTimes(1); - }); -}); diff --git a/tests/ui/DynamicReportDetailsPageTest.tsx b/tests/ui/DynamicReportDetailsPageTest.tsx index 495c6f481ef3..39c08307f33b 100644 --- a/tests/ui/DynamicReportDetailsPageTest.tsx +++ b/tests/ui/DynamicReportDetailsPageTest.tsx @@ -117,7 +117,12 @@ describe('DynamicReportDetailsPage', () => { @@ -146,7 +151,12 @@ describe('DynamicReportDetailsPage', () => { @@ -169,7 +179,12 @@ describe('DynamicReportDetailsPage', () => { @@ -305,7 +320,12 @@ describe('DynamicReportDetailsPage', () => { @@ -337,7 +357,12 @@ describe('DynamicReportDetailsPage', () => { diff --git a/tests/ui/IOURequestRedirectToStartPageTest.tsx b/tests/ui/IOURequestRedirectToStartPageTest.tsx index 96e1fd272e20..567e288811a0 100644 --- a/tests/ui/IOURequestRedirectToStartPageTest.tsx +++ b/tests/ui/IOURequestRedirectToStartPageTest.tsx @@ -24,7 +24,6 @@ import Onyx from 'react-native-onyx'; import {getGlobalFetchMock} from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; -jest.mock('@libs/API'); jest.mock('@userActions/Tab'); jest.mock('@rnmapbox/maps', () => ({ default: jest.fn(), diff --git a/tests/ui/LogInWithShortLivedAuthTokenPageTest.tsx b/tests/ui/LogInWithShortLivedAuthTokenPageTest.tsx index 259484c434e2..70841fbbcc3f 100644 --- a/tests/ui/LogInWithShortLivedAuthTokenPageTest.tsx +++ b/tests/ui/LogInWithShortLivedAuthTokenPageTest.tsx @@ -25,7 +25,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({ })); jest.mock('@userActions/Session', () => ({ - signInWithShortLivedAuthToken: jest.fn(() => Promise.resolve()), + signInWithShortLivedAuthToken: jest.fn(), signInWithSupportAuthToken: jest.fn(), setAccountError: jest.fn(), })); @@ -71,7 +71,7 @@ describe('LogInWithShortLivedAuthTokenPage', () => { renderPage({shortLivedAuthToken: 'token', isSAML: true, shouldForceLogin: ''}); await waitForBatchedUpdatesWithAct(); - expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', undefined, true, '/search?q=status:outstanding', undefined); + expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', true, '/search?q=status:outstanding', undefined); }); it('passes the stored login to the sign-in so it can compare against the signed-in session', async () => { @@ -83,14 +83,14 @@ describe('LogInWithShortLivedAuthTokenPage', () => { renderPage({shortLivedAuthToken: 'token', isSAML: true, shouldForceLogin: ''}); await waitForBatchedUpdatesWithAct(); - expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', undefined, true, '/search?q=status:outstanding', 'user@saml.example.com'); + expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', true, '/search?q=status:outstanding', 'user@saml.example.com'); }); it('signs in with SAML without a landing page when nothing was kept', async () => { renderPage({shortLivedAuthToken: 'token', isSAML: true, shouldForceLogin: ''}); await waitForBatchedUpdatesWithAct(); - expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', undefined, true, undefined, undefined); + expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', true, undefined, undefined); }); it('signs in with a SAML token while the account is still marked loading by a forced re-auth', async () => { @@ -102,7 +102,7 @@ describe('LogInWithShortLivedAuthTokenPage', () => { renderPage({shortLivedAuthToken: 'token', isSAML: true, shouldForceLogin: ''}); await waitForBatchedUpdatesWithAct(); - expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', undefined, true, '/search?q=status:outstanding', undefined); + expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', true, '/search?q=status:outstanding', undefined); }); it('waits for the account while it is loading for a non-SAML sign-in', async () => { @@ -124,6 +124,6 @@ describe('LogInWithShortLivedAuthTokenPage', () => { renderPage({shortLivedAuthToken: 'token', shouldForceLogin: ''}); await waitForBatchedUpdatesWithAct(); - expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', undefined, false, undefined, undefined); + expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', false, undefined, undefined); }); }); diff --git a/tests/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index 9621c3343ef3..7546e6679f08 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -634,35 +634,6 @@ describe('MoneyRequestView edit fields', () => { }); }); - it('shows a negative tax amount for a negative expense on an expense report', async () => { - // Given a negative expense on an expense report. Expense reports store amounts with the opposite sign, - // so a -$9.50 expense with -$0.95 tax is stored as amount 950 and taxAmount 95. - const threadReport = { - ...LHNTestUtils.getFakeReport(), - parentReportID: expenseReportID, - parentReportActionID, - }; - - await setupTestData(); - await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { - amount: 950, - taxCode: 'TAX_10', - taxAmount: 95, - }); - }); - await waitForBatchedUpdatesWithAct(); - - // When the expense details are rendered - renderMoneyRequestView(threadReport, {tax: {trackingEnabled: true}}); - await waitForBatchedUpdatesWithAct(); - - // Then the tax amount keeps the negative sign instead of being shown as an absolute value - await waitFor(() => { - expect(screen.getByLabelText('iou.taxAmount, USD-95')).toBeOnTheScreen(); - }); - }); - it('does NOT append "Converted" to the Tax amount description when the converted tax is zero (tax exempt)', async () => { const threadReport = { ...LHNTestUtils.getFakeReport(), diff --git a/tests/ui/OnboardingPurpose.tsx b/tests/ui/OnboardingPurpose.tsx index 80b19514c7aa..7266f827bd6a 100644 --- a/tests/ui/OnboardingPurpose.tsx +++ b/tests/ui/OnboardingPurpose.tsx @@ -32,7 +32,6 @@ import React from 'react'; import Onyx from 'react-native-onyx'; import createMock from '../utils/createMock'; -import getOnyxValue from '../utils/getOnyxValue'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -202,30 +201,6 @@ describe('OnboardingPurpose Page', () => { await waitForBatchedUpdatesWithAct(); }); - it('should clear a stale merge-blocked flag when a public-domain user selects Join Workspace', async () => { - // Given a public-domain user whose earlier work email merge was blocked - await TestHelper.signInWithTestUser(1, 'test@gmail.com'); - await act(async () => { - await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {isMergingAccountBlocked: true}); - }); - - const {unmount} = renderOnboardingPurposePage(SCREENS.ONBOARDING.PURPOSE, {backTo: ''}); - await waitForBatchedUpdatesWithAct(); - - // When they select Join Workspace - const user = userEvent.setup(); - await user.press(screen.getByLabelText(translatePurpose(CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE))); - await waitForBatchedUpdatesWithAct(); - - // Then the flag is cleared so the work email step shows the form instead of the blocked view - expect(navigate).toHaveBeenCalledWith(ROUTES.ONBOARDING_WORK_EMAIL.getRoute()); - const onboarding = await getOnyxValue(ONYXKEYS.NVP_ONBOARDING); - expect(onboarding?.isMergingAccountBlocked).toBe(false); - - unmount(); - await waitForBatchedUpdatesWithAct(); - }); - it('should navigate to personal details page when user selects EMPLOYER and is from public domain', async () => { await TestHelper.signInWithTestUser(); diff --git a/tests/ui/ReportActionMessageEditLayoutTest.tsx b/tests/ui/ReportActionMessageEditLayoutTest.tsx index d21e3544074d..24d8f6bf536f 100644 --- a/tests/ui/ReportActionMessageEditLayoutTest.tsx +++ b/tests/ui/ReportActionMessageEditLayoutTest.tsx @@ -62,13 +62,6 @@ const wideLayout = createMock>({ isSmallScreen: false, }); -// A report opened in the RHP on a wide screen: shouldUseNarrowLayout is true, but the screen is not small -const wideRHPLayout = createMock>({ - ...wideLayout, - shouldUseNarrowLayout: true, - isInNarrowPaneModal: true, -}); - jest.mock('@libs/getPlatform', () => ({ __esModule: true, default: () => 'web', @@ -382,31 +375,6 @@ describe('ReportActionMessageEdit layout and draft (narrow vs wide)', () => { expect(screen.queryByTestId(testIds.MESSAGE_EDIT_CANCEL_MAIN_COMPOSER)).toBeNull(); }); - it('in the RHP on a wide screen, edits stay inline and typing in the main composer does not change the edit draft', async () => { - // Given a report opened in the RHP on a wide screen, where shouldUseNarrowLayout is true but the screen is not small - await seedReportAndActions(); - await setReportActionDraftWithMessage('RHP inline'); - await waitForBatchedUpdatesWithAct(); - - mockUseResponsiveLayout.mockReturnValue(wideRHPLayout); - render(); - await waitForBatchedUpdatesWithAct(); - - // Then the main composer stays in normal draft mode and does not take the edited message - const mainRoot = screen.getByTestId(testIds.REPORT_ACTION_COMPOSE); - expect(within(mainRoot).getByTestId(testIds.DRAFT_MESSAGE_ACTION_ROW)).toBeOnTheScreen(); - expect(within(mainRoot).queryByTestId(testIds.EDITING_MESSAGE_ACTION_ROW)).toBeNull(); - expect(within(mainRoot).getByTestId(CONST.COMPOSER.NATIVE_ID).props.value).not.toBe('RHP inline'); - expect(screen.getByTestId(testIds.REPORT_ACTION_ITEM_MESSAGE_EDIT)).toBeOnTheScreen(); - - // When the user types in the main composer - fireEvent.changeText(within(mainRoot).getByTestId(CONST.COMPOSER.NATIVE_ID), 'New comment'); - await waitForBatchedUpdatesWithAct(); - - // Then the inline edit draft is unchanged, because the main composer only edits messages on small screens - expect(await getReportActionDraftMessage(defaultReport.reportID, commentAction.reportActionID)).toBe('RHP inline'); - }); - it('switches the editing surface from inline (wide) to main composer (narrow) when layout becomes narrow', async () => { await seedReportAndActions(); await setReportActionDraftWithMessage('Shared draft'); diff --git a/tests/ui/SAMLSignInPageTest.tsx b/tests/ui/SAMLSignInPageTest.tsx index b7ed74062a70..0e62e258f3c5 100644 --- a/tests/ui/SAMLSignInPageTest.tsx +++ b/tests/ui/SAMLSignInPageTest.tsx @@ -46,7 +46,7 @@ jest.mock('@userActions/Session', () => ({ clearSignInData: jest.fn(), setAccountError: jest.fn(), setIsAuthenticatingWithShortLivedToken: jest.fn(), - signInWithShortLivedAuthToken: jest.fn().mockResolvedValue(undefined), + signInWithShortLivedAuthToken: jest.fn(), })); const mockedOpenAuthSessionAsync = jest.mocked(openAuthSessionAsync); @@ -169,7 +169,7 @@ describe('SAMLSignInPage', () => { renderPage(); await waitForBatchedUpdatesWithAct(); - expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', undefined, true, '/search?q=status:outstanding', 'user@saml.example.com'); + expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', true, '/search?q=status:outstanding', 'user@saml.example.com'); expect(clearSignInData).not.toHaveBeenCalled(); }); @@ -181,6 +181,6 @@ describe('SAMLSignInPage', () => { renderPage(); await waitForBatchedUpdatesWithAct(); - expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', undefined, true, '/search?q=status:outstanding', 'user@saml.example.com'); + expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', true, '/search?q=status:outstanding', 'user@saml.example.com'); }); }); diff --git a/tests/ui/SessionTest.tsx b/tests/ui/SessionTest.tsx index 07a2a0ef624f..e8fab6024baa 100644 --- a/tests/ui/SessionTest.tsx +++ b/tests/ui/SessionTest.tsx @@ -1,4 +1,4 @@ -import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; +import {act, cleanup, render, waitFor} from '@testing-library/react-native'; import * as AppActions from '@libs/actions/App'; import * as Device from '@libs/actions/Device'; @@ -18,8 +18,6 @@ import {Str} from 'expensify-common'; import {Linking} from 'react-native'; import Onyx from 'react-native-onyx'; -import type * as MockReanimatedModalModule from '../utils/mockReanimatedModal'; - import {createRandomReport} from '../utils/collections/reports'; import createMock from '../utils/createMock'; import PusherHelper from '../utils/PusherHelper'; @@ -33,11 +31,6 @@ jest.mock('@libs/BootSplash', () => ({ hide: jest.fn().mockResolvedValue(undefined), })); -jest.mock('@components/Modal/ReanimatedModal', () => { - const {default: MockReanimatedModal} = jest.requireActual('../utils/mockReanimatedModal'); - return MockReanimatedModal; -}); - const TEST_USER_ACCOUNT_ID_1 = 123; const TEST_USER_LOGIN_1 = 'test@test.com'; // cspell:disable-next-line @@ -126,7 +119,7 @@ describe('Deep linking', () => { }, [ONYXKEYS.NVP_PRIVATE_PUSH_NOTIFICATION_ID]: 'randomID', }); - return originalSignInWithShortLivedAuthToken(TEST_AUTH_TOKEN_1, undefined, false, undefined, undefined); + return originalSignInWithShortLivedAuthToken(TEST_AUTH_TOKEN_1, false, undefined, undefined); }); // Set the keys the app needs to finish loading rather than going through @@ -234,10 +227,6 @@ describe('Deep linking', () => { await waitForBatchedUpdatesWithAct(); - fireEvent.press(await screen.findByRole(CONST.ROLE.BUTTON, {name: 'Switch accounts'})); - await waitForBatchedUpdatesWithAct(); - await waitForNetworkPromises(); - expect(getCurrentUserEmail()).toBe(TEST_USER_LOGIN_1); await TestHelper.signOutTestUser(); @@ -262,104 +251,6 @@ describe('Deep linking', () => { }, FULL_APP_UI_TEST_TIMEOUT_MS, ); - - // Renders the full App twice to reach the switch-account prompt, so it runs longer than the suite default (see FULL_APP_UI_TEST_TIMEOUT_MS). - it( - 'should show the current and new account emails in the switch account prompt', - async () => { - // Given a user signed in as account 2 - const {unmount: unmount1} = render(); - await TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID_2, TEST_USER_LOGIN_2, undefined, TEST_AUTH_TOKEN_2); - - await waitForBatchedUpdatesWithAct(); - - unmount1(); - await waitForBatchedUpdatesWithAct(); - await waitForNetworkPromises(); - - // When the app remounts because of a deep link that would sign them in as account 1 instead - const url = getInitialURL(); - Linking.setInitialURL(url); - const {unmount: unmount2} = render(); - - await waitForBatchedUpdatesWithAct(); - - // Then the prompt shows both the current and the new account emails, not a blank one - const prompt = await screen.findByText(`You're signed in as ${TEST_USER_LOGIN_2}. This action will sign you in as ${TEST_USER_LOGIN_1} instead.`); - expect(prompt).toBeOnTheScreen(); - - unmount2(); - await waitForBatchedUpdatesWithAct(); - await waitForNetworkPromises(); - }, - FULL_APP_UI_TEST_TIMEOUT_MS, - ); - - // Renders the full App twice to reach the switch-account prompt, so it runs longer than the suite default (see FULL_APP_UI_TEST_TIMEOUT_MS). - it( - 'should stay on the current account when the switch is canceled', - async () => { - // Given a user signed in as account 2 - const {unmount: unmount1} = render(); - await TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID_2, TEST_USER_LOGIN_2, undefined, TEST_AUTH_TOKEN_2); - - await waitForBatchedUpdatesWithAct(); - - unmount1(); - await waitForBatchedUpdatesWithAct(); - await waitForNetworkPromises(); - - // When the app remounts because of a deep link that would sign them in as account 1, and they cancel the switch - const url = getInitialURL(); - Linking.setInitialURL(url); - const {unmount: unmount2} = render(); - - await waitForBatchedUpdatesWithAct(); - - fireEvent.press(await screen.findByRole(CONST.ROLE.BUTTON, {name: TestHelper.translateLocal('deeplinkWrapper.switchAccount.staySignedIn')})); - await waitForBatchedUpdatesWithAct(); - await waitForNetworkPromises(); - - // Then they stay signed in as account 2 and nothing was signed out - expect(hasAuthToken()).toBe(true); - expect(getCurrentUserEmail()).toBe(TEST_USER_LOGIN_2); - - unmount2(); - await waitForBatchedUpdatesWithAct(); - await waitForNetworkPromises(); - }, - FULL_APP_UI_TEST_TIMEOUT_MS, - ); - - // Renders the full App to reach the session mismatch modal, so it runs longer than the suite default (see FULL_APP_UI_TEST_TIMEOUT_MS). - it( - 'should show a session mismatch modal when the shortLivedAuthToken does not match the current session', - async () => { - // Given a user already signed in as account 1 - jest.spyOn(Session, 'signInWithShortLivedAuthToken').mockImplementation(() => - Promise.resolve({type: CONST.ERROR_TYPE.SESSION_MISMATCH} as Awaited>), - ); - await TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID_1, TEST_USER_LOGIN_1, undefined, TEST_AUTH_TOKEN_1); - await waitForBatchedUpdatesWithAct(); - - // When they open the same transition link again - const url = getInitialURL(); - Linking.setInitialURL(url); - const {unmount} = render(); - - await waitForBatchedUpdatesWithAct(); - await waitForNetworkPromises(); - - // Then a modal tells them the link is not valid for their current session - const prompt = await screen.findByText("The link you clicked isn't valid for your current session."); - expect(prompt).toBeOnTheScreen(); - - unmount(); - await waitForBatchedUpdatesWithAct(); - await waitForNetworkPromises(); - }, - FULL_APP_UI_TEST_TIMEOUT_MS, - ); }); describe('Support auth token login', () => { @@ -523,7 +414,7 @@ describe('signInWithShortLivedAuthToken', () => { }), ); - Session.signInWithShortLivedAuthToken('token', undefined, true, undefined, undefined); + Session.signInWithShortLivedAuthToken('token', true, undefined, undefined); await waitForBatchedUpdates(); let isAuthenticating: boolean | undefined; diff --git a/tests/ui/WorkspaceMemberDetailsPageTest.tsx b/tests/ui/WorkspaceMemberDetailsPageTest.tsx index a0f7cf1bce9d..61ef607f8613 100644 --- a/tests/ui/WorkspaceMemberDetailsPageTest.tsx +++ b/tests/ui/WorkspaceMemberDetailsPageTest.tsx @@ -3,11 +3,10 @@ import {act, fireEvent, render, screen, waitFor, within} from '@testing-library/ import ComposeProviders from '@components/ComposeProviders'; import HTMLEngineProvider from '@components/HTMLEngineProvider'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; -import {ModalActions, ModalProvider} from '@components/Modal/Global/ModalContext'; +import {ModalProvider} from '@components/Modal/Global/ModalContext'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import PersonalDetailsByLoginProvider from '@components/PersonalDetailsByLoginProvider'; -import * as useConfirmModalModule from '@hooks/useConfirmModal'; import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; import * as useResponsiveLayoutModule from '@hooks/useResponsiveLayout'; import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; @@ -30,7 +29,6 @@ import React from 'react'; import Onyx from 'react-native-onyx'; import createMock from '../utils/createMock'; -import getOnyxValue from '../utils/getOnyxValue'; import * as LHNTestUtils from '../utils/LHNTestUtils'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -301,40 +299,6 @@ describe('WorkspaceMemberDetailsPage', () => { await waitForBatchedUpdatesWithAct(); }); - it('should reassign the submitters of a removed approver whose personal details are missing', async () => { - // Given an approver with no personal details loaded, who a member submits to, opened through the route - // accountID derived from their login - const approverWithoutDetails = 'nodetails@example.com'; - await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - employeeList: { - [approverWithoutDetails]: {email: approverWithoutDetails, role: CONST.POLICY.ROLE.USER}, - [invitedEmail]: {email: invitedEmail, role: CONST.POLICY.ROLE.USER, submitsTo: approverWithoutDetails}, - }, - }); - }); - - // The admin confirms the removal prompt - const showConfirmModal = jest.fn(() => Promise.resolve({action: ModalActions.CONFIRM})); - jest.spyOn(useConfirmModalModule, 'default').mockReturnValue(createMock>({showConfirmModal})); - const {unmount} = renderPage({policyID: policy.id, accountID: String(generateAccountID(approverWithoutDetails))}); - await waitForBatchedUpdatesWithAct(); - - // When the admin removes that approver - fireEvent.press(await screen.findByText(TestHelper.translateLocal('workspace.people.removeWorkspaceMemberButtonTitle'))); - await waitForBatchedUpdatesWithAct(); - expect(showConfirmModal).toHaveBeenCalledTimes(1); - - // Then the member's `submitsTo` is cleared so the backend sends them to the default approver, instead of - // being left submitting to someone no longer on the workspace - const updatedPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); - expect(updatedPolicy?.employeeList?.[invitedEmail]?.submitsTo).toBe(''); - - unmount(); - await waitForBatchedUpdatesWithAct(); - }); - it('should show the unable-to-remove modal when the member is a RuleBot enforcing agent rules', async () => { // The invited member acts as the workspace RuleBot with an active agent rule await act(async () => { diff --git a/tests/ui/WorkspaceMembersTest.tsx b/tests/ui/WorkspaceMembersTest.tsx index a33f55062f73..e393441b028c 100644 --- a/tests/ui/WorkspaceMembersTest.tsx +++ b/tests/ui/WorkspaceMembersTest.tsx @@ -4,10 +4,9 @@ import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import ComposeProviders from '@components/ComposeProviders'; import HTMLEngineProvider from '@components/HTMLEngineProvider'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; -import {ModalActions, ModalProvider} from '@components/Modal/Global/ModalContext'; +import {ModalProvider} from '@components/Modal/Global/ModalContext'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; -import * as useConfirmModalModule from '@hooks/useConfirmModal'; import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; import * as useResponsiveLayoutModule from '@hooks/useResponsiveLayout'; import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; @@ -28,7 +27,6 @@ import React from 'react'; import Onyx from 'react-native-onyx'; import createMock from '../utils/createMock'; -import getOnyxValue from '../utils/getOnyxValue'; import * as LHNTestUtils from '../utils/LHNTestUtils'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -573,44 +571,6 @@ describe('WorkspaceMembers', () => { unmount(); }); - - it('should reassign the submitters of a removed approver whose personal details are missing', async () => { - // Given an approver with no personal details loaded, who a member submits to - const approverWithoutDetails = 'nodetails@example.com'; - await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - employeeList: { - [approverWithoutDetails]: {email: approverWithoutDetails, role: CONST.POLICY.ROLE.USER}, - [userEmail]: {email: userEmail, role: CONST.POLICY.ROLE.USER, submitsTo: approverWithoutDetails}, - }, - }); - }); - // The admin confirms the removal prompt - const showConfirmModal = jest.fn(() => Promise.resolve({action: ModalActions.CONFIRM})); - const confirmModalSpy = jest.spyOn(useConfirmModalModule, 'default').mockReturnValue(createMock>({showConfirmModal})); - const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); - await waitForBatchedUpdatesWithAct(); - - // When the admin removes that approver - const row = await screen.findByLabelText(new RegExp(`^${approverWithoutDetails}`)); - fireEvent.press(within(row).getByLabelText(TestHelper.translateLocal('common.select'))); - fireEvent.press(await screen.findByTestId('WorkspaceMembersPage-header-dropdown-menu-button')); - await waitForBatchedUpdatesWithAct(); - const removeMenuItem = screen.getByText(TestHelper.translateLocal('workspace.people.removeMembersTitle', {count: 1})); - fireEvent.press(removeMenuItem, {nativeEvent: {}, type: 'press', target: removeMenuItem, currentTarget: removeMenuItem}); - await waitForBatchedUpdatesWithAct(); - expect(showConfirmModal).toHaveBeenCalledTimes(1); - - // Then the approver is removed and the member is moved to the workspace owner, instead of being left - // submitting to someone no longer on the workspace - const updatedPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); - expect(updatedPolicy?.employeeList?.[approverWithoutDetails]).toBeUndefined(); - expect(updatedPolicy?.employeeList?.[userEmail]?.submitsTo).toBe(ownerEmail); - - unmount(); - confirmModalSpy.mockRestore(); - }); }); describe('RuleBot restrictions', () => { diff --git a/tests/ui/WorkspaceWorkflowsApprovalsApproverPageTest.tsx b/tests/ui/WorkspaceWorkflowsApprovalsApproverPageTest.tsx deleted file mode 100644 index 4f4cf3419630..000000000000 --- a/tests/ui/WorkspaceWorkflowsApprovalsApproverPageTest.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import {act, render} from '@testing-library/react-native'; - -import type {SelectionListApprover} from '@components/ApproverSelectionList'; -import ApproverSelectionList from '@components/ApproverSelectionList'; -import ComposeProviders from '@components/ComposeProviders'; -import {LocaleContextProvider} from '@components/LocaleContextProvider'; -import OnyxListItemProvider from '@components/OnyxListItemProvider'; - -import Navigation from '@libs/Navigation/Navigation'; -import {convertPolicyEmployeesToApprovalWorkflows} from '@libs/WorkflowUtils'; - -import WorkspaceWorkflowsApprovalsApproverPage from '@pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage'; - -import {clearApprovalWorkflowApprover, selectApprovalWorkflowForEdit} from '@userActions/Workflow'; - -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 {PersonalDetailsList} from '@src/types/onyx/PersonalDetails'; -import type {PolicyEmployeeList} from '@src/types/onyx/PolicyEmployee'; - -import type {PropsWithChildren} from 'react'; - -import React from 'react'; -import Onyx from 'react-native-onyx'; - -import createMock from '../utils/createMock'; -import getOnyxValue from '../utils/getOnyxValue'; -import {buildPersonalDetails, localeCompare} from '../utils/TestHelper'; -import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; - -const POLICY_ID = 'workflow-approver-test-policy'; -const ADMIN_EMAIL = 'admin@example.com'; -const ADMIN_ACCOUNT_ID = 1; -const BOB_EMAIL = 'bob@example.com'; -const BOB_ACCOUNT_ID = 2; -const ERIN_EMAIL = 'erin@example.com'; -const ERIN_ACCOUNT_ID = 3; -const DAVE_EMAIL = 'dave@example.com'; -const DAVE_ACCOUNT_ID = 4; -const REMOVED_EMAIL = 'removed@example.com'; -const REMOVED_ACCOUNT_ID = 5; - -// The tests read the rows the page hands the list and tap them through its callback, so the list itself renders nothing. -jest.mock('@components/ApproverSelectionList', () => jest.fn(() => null)); -jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => jest.fn(({children}: PropsWithChildren) => children)); - -jest.mock('@react-navigation/native', () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const actualNav = jest.requireActual('@react-navigation/native'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return { - ...actualNav, - useNavigationState: () => [], - }; -}); - -jest.mock('@libs/Navigation/Navigation', () => ({ - goBack: jest.fn(), - navigate: jest.fn(), -})); - -/** Bob approves Dave's reports and forwards to Erin, who still forwards to someone removed from the workspace. */ -function buildEmployeeList(): PolicyEmployeeList { - return { - [ADMIN_EMAIL]: {email: ADMIN_EMAIL, role: CONST.POLICY.ROLE.ADMIN, submitsTo: ADMIN_EMAIL}, - [BOB_EMAIL]: {email: BOB_EMAIL, role: CONST.POLICY.ROLE.USER, submitsTo: ADMIN_EMAIL, forwardsTo: ERIN_EMAIL}, - [ERIN_EMAIL]: {email: ERIN_EMAIL, role: CONST.POLICY.ROLE.USER, submitsTo: ADMIN_EMAIL, forwardsTo: REMOVED_EMAIL}, - [DAVE_EMAIL]: {email: DAVE_EMAIL, role: CONST.POLICY.ROLE.USER, submitsTo: BOB_EMAIL}, - }; -} - -function buildPolicy(employeeList: PolicyEmployeeList): Policy { - return createMock({ - id: POLICY_ID, - name: 'Test Workspace', - type: CONST.POLICY.TYPE.CORPORATE, - role: CONST.POLICY.ROLE.ADMIN, - owner: ADMIN_EMAIL, - approver: ADMIN_EMAIL, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - preventSelfApproval: true, - areWorkflowsEnabled: true, - employeeList, - }); -} - -function buildPersonalDetailsList(): PersonalDetailsList { - return { - [ADMIN_ACCOUNT_ID]: buildPersonalDetails(ADMIN_EMAIL, ADMIN_ACCOUNT_ID, 'admin'), - [BOB_ACCOUNT_ID]: buildPersonalDetails(BOB_EMAIL, BOB_ACCOUNT_ID, 'bob'), - [ERIN_ACCOUNT_ID]: buildPersonalDetails(ERIN_EMAIL, ERIN_ACCOUNT_ID, 'erin'), - [DAVE_ACCOUNT_ID]: buildPersonalDetails(DAVE_EMAIL, DAVE_ACCOUNT_ID, 'dave'), - [REMOVED_ACCOUNT_ID]: buildPersonalDetails(REMOVED_EMAIL, REMOVED_ACCOUNT_ID, 'removed'), - }; -} - -/** Opens Bob > Erin > removed approver for editing, the way the Workflows page builds and selects it. */ -async function editWorkflowWithRemovedApprover() { - const {approvalWorkflows} = convertPolicyEmployeesToApprovalWorkflows({ - policy: buildPolicy(buildEmployeeList()), - personalDetails: buildPersonalDetailsList(), - localeCompare, - currentUserLogin: ADMIN_EMAIL, - }); - const workflow = approvalWorkflows.find((approvalWorkflow) => approvalWorkflow.approvers.at(0)?.email === BOB_EMAIL); - if (!workflow) { - throw new Error("Bob's workflow was not built"); - } - await act(async () => { - selectApprovalWorkflowForEdit({workflow, defaultWorkflowMembers: [], usedApproverEmails: []}); - }); - await waitForBatchedUpdatesWithAct(); -} - -async function renderPage(approverIndex: number) { - const result = render( - - - , - ); - await waitForBatchedUpdatesWithAct(); - return result; -} - -function getRows(): SelectionListApprover[] { - return jest.mocked(ApproverSelectionList).mock.lastCall?.[0].allApprovers ?? []; -} - -/** Taps a row the way ApproverSelectionList does with single selection: a selected row clears the selection. */ -async function tapRow(email: string) { - const listProps = jest.mocked(ApproverSelectionList).mock.lastCall?.[0]; - const row = listProps?.allApprovers.find((approver) => approver.login === email); - if (!listProps || !row) { - throw new Error(`${email} is not in the approver list`); - } - await act(async () => { - listProps.onSelectApprover?.(row.isSelected ? [] : [{...row, isSelected: true}]); - }); - await waitForBatchedUpdatesWithAct(); -} - -async function getApproverEmails() { - const approvalWorkflow = await getOnyxValue(ONYXKEYS.APPROVAL_WORKFLOW); - return approvalWorkflow?.approvers.map((approver) => approver?.email); -} - -describe('WorkspaceWorkflowsApprovalsApproverPage', () => { - beforeAll(() => { - Onyx.init({keys: ONYXKEYS}); - }); - - beforeEach(async () => { - jest.mocked(ApproverSelectionList).mockClear(); - jest.mocked(Navigation.goBack).mockClear(); - await act(async () => { - await Onyx.clear(); - await Onyx.set(ONYXKEYS.HAS_LOADED_APP, true); - await Onyx.set(ONYXKEYS.IS_LOADING_REPORT_DATA, false); - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, buildPolicy(buildEmployeeList())); - await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, buildPersonalDetailsList()); - await Onyx.merge(ONYXKEYS.SESSION, {email: ADMIN_EMAIL, accountID: ADMIN_ACCOUNT_ID}); - }); - await waitForBatchedUpdatesWithAct(); - await editWorkflowWithRemovedApprover(); - }); - - it('lets the admin drop just the approver who left so the previous approver becomes final', async () => { - // Given the edited workflow Bob > Erin > an approver who left the workspace - expect(await getApproverEmails()).toEqual([BOB_EMAIL, ERIN_EMAIL, REMOVED_EMAIL]); - - // When the admin opens the approver who left - await renderPage(2); - - // Then they are listed once, selected, even though they are no longer on employeeList - const removedRows = getRows().filter((row) => row.login === REMOVED_EMAIL); - expect(removedRows).toHaveLength(1); - expect(removedRows.at(0)?.isSelected).toBe(true); - - // When the admin taps that selected row - await tapRow(REMOVED_EMAIL); - - // Then only that approver is dropped, so Erin is the final approver, and the admin is taken back to the workflow - expect(await getApproverEmails()).toEqual([BOB_EMAIL, ERIN_EMAIL]); - expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(POLICY_ID, BOB_EMAIL, undefined), {compareParams: false}); - }); - - it('never offers the approver who left as a new pick', async () => { - // Given the edited workflow Bob > Erin > an approver who left the workspace - - // When the admin opens Erin's slot - const {unmount} = await renderPage(1); - - // Then the approver who left can't be picked to replace Erin - expect(getRows().some((row) => row.login === REMOVED_EMAIL)).toBe(false); - unmount(); - - // When the approver who left is removed from their slot and the admin opens that now empty slot - await act(async () => { - clearApprovalWorkflowApprover({approverIndex: 2, currentApprovalWorkflow: await getOnyxValue(ONYXKEYS.APPROVAL_WORKFLOW)}); - }); - await waitForBatchedUpdatesWithAct(); - await renderPage(2); - - // Then they can't be picked again - expect(getRows().some((row) => row.login === REMOVED_EMAIL)).toBe(false); - }); - - it('lists a flagged approver only once when they are back on the workspace', async () => { - // Given the approver who left was invited back while the edited workflow still has them flagged - await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, { - employeeList: {[REMOVED_EMAIL]: {email: REMOVED_EMAIL, role: CONST.POLICY.ROLE.USER, submitsTo: ADMIN_EMAIL}}, - }); - }); - await waitForBatchedUpdatesWithAct(); - - // When the admin opens their slot - await renderPage(2); - - // Then they appear as a single selected row rather than once from employeeList and once as the flagged approver - const removedRows = getRows().filter((row) => row.login === REMOVED_EMAIL); - expect(removedRows).toHaveLength(1); - expect(removedRows.at(0)?.isSelected).toBe(true); - }); -}); diff --git a/tests/ui/WorkspaceWorkflowsApprovalsEditPageTest.tsx b/tests/ui/WorkspaceWorkflowsApprovalsEditPageTest.tsx index 553487ac45a4..38e46d731a04 100644 --- a/tests/ui/WorkspaceWorkflowsApprovalsEditPageTest.tsx +++ b/tests/ui/WorkspaceWorkflowsApprovalsEditPageTest.tsx @@ -1,7 +1,6 @@ import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; import ComposeProviders from '@components/ComposeProviders'; -import HTMLEngineProvider from '@components/HTMLEngineProvider'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; @@ -97,7 +96,7 @@ const renderEditPage = (route = mockRoute) => {() => ( - + { expect(approvalWorkflows.at(0)?.approvers.map((approver) => approver.email)).toEqual([bobEmail]); }); - describe('approver no longer on the workspace', () => { - it('flags the approver as soon as the editor opens and blocks saving', async () => { - // Given Bob still submits to someone who was removed from the workspace - const bobEmail = 'bob@example.com'; - const removedEmail = 'removed@example.com'; - await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {employeeList: {[bobEmail]: {email: bobEmail, submitsTo: removedEmail}}}); - await waitForBatchedUpdatesWithAct(); - }); - - // When the admin opens that workflow in the editor - renderEditPage({...mockRoute, params: {policyID: POLICY_ID, firstApproverEmail: removedEmail}}); - await waitForBatchedUpdatesWithAct(); - - // Then the approver shows the error right away, before any Save attempt - expect(await screen.findByText(translateLocal('workflowsPage.approverNotWorkspaceMember'))).toBeOnTheScreen(); - - // And saving is blocked until the admin picks a new approver or deletes the workflow - jest.mocked(Navigation.dismissModal).mockClear(); - fireEvent.press(screen.getByText(translateLocal('common.save'))); - await waitForBatchedUpdatesWithAct(); - expect(Navigation.dismissModal).not.toHaveBeenCalled(); - }); - }); - describe('shared approver hint', () => { const aliceApprover: Approver = { email: ALICE_EMAIL, diff --git a/tests/unit/ApprovalWorkflowSectionTest.tsx b/tests/unit/ApprovalWorkflowSectionTest.tsx index 451eb050258a..d28f7fe907a7 100644 --- a/tests/unit/ApprovalWorkflowSectionTest.tsx +++ b/tests/unit/ApprovalWorkflowSectionTest.tsx @@ -78,62 +78,4 @@ describe('ApprovalWorkflowSection', () => { expect(screen.queryByLabelText('Edit')).toBeNull(); }); - - it('shows an error under an approver who is no longer a workspace member', () => { - // Given a workflow whose approver was removed from the workspace without reassigning its members - const brokenWorkflow: ApprovalWorkflow = {...baseWorkflow, approvers: [{email: 'removed@example.com', displayName: 'removed@example.com', isNotWorkspaceMember: true}]}; - - // When the workflow card renders - renderSection({approvalWorkflow: brokenWorkflow, onPress: jest.fn()}); - - // Then the admin sees an error telling them to fix it - expect(screen.getByText('workflowsPage.approverNotWorkspaceMember')).toBeTruthy(); - }); - - it('does not show the non-member error for a regular approver', () => { - // Given a workflow whose approver is a workspace member - // When the workflow card renders - renderSection({approvalWorkflow: baseWorkflow, onPress: jest.fn()}); - - // Then no error is shown - expect(screen.queryByText('workflowsPage.approverNotWorkspaceMember')).toBeNull(); - }); - - it('reads the non-member error out through the Edit button, since the approver rows are hidden from screen readers', () => { - // Given a workflow whose approver was removed from the workspace - const brokenWorkflow: ApprovalWorkflow = {...baseWorkflow, approvers: [{email: 'removed@example.com', displayName: 'removed@example.com', isNotWorkspaceMember: true}]}; - - // When the workflow card renders - renderSection({approvalWorkflow: brokenWorkflow, onPress: jest.fn()}); - - // Then the Edit button's label carries the error, so screen reader users hear what needs fixing - expect(screen.getByLabelText('Edit, workflowsPage.approverNotWorkspaceMember')).toBeTruthy(); - }); - - it('does not offer deleting the default workflow when its approver is no longer a workspace member', () => { - // Given the default workflow, whose approver was removed from the workspace - const brokenDefaultWorkflow: ApprovalWorkflow = { - ...baseWorkflow, - isDefault: true, - approvers: [{email: 'removed@example.com', displayName: 'removed@example.com', isNotWorkspaceMember: true}], - }; - - // When the workflow card renders - renderSection({approvalWorkflow: brokenDefaultWorkflow, onPress: jest.fn()}); - - // Then the error only asks for a new approver, since the default workflow can't be deleted - expect(screen.getByText('workflowsPage.defaultWorkflowApproverNotWorkspaceMember')).toBeTruthy(); - expect(screen.queryByText('workflowsPage.approverNotWorkspaceMember')).toBeNull(); - }); - - it('does not show the non-member error to someone who cannot edit the workflow', () => { - // Given a workflow whose approver was removed from the workspace, shown read-only - const brokenWorkflow: ApprovalWorkflow = {...baseWorkflow, approvers: [{email: 'removed@example.com', displayName: 'removed@example.com', isNotWorkspaceMember: true}]}; - - // When the workflow card renders disabled - renderSection({approvalWorkflow: brokenWorkflow, onPress: jest.fn(), isDisabled: true}); - - // Then no error is shown, since there is no Edit button to act on it - expect(screen.queryByText('workflowsPage.approverNotWorkspaceMember')).toBeNull(); - }); }); diff --git a/tests/unit/CardUtilsTest.ts b/tests/unit/CardUtilsTest.ts index ec0f91c5d563..78c5d3cf0466 100644 --- a/tests/unit/CardUtilsTest.ts +++ b/tests/unit/CardUtilsTest.ts @@ -613,17 +613,6 @@ describe('CardUtils', () => { expect(isCustomFeed).toBe(true); }); - it('Should return true for the mock commercial feed', () => { - // Given a mock commercial feed. - const customFeed = CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK; - - // When checking whether it is a custom feed. - const isCustomFeed = isCustomFeedCardUtils(customFeed); - - // Then it is treated as a custom feed. - expect(isCustomFeed).toBe(true); - }); - test.each(directFeedBanks)('Should return false for the direct feed %s', (directFeed) => { const isCustomFeed = isCustomFeedCardUtils(directFeed); expect(isCustomFeed).toBe(false); @@ -2074,19 +2063,6 @@ describe('CardUtils', () => { const feedType = getFeedType('vcf', companyCardsCustomVisaFeedSettingsWithNumbers); expect(feedType).toBe('vcf2'); }); - - it('should number mock commercial feeds', () => { - // Given an existing numbered mock commercial feed. - const cardFeeds = createMock({ - [`${CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK}1`]: {}, - }); - - // When finding the next feed type. - const feedType = getFeedType(CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK, cardFeeds); - - // Then the next number is returned. - expect(feedType).toBe(`${CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK}2`); - }); }); describe('getCSVFeedType', () => { diff --git a/tests/unit/ClearReportActionErrorsTest.ts b/tests/unit/ClearReportActionErrorsTest.ts index b56a5ec8e2c0..47e4eb45864b 100644 --- a/tests/unit/ClearReportActionErrorsTest.ts +++ b/tests/unit/ClearReportActionErrorsTest.ts @@ -1,6 +1,5 @@ import {clearAllRelatedReportActionErrors} from '@libs/actions/ClearReportActionErrors'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportActions} from '@src/types/onyx'; @@ -172,42 +171,6 @@ describe('ClearReportActionErrors', () => { expect(parentReportActions?.[PARENT_REPORT_ACTION_ID]?.errors).toEqual({}); }); - it('should clear the mirrored pay error on the chat preview without deleting the preview itself', async () => { - // Given a failed payment: the optimistic pay action inside the IOU report and the chat's report preview - // both carry the same error key, which is what links them. - const iouReport = createMockReport({ - parentReportID: PARENT_REPORT_ID, - parentReportActionID: PARENT_REPORT_ACTION_ID, - }); - const payAction = getFakeReportAction(Number(REPORT_ACTION_ID), { - // A failed optimistic pay action keeps its ADD pending state, so dismissing it removes the action. - pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, - errors: {[CONST.IOU.PAY_FAILURE_PREVIEW_ERROR_KEY]: 'Unexpected error. Please try again later.'}, - }); - const reportPreviewAction = getFakeReportAction(Number(PARENT_REPORT_ACTION_ID), { - actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, - errors: {[CONST.IOU.PAY_FAILURE_PREVIEW_ERROR_KEY]: 'Unexpected error. Please try again later.'}, - }); - - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, iouReport); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {[REPORT_ACTION_ID]: payAction}); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${PARENT_REPORT_ID}`, {[PARENT_REPORT_ACTION_ID]: reportPreviewAction}); - await waitForBatchedUpdates(); - - // When the payer dismisses the error from inside the expense report - clearAllRelatedReportActionErrors(REPORT_ID, payAction, REPORT_ID, false); - await waitForBatchedUpdates(); - - // Then the copy on the chat preview is cleared too, so a dismissed error cannot resurface later when the - // expense is deleted and that preview becomes visible again. - const parentReportActions = await getReportActionsFromOnyx(PARENT_REPORT_ID); - expect(parentReportActions?.[PARENT_REPORT_ACTION_ID]?.errors).toEqual({}); - - // And the preview survives. It is a real server action, so it must not take the optimistic-delete path. - expect(parentReportActions?.[PARENT_REPORT_ACTION_ID]).toBeTruthy(); - expect(parentReportActions?.[PARENT_REPORT_ACTION_ID]?.actionName).toBe(CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW); - }); - it('should not clear parent errors when ignore is set to parent', async () => { // Given a child report with parent reference and both have errors with matching keys const report = createMockReport({ diff --git a/tests/unit/ContextMenuActionsDeleteTest.ts b/tests/unit/ContextMenuActionsDeleteTest.ts deleted file mode 100644 index b2e060fa2a63..000000000000 --- a/tests/unit/ContextMenuActionsDeleteTest.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type * as ContextMenuActionsModule from '@pages/inbox/report/ContextMenu/ContextMenuActions'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, ReportAction} from '@src/types/onyx'; - -import Onyx from 'react-native-onyx'; - -import createRandomReportAction from '../utils/collections/reportActions'; -import createRandomTransaction from '../utils/collections/transaction'; -import createMock from '../utils/createMock'; - -jest.mock( - 'expo-web-browser', - () => ({ - openAuthSessionAsync: jest.fn(), - }), - {virtual: true}, -); - -jest.mock('@components/Reactions/MiniQuickEmojiReactions', () => 'MiniQuickEmojiReactions'); -jest.mock('@components/Reactions/QuickEmojiReactions', () => 'QuickEmojiReactions'); - -const mockShowDeleteModal = jest.fn(); - -jest.mock('@pages/inbox/report/ContextMenu/ReportActionContextMenu', () => ({ - hideContextMenu: jest.fn(), - showDeleteModal: (...args: unknown[]) => { - mockShowDeleteModal(...args); - }, -})); - -const {default: ContextMenuActions} = jest.requireActual('@pages/inbox/report/ContextMenu/ContextMenuActions'); - -const deleteAction = ContextMenuActions.find((action) => 'sentryLabel' in action && action.sentryLabel === CONST.SENTRY_LABEL.CONTEXT_MENU.DELETE); -if (!deleteAction || !('onPress' in deleteAction)) { - throw new Error('Delete context menu action was not found'); -} - -type DeleteActionVisibilityPayload = Parameters[0]; - -describe('ContextMenuActions delete action', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('hides Delete from a member who owns an Expensify Card expense', async () => { - // Given a member viewing the report preview for their own Expensify Card expense - const currentUserAccountID = 1; - const parentReportID = '20'; - const expenseReportID = '21'; - const transactionID = '22'; - const expenseReport: Report = { - reportID: expenseReportID, - type: CONST.REPORT.TYPE.EXPENSE, - ownerAccountID: currentUserAccountID, - stateNum: CONST.REPORT.STATE_NUM.OPEN, - statusNum: CONST.REPORT.STATUS_NUM.OPEN, - }; - const reportPreviewAction: ReportAction = { - ...createRandomReportAction(20), - actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, - childReportID: expenseReportID, - }; - const moneyRequestAction: ReportAction = { - ...createRandomReportAction(21), - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - actorAccountID: currentUserAccountID, - reportID: expenseReportID, - originalMessage: { - IOUTransactionID: transactionID, - amount: 100, - currency: CONST.CURRENCY.USD, - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - }, - }; - const iouTransaction = { - ...createRandomTransaction(Number(transactionID)), - transactionID, - reportID: expenseReportID, - managedCard: false, - bank: CONST.EXPENSIFY_CARD.BANK, - comment: {liabilityType: CONST.TRANSACTION.LIABILITY_TYPE.RESTRICT}, - }; - const payload = createMock({ - type: CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, - reportID: parentReportID, - reportAction: reportPreviewAction, - moneyRequestAction, - currentUserAccountID, - iouTransaction, - transactions: {[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: iouTransaction}, - childReportActions: {[moneyRequestAction.reportActionID]: moneyRequestAction}, - isArchivedRoom: false, - isChronosReport: false, - }); - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, expenseReport); - - // When the context menu determines which actions to show - const shouldShowDelete = deleteAction.shouldShow(payload); - - // Then Delete is hidden because Auth does not allow a member to delete an Expensify Card expense or its report - expect(shouldShowDelete).toBe(false); - }); -}); diff --git a/tests/unit/HrUtilsTest.ts b/tests/unit/HrUtilsTest.ts index bdb56616bc40..8756c9ef9dba 100644 --- a/tests/unit/HrUtilsTest.ts +++ b/tests/unit/HrUtilsTest.ts @@ -1,16 +1,6 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; -import { - getConnectedHRProvider, - getHRApprovalMode, - getNonRenderableMergeHRGroupIDs, - getValidMergeHRGroupIDs, - hasStaleMergeHRGroups, - isAnyHRConnected, - isAnyHRReadOnlyWorkflowMode, - isMergeHRCompleteSetupNeeded, - shouldShowHRConnectionError, -} from '@libs/merge/HRUtils'; +import {getConnectedHRProvider, getHRApprovalMode, isAnyHRConnected, isAnyHRReadOnlyWorkflowMode, isMergeHRCompleteSetupNeeded, shouldShowHRConnectionError} from '@libs/merge/HRUtils'; import {getApprovalModeLabel, getHRCards, getHRCardState} from '@pages/workspace/hr/utils'; import type {MergeProviderCardDescriptor} from '@pages/workspace/merge/types'; @@ -331,197 +321,6 @@ describe('HRUtils', () => { }); }); - describe('hasStaleMergeHRGroups', () => { - it('returns false when not connected', () => { - expect(hasStaleMergeHRGroups(makePolicy())).toBe(false); - }); - - it('returns false when the admin has not chosen groups yet', () => { - const policy = makePolicy({ - connections: { - [MERGE_HR]: makeMergeHRConnection({data: {groups: [{id: 'g1', name: 'Eng', type: 'Department'}]}}), - }, - }); - expect(hasStaleMergeHRGroups(policy)).toBe(false); - }); - - it('returns false when the cache has never synced', () => { - const policy = makePolicy({ - connections: { - [MERGE_HR]: makeMergeHRConnection({config: {groups: ['g1']}, data: {}}), - }, - }); - expect(hasStaleMergeHRGroups(policy)).toBe(false); - }); - - it('returns true when a synced cache legitimately has zero groups', () => { - const policy = makePolicy({ - connections: { - [MERGE_HR]: makeMergeHRConnection({config: {groups: ['g1']}, data: {allGroupIDs: []}}), - }, - }); - expect(hasStaleMergeHRGroups(policy)).toBe(true); - }); - - it('returns false when every selected group still exists', () => { - const policy = makePolicy({ - connections: { - [MERGE_HR]: makeMergeHRConnection({ - config: {groups: ['g1', 'g2']}, - data: {allGroupIDs: ['g1', 'g2']}, - }), - }, - }); - expect(hasStaleMergeHRGroups(policy)).toBe(false); - }); - - it('returns true when a selected group is missing from the cached list', () => { - const policy = makePolicy({ - connections: { - [MERGE_HR]: makeMergeHRConnection({ - config: {groups: ['g1', 'g-deleted']}, - data: {allGroupIDs: ['g1']}, - }), - }, - }); - expect(hasStaleMergeHRGroups(policy)).toBe(true); - }); - - it('returns false when a selected group still exists but lost its display name/type', () => { - const policy = makePolicy({ - connections: { - [MERGE_HR]: makeMergeHRConnection({ - config: {groups: ['g1']}, - data: {groups: [], allGroupIDs: ['g1']}, - }), - }, - }); - expect(hasStaleMergeHRGroups(policy)).toBe(false); - }); - }); - - describe('getValidMergeHRGroupIDs', () => { - it('returns an empty list when the admin has not chosen groups yet', () => { - expect(getValidMergeHRGroupIDs(makePolicy())).toEqual([]); - }); - - it('keeps the selection intact when every selected group is still in the cached list', () => { - const policy = makePolicy({ - connections: { - [MERGE_HR]: makeMergeHRConnection({ - config: {groups: ['g1', 'g2']}, - data: { - groups: [ - {id: 'g1', name: 'Eng', type: 'Department'}, - {id: 'g2', name: 'Sales', type: 'Department'}, - ], - }, - }), - }, - }); - expect(getValidMergeHRGroupIDs(policy)).toEqual(['g1', 'g2']); - }); - - it('drops a selected group the cached list no longer has', () => { - const policy = makePolicy({ - connections: { - [MERGE_HR]: makeMergeHRConnection({ - config: {groups: ['g1', 'g-deleted']}, - data: {groups: [{id: 'g1', name: 'Eng', type: 'Department'}], allGroupIDs: ['g1']}, - }), - }, - }); - expect(getValidMergeHRGroupIDs(policy)).toEqual(['g1']); - }); - - it('keeps a selected group that still exists upstream even when it has no renderable row', () => { - const policy = makePolicy({ - connections: { - [MERGE_HR]: makeMergeHRConnection({ - config: {groups: ['g1', 'g2']}, - data: {groups: [{id: 'g1', name: 'Eng', type: 'Department'}], allGroupIDs: ['g1', 'g2']}, - }), - }, - }); - expect(getValidMergeHRGroupIDs(policy)).toEqual(['g1', 'g2']); - }); - - it('leaves the selection alone when the cache has never synced', () => { - const policy = makePolicy({ - connections: {[MERGE_HR]: makeMergeHRConnection({config: {groups: ['g1', 'g2']}, data: {}})}, - }); - expect(getValidMergeHRGroupIDs(policy)).toEqual(['g1', 'g2']); - }); - - it('drops the whole selection when a synced cache legitimately has zero groups and allGroupIDs has not synced', () => { - const policy = makePolicy({ - connections: {[MERGE_HR]: makeMergeHRConnection({config: {groups: ['g1', 'g2']}, data: {groups: []}})}, - }); - expect(getValidMergeHRGroupIDs(policy)).toEqual([]); - }); - }); - - describe('getNonRenderableMergeHRGroupIDs', () => { - it('returns an empty list when the admin has not chosen groups yet', () => { - expect(getNonRenderableMergeHRGroupIDs(makePolicy())).toEqual([]); - }); - - it('returns an empty list when every selected group has a renderable row', () => { - const policy = makePolicy({ - connections: { - [MERGE_HR]: makeMergeHRConnection({ - config: {groups: ['g1']}, - data: {groups: [{id: 'g1', name: 'Eng', type: 'Department'}], allGroupIDs: ['g1']}, - }), - }, - }); - expect(getNonRenderableMergeHRGroupIDs(policy)).toEqual([]); - }); - - it('keeps a selected group that still exists upstream but has no renderable row', () => { - const policy = makePolicy({ - connections: { - [MERGE_HR]: makeMergeHRConnection({ - config: {groups: ['g1', 'g2']}, - data: {groups: [{id: 'g1', name: 'Eng', type: 'Department'}], allGroupIDs: ['g1', 'g2']}, - }), - }, - }); - expect(getNonRenderableMergeHRGroupIDs(policy)).toEqual(['g2']); - }); - - it('drops a selected group that no longer exists anywhere upstream', () => { - const policy = makePolicy({ - connections: { - [MERGE_HR]: makeMergeHRConnection({ - config: {groups: ['g1', 'g-deleted']}, - data: {groups: [{id: 'g1', name: 'Eng', type: 'Department'}], allGroupIDs: ['g1']}, - }), - }, - }); - expect(getNonRenderableMergeHRGroupIDs(policy)).toEqual([]); - }); - - it('falls back to data.groups for validity when allGroupIDs has not synced yet', () => { - const policy = makePolicy({ - connections: { - [MERGE_HR]: makeMergeHRConnection({ - config: {groups: ['g1', 'g-deleted']}, - data: {groups: [{id: 'g1', name: 'Eng', type: 'Department'}]}, - }), - }, - }); - expect(getNonRenderableMergeHRGroupIDs(policy)).toEqual([]); - }); - - it('leaves the selection alone when the cache has never synced', () => { - const policy = makePolicy({ - connections: {[MERGE_HR]: makeMergeHRConnection({config: {groups: ['g1', 'g2']}, data: {}})}, - }); - expect(getNonRenderableMergeHRGroupIDs(policy)).toEqual([]); - }); - }); - describe('shouldShowHRConnectionError', () => { it('returns false when user is not an admin', () => { const policy = makePolicy({ @@ -552,32 +351,6 @@ describe('HRUtils', () => { expect(shouldShowHRConnectionError(policy, false, true)).toBe(true); }); - it('returns false when the Merge HR failure came from its own settings and they are now valid', () => { - const policy = makePolicy({ - connections: { - [MERGE_HR]: makeMergeHRConnection({ - config: {integration: 'workday', groups: ['g1']}, - data: {allGroupIDs: ['g1']}, - lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.FAILED, isConfigurationError: true}, - }), - }, - }); - expect(shouldShowHRConnectionError(policy, false, true)).toBe(false); - }); - - it('returns true when the Merge HR settings failure is still unresolved', () => { - const policy = makePolicy({ - connections: { - [MERGE_HR]: makeMergeHRConnection({ - config: {integration: 'workday', groups: ['g-deleted']}, - data: {allGroupIDs: ['g1']}, - lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.FAILED, isConfigurationError: true}, - }), - }, - }); - expect(shouldShowHRConnectionError(policy, false, true)).toBe(true); - }); - it('returns false when Merge HR sync status is DONE', () => { const policy = makePolicy({ connections: {[MERGE_HR]: makeMergeHRConnection({config: {integration: 'workday'}, lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.DONE}})}, @@ -981,62 +754,6 @@ describe('getHRCards', () => { expect(bamboo?.lastSyncErrorMessage).toBe('Auth failed'); }); - it('keeps the generic sync error off the Merge card when the failure came from its own settings', () => { - const policy = makePolicy({ - connections: { - [MERGE_HR]: makeMergeHRConnection({ - config: {integration: 'bamboohr', groups: ['g1']}, - data: {groups: [{id: 'g1', name: 'Eng', type: 'Department'}], allGroupIDs: ['g1']}, - lastSync: { - syncStatus: CONST.MERGE.SYNC_STATUS.FAILED, - errorMessage: 'None of the selected groups exist in your HR system anymore.', - isConfigurationError: true, - }, - }), - }, - }); - const cards = getHRCards(makeGetHRCardsParams({policy})); - - const bamboo = cards.find((c) => c.key === 'merge_bamboohr'); - expect(bamboo?.hasError).toBe(false); - expect(bamboo?.lastSyncErrorMessage).toBeUndefined(); - expect(bamboo?.staleGroupsRoute).toBeUndefined(); - }); - - it('points the connected Merge card at the group selector when a selected group no longer exists', () => { - const policy = makePolicy({ - connections: { - [MERGE_HR]: makeMergeHRConnection({ - config: {integration: 'bamboohr', groups: ['g1', 'g-deleted']}, - data: {groups: [{id: 'g1', name: 'Eng', type: 'Department'}], allGroupIDs: ['g1']}, - lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.DONE}, - }), - }, - }); - const cards = getHRCards(makeGetHRCardsParams({policy})); - - const bamboo = cards.find((c) => c.key === 'merge_bamboohr'); - expect(bamboo?.staleGroupsRoute).toBe(ROUTES.WORKSPACE_HR_MERGE_GROUPS.getRoute(POLICY_ID)); - expect(getRow(bamboo, 'groups')?.hasInvalidValue).toBe(true); - }); - - it('leaves the connected Merge card alone when every selected group still exists', () => { - const policy = makePolicy({ - connections: { - [MERGE_HR]: makeMergeHRConnection({ - config: {integration: 'bamboohr', groups: ['g1']}, - data: {groups: [{id: 'g1', name: 'Eng', type: 'Department'}]}, - lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.DONE}, - }), - }, - }); - const cards = getHRCards(makeGetHRCardsParams({policy})); - - const bamboo = cards.find((c) => c.key === 'merge_bamboohr'); - expect(bamboo?.staleGroupsRoute).toBeUndefined(); - expect(getRow(bamboo, 'groups')?.hasInvalidValue).toBe(false); - }); - it('disconnected Merge cards do not inherit error state from the connection', () => { const policy = makePolicy({ connections: { diff --git a/tests/unit/MergeActionTest.ts b/tests/unit/MergeActionTest.ts index 8d9cb6d04f9a..56b27b7c1038 100644 --- a/tests/unit/MergeActionTest.ts +++ b/tests/unit/MergeActionTest.ts @@ -1,17 +1,14 @@ -import {setMergeInitialSyncModalShown, syncMerge, updateMergeApprovalMode} from '@libs/actions/connections/merge'; +import {setMergeInitialSyncModalShown, updateMergeApprovalMode} from '@libs/actions/connections/merge'; import {write} from '@libs/API'; import {WRITE_COMMANDS} from '@libs/API/types'; import {toIndexMap} from '@libs/RuleUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type Policy from '@src/types/onyx/Policy'; import type Rule from '@src/types/onyx/Rule'; import Onyx from 'react-native-onyx'; -import createRandomPolicy from '../utils/collections/policies'; - jest.mock('@libs/API'); const mockErrorTimestamp = 123; @@ -24,16 +21,6 @@ const mockWrite = jest.mocked(write); const policyID = 'policyID'; const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}`; const error = {[mockErrorTimestamp]: 'common.genericErrorMessage'}; -const connectionName = CONST.POLICY.CONNECTIONS.NAME.MERGE_HR; - -function makePolicy(overrides: Partial = {}): Policy { - return { - ...createRandomPolicy(1), - id: policyID, - ...overrides, - }; -} - const approvalWorkflowRuleKey = `${ONYXKEYS.COLLECTION.RULE}1`; /** A rule that forwards the reports of the given workspace, which makes it one of its approval workflow rules */ @@ -56,66 +43,6 @@ describe('MergeActions', () => { jest.clearAllMocks(); }); - describe('syncMerge', () => { - it('clears a leftover isConfigurationError so an unrelated failure is not mistaken for an already-fixed one', () => { - // Given a connection whose last sync failed because of its settings - const policy = makePolicy({ - connections: { - [connectionName]: { - config: {integration: 'workday', approvalMode: null, finalApprover: null, groups: null}, - lastSync: {isAuthenticationError: false, isSuccessful: false, source: 'NEWEXPENSIFY', isConfigurationError: true}, - }, - }, - }); - - // When the admin manually syncs again - syncMerge(policy, connectionName); - - // Then both the optimistic and failure updates clear the flag, so a later unrelated failure is reported - expect(mockWrite).toHaveBeenCalledWith( - WRITE_COMMANDS.SYNC_POLICY_TO_MERGE, - {policyID, connectionName}, - expect.objectContaining({ - optimisticData: [ - expect.objectContaining({ - onyxMethod: Onyx.METHOD.MERGE, - key: policyKey, - value: expect.objectContaining({ - connections: { - [connectionName]: expect.objectContaining({ - lastSync: expect.objectContaining({isConfigurationError: false}), - }), - }, - }), - }), - ], - failureData: [ - expect.objectContaining({ - onyxMethod: Onyx.METHOD.MERGE, - key: policyKey, - value: expect.objectContaining({ - connections: { - [connectionName]: expect.objectContaining({ - lastSync: expect.objectContaining({isConfigurationError: false}), - }), - }, - }), - }), - ], - }), - ); - }); - - it('does nothing when the policy has no id', () => { - // Given a policy that has not finished loading - // When a sync is attempted - syncMerge(undefined, connectionName); - - // Then no API call is made - expect(mockWrite).not.toHaveBeenCalled(); - }); - }); - describe('updateMergeApprovalMode', () => { it('sends only the approval mode when no approver fields are passed', () => { // Given a Merge HR connection, which saves its final approver in its own request diff --git a/tests/unit/MiddlewareTest.ts b/tests/unit/MiddlewareTest.ts index 53629bca8c04..d6a6d79da16d 100644 --- a/tests/unit/MiddlewareTest.ts +++ b/tests/unit/MiddlewareTest.ts @@ -1,4 +1,4 @@ -import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; +import {READ_COMMANDS} from '@libs/API/types'; import SaveResponseInOnyx from '@libs/Middleware/SaveResponseInOnyx'; import CONST from '@src/CONST'; @@ -104,7 +104,7 @@ describe('Middleware', () => { jest.spyOn(HttpUtils, 'xhr').mockResolvedValueOnce({jsonCode: 200, lastUpdateID: 501, previousUpdateID: 500}); await Request.processWithMiddleware({ - command: SIDE_EFFECT_REQUEST_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN, + command: READ_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN, data: {apiRequestType: CONST.API_REQUEST_TYPE.READ}, finallyData: [ { diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index eddd6f57f732..f622b21f261e 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -2051,49 +2051,6 @@ describe('OptionsListUtils', () => { expect(hasMore).toBe(false); }); - - it('should return the self DM when searching "me"', () => { - // Given options with a self DM, whose title and login don't contain "me" - const optionsWithSelfDM = createFilteredOptionList( - PERSONAL_DETAILS, - REPORTS_WITH_SELF_DM, - undefined, - EMPTY_PRIVATE_IS_ARCHIVED_MAP, - undefined, - { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - convertToDisplayString, - conciergeReportID: undefined, - isSearching: true, - }, - undefined, - ); - - // When we call getValidOptions with includeSelfDM and the localized word the current user uses to refer to themselves, - // without includeCurrentUser, like New chat does - const {options: results} = getValidOptions( - {reports: optionsWithSelfDM.reports, personalDetails: optionsWithSelfDM.personalDetails}, - allPolicies, - {}, - loginList, - CURRENT_USER_ACCOUNT_ID, - CURRENT_USER_EMAIL, - undefined, - { - getReportByID: getReportByIDFromOnyx, - dateFnsLocale: undefined, - convertToDisplayString, - includeSelfDM: true, - searchString: 'me', - }, - translateLocal, - undefined, - ); - - // Then the self DM should be returned, because it is the only row that represents the current user - expect(results.recentReports.some((option) => option.isSelfDM)).toBe(true); - }); }); describe('getValidOptions() with lazy contact options', () => { @@ -5802,64 +5759,6 @@ describe('OptionsListUtils', () => { expect(filteredOptions.recentReports.at(0)?.isSelfDM).toBe(true); }); - it('should return the self DM when searching "me"', () => { - // Given a set of options with a self DM, whose title and login don't contain "me" - const optionsWithSelfDM = createFilteredOptionList( - PERSONAL_DETAILS, - REPORTS_WITH_SELF_DM, - undefined, - EMPTY_PRIVATE_IS_ARCHIVED_MAP, - undefined, - { - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - dateFnsLocale: undefined, - convertToDisplayString, - conciergeReportID: undefined, - isSearching: true, - }, - undefined, - ); - const {options} = getSearchOptions({ - getReportByID: getReportByIDFromOnyx, - rules: undefined, - dateFnsLocale: undefined, - convertToDisplayString, - translate: translateLocal, - options: optionsWithSelfDM, - draftComments: {}, - loginList, - isDefaultRoomsBetaEnabled: true, - policyCollection: allPolicies, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - currentUserEmail: CURRENT_USER_EMAIL, - personalDetails: PERSONAL_DETAILS, - sortedActions: undefined, - conciergeReportID: undefined, - }); - - // When we call filterAndOrderOptions with the localized word the current user uses to refer to themselves - const filteredOptions = filterAndOrderOptions({ - options, - searchInputValue: 'me', - countryCode: COUNTRY_CODE, - loginList, - currentUserEmail: CURRENT_USER_EMAIL, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - personalDetails: PERSONAL_DETAILS, - config: { - dateFnsLocale: undefined, - currentUserAccountID: CURRENT_USER_ACCOUNT_ID, - convertToDisplayString, - }, - translate: translateLocal, - rules: undefined, - activePolicyID, - }); - - // Then the self DM should be returned so the current user can find their own chat - expect(filteredOptions.recentReports.some((option) => option.isSelfDM)).toBe(true); - }); - it('should return the same matches for normalized multi-word queries with extra spaces', () => { const {options} = getSearchOptions({ getReportByID: getReportByIDFromOnyx, @@ -7086,7 +6985,7 @@ describe('OptionsListUtils', () => { it('should return the report when there are no search terms', () => { // Given a report object // When we call filterSelfDMChat with the report and no search terms - const result = filterSelfDMChat(REPORT, [], translateLocal); + const result = filterSelfDMChat(REPORT, []); // Then the returned value should be the same as the input expect(result?.reportID).toEqual(REPORT.reportID); @@ -7095,7 +6994,7 @@ describe('OptionsListUtils', () => { it('should return undefined, when the search term does not match the report', () => { // Given a report object // When we call filterSelfDMChat with the report and a search term that does not match the report - const result = filterSelfDMChat(REPORT, ['XYZ'], translateLocal); + const result = filterSelfDMChat(REPORT, ['XYZ']); // Then the returned value should be undefined expect(result).toBeUndefined(); @@ -7104,7 +7003,7 @@ describe('OptionsListUtils', () => { it('should filter report by text', () => { // Given a report object // When we call filterSelfDMChat with the report and search term that matches the report - const result = filterSelfDMChat(REPORT, ['Google'], translateLocal); + const result = filterSelfDMChat(REPORT, ['Google']); // Then the returned value should be the same as the input expect(result?.reportID).toEqual(REPORT.reportID); @@ -7113,7 +7012,7 @@ describe('OptionsListUtils', () => { it('should filter report by exact text', () => { // Given a report object // When we call filterSelfDMChat with the report and multiple search terms that match the report's exact name - const result = filterSelfDMChat(REPORT, ['Google', 'Workspace'], translateLocal); + const result = filterSelfDMChat(REPORT, ['Google', 'Workspace']); // Then the returned value should be the same as the input expect(result?.reportID).toEqual(REPORT.reportID); @@ -7122,7 +7021,7 @@ describe('OptionsListUtils', () => { it('should filter report by login', () => { // Given a report object // When we call filterSelfDMChat with the report and a search term that matches the report's login - const result = filterSelfDMChat({...REPORT, login: LOGIN}, ['john'], translateLocal); + const result = filterSelfDMChat({...REPORT, login: LOGIN}, ['john']); // Then the returned value should be the same as the input expect(result?.reportID).toEqual(REPORT.reportID); @@ -7131,7 +7030,7 @@ describe('OptionsListUtils', () => { it('should filter report by exact login', () => { // Given a report object // When we call filterSelfDMChat with the report and multiple search terms that match the report's exact login - const result = filterSelfDMChat({...REPORT, login: LOGIN}, [LOGIN], translateLocal); + const result = filterSelfDMChat({...REPORT, login: LOGIN}, [LOGIN]); // Then the returned value should be the same as the input expect(result?.reportID).toEqual(REPORT.reportID); @@ -7140,7 +7039,7 @@ describe('OptionsListUtils', () => { it('should filter report by alternate text', () => { // Given a report object // When we call filterSelfDMChat with the report and a search term that matches the report's alternate text - const result = filterSelfDMChat({...REPORT, alternateText: ALTERNATE_TEXT, isThread: true}, ['William'], translateLocal); + const result = filterSelfDMChat({...REPORT, alternateText: ALTERNATE_TEXT, isThread: true}, ['William']); // Then the returned value should be the same as the input expect(result?.reportID).toEqual(REPORT.reportID); @@ -7149,7 +7048,7 @@ describe('OptionsListUtils', () => { it('should filter report by exact alternate text', () => { // Given a report object that is a thread // When we call filterSelfDMChat with the report and multiple search terms that match the report's exact alternate text - const result = filterSelfDMChat({...REPORT, alternateText: ALTERNATE_TEXT, isThread: true}, ['John', 'William', 'Doe'], translateLocal); + const result = filterSelfDMChat({...REPORT, alternateText: ALTERNATE_TEXT, isThread: true}, ['John', 'William', 'Doe']); // Then the returned value should be the same as the input expect(result?.reportID).toEqual(REPORT.reportID); @@ -7158,7 +7057,7 @@ describe('OptionsListUtils', () => { it('should filter report by alternate text if it is not a thread', () => { // Given a report object that is not a thread // When we call filterSelfDMChat with the report and a search term that matches the report's alternate text - const result = filterSelfDMChat({...REPORT, alternateText: ALTERNATE_TEXT, isThread: false}, ['William'], translateLocal); + const result = filterSelfDMChat({...REPORT, alternateText: ALTERNATE_TEXT, isThread: false}, ['William']); // Then the returned value should be undefined expect(result?.reportID).toBeUndefined(); @@ -7167,7 +7066,7 @@ describe('OptionsListUtils', () => { it('should filter report by subtitle', () => { // Given a report object // When we call filterSelfDMChat with the report and a search term that matches the report's subtitle - const result = filterSelfDMChat({...REPORT, subtitle: SUBTITLE}, ['Software'], translateLocal); + const result = filterSelfDMChat({...REPORT, subtitle: SUBTITLE}, ['Software']); // Then the returned value should be the same as the input expect(result?.reportID).toEqual(REPORT.reportID); @@ -7176,7 +7075,7 @@ describe('OptionsListUtils', () => { it('should filter report by exact subtitle', () => { // Given a report object // When we call filterSelfDMChat with the report and multiple search terms that match the report's exact subtitle - const result = filterSelfDMChat({...REPORT, subtitle: SUBTITLE}, ['Software', 'Engineer'], translateLocal); + const result = filterSelfDMChat({...REPORT, subtitle: SUBTITLE}, ['Software', 'Engineer']); // Then the returned value should be the same as the input expect(result?.reportID).toEqual(REPORT.reportID); @@ -7193,7 +7092,6 @@ describe('OptionsListUtils', () => { isChatRoom: false, }, ['Software'], - translateLocal, ); // Then the returned value should be undefined @@ -7211,34 +7109,11 @@ describe('OptionsListUtils', () => { isChatRoom: true, }, ['Software'], - translateLocal, ); // Then the returned value should be the same as the input expect(result?.reportID).toEqual(REPORT.reportID); }); - - it.each(['me', 'you'])('should filter the self DM by the localized "%s"', (searchTerm) => { - // Given a self DM whose title and login don't contain the search term - const selfDM = {...REPORT, text: 'Test User', login: 'test@test.com', isPolicyExpenseChat: false, isSelfDM: true}; - - // When we call filterSelfDMChat with the localized word the current user uses to refer to themselves - const result = filterSelfDMChat(selfDM, [searchTerm], translateLocal); - - // Then the self DM should be returned so the current user can find their own chat - expect(result?.reportID).toEqual(REPORT.reportID); - }); - - it('should not filter a non-self DM report by "me"', () => { - // Given a report that is not a self DM and whose title and login don't contain "me" - const report = {...REPORT, text: 'Test User', login: 'test@test.com', isPolicyExpenseChat: false}; - - // When we call filterSelfDMChat with "me" - const result = filterSelfDMChat(report, ['me'], translateLocal); - - // Then the report should not be returned, because only the self DM belongs to the current user - expect(result).toBeUndefined(); - }); }); describe('filterReports()', () => { @@ -7250,7 +7125,7 @@ describe('OptionsListUtils', () => { // cspell:disable-next-line const searchTerms = ['Alex Timon Dartagnan Zoe']; // When we call filterReports with the report and search terms - const filteredReports = filterReports(reports, searchTerms, translateLocal); + const filteredReports = filterReports(reports, searchTerms); // Then the returned value should match the search term expect(filteredReports).toEqual(reports); @@ -7277,7 +7152,7 @@ describe('OptionsListUtils', () => { const report: OptionData = {text: reportText, reportID: 'normalized', keyForList: 'normalized'}; // When the report is filtered with the normalized search value - const filteredReports = filterReports([report], [searchText], translateLocal); + const filteredReports = filterReports([report], [searchText]); // Then the report should remain in the results expect(filteredReports).toEqual([report]); @@ -7291,7 +7166,7 @@ describe('OptionsListUtils', () => { keyForList: 'email', }; - const filteredReports = filterReports([report], ['testuser@example.com'], translateLocal); + const filteredReports = filterReports([report], ['testuser@example.com']); expect(filteredReports).toEqual([report]); }); @@ -7304,7 +7179,7 @@ describe('OptionsListUtils', () => { keyForList: 'phone', }; - const filteredReports = filterReports([report], [getSearchValueForPhoneOrEmail('+1 (234) 567-8901', COUNTRY_CODE)], translateLocal); + const filteredReports = filterReports([report], [getSearchValueForPhoneOrEmail('+1 (234) 567-8901', COUNTRY_CODE)]); expect(filteredReports).toEqual([report]); }); @@ -7322,7 +7197,7 @@ describe('OptionsListUtils', () => { it('matches an email query against an email address', () => { // Given a report with a matching email address // When the query is an email search - const doesMatch = doesReportMatchSearchTerms(report, ['person@'], translateLocal); + const doesMatch = doesReportMatchSearchTerms(report, ['person@']); // Then the report matches expect(doesMatch).toBe(true); @@ -7338,7 +7213,7 @@ describe('OptionsListUtils', () => { }; // When the query uses the same accented name in uppercase - const doesMatch = doesReportMatchSearchTerms(groupReport, ['JOSÉ'], translateLocal); + const doesMatch = doesReportMatchSearchTerms(groupReport, ['JOSÉ']); // Then the group report matches expect(doesMatch).toBe(true); @@ -7360,26 +7235,11 @@ describe('OptionsListUtils', () => { }; // When the query uses that participant name - const doesMatch = doesReportMatchSearchTerms(groupReport, [displayName], translateLocal); + const doesMatch = doesReportMatchSearchTerms(groupReport, [displayName]); // Then the group report matches expect(doesMatch).toBe(true); }); - - it.each(['Me', 'You'])('matches the self DM when searching the localized "%s"', (searchTerm) => { - // Given a self DM whose title and login don't contain the search term - const selfDMReport: SearchOption = { - ...report, - isSelfDM: true, - item: {...createRandomReport(1, undefined), chatType: CONST.REPORT.CHAT_TYPE.SELF_DM}, - }; - - // When the query is the localized word the current user uses to refer to themselves - const doesMatch = doesReportMatchSearchTerms(selfDMReport, [searchTerm], translateLocal); - - // Then the self DM matches, so it shows up in pickers like New chat - expect(doesMatch).toBe(true); - }); }); describe('getMostRecentOptions()', () => { diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 20224602e4d5..1a92b7c66ad5 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -100,7 +100,6 @@ import { isMaxExpenseAmountSet, isMemberInHomeAndOfficeWorkspace, isMergeHRCompleteSetupNeededSelector, - isNonMemberApprover, isQBORefreshTokenExpiringSoonSelector, isPerDiemEligiblePolicy, isPerDiemEnabled, @@ -684,19 +683,6 @@ describe('PolicyUtils', () => { expect(isRoomMemberProtectedByPolicyRole(buildPolicy(), 'Member@Test.com', undefined)).toBe(false); }); - it('protects a member who is an auditor of the policy in their own right', () => { - // Given a policy viewed by an admin, holding an auditor in its employee list - const auditorLogin = 'auditor@test.com'; - const policy = buildPolicy(); - const policyWithAuditor = {...policy, employeeList: {...policy.employeeList, [auditorLogin]: {role: CONST.POLICY.ROLE.AUDITOR}}}; - - // When that member's protection is resolved, including through a mixed-case login - // Then they are protected, because auditors are default members of the workspace chat and the server adds - // them back after removal. The viewer's global `role` must not hide the auditor's own role - expect(isRoomMemberProtectedByPolicyRole(policyWithAuditor, auditorLogin, undefined)).toBe(true); - expect(isRoomMemberProtectedByPolicyRole(policyWithAuditor, 'Auditor@Test.com', undefined)).toBe(true); - }); - it('protects the policy owner by accountID even when the employee list does not list them', () => { // Given the policy owner participating in another employee's workspace chat, absent from `employeeList` // When their protection is resolved @@ -2902,32 +2888,6 @@ describe('PolicyUtils', () => { }); }); - describe('isNonMemberApprover', () => { - const ownerEmail = 'owner@example.com'; - const policy = createMock({ - id: '1', - owner: ownerEmail, - employeeList: { - [employeeEmail]: {email: employeeEmail, role: CONST.POLICY.ROLE.USER}, - }, - }); - - it('flags an approver who is no longer on the workspace, but not a member', () => { - // Given a workspace with one member - // When checking a removed approver and that member - // Then only the removed approver is flagged - expect(isNonMemberApprover(policy, 'removed@example.com')).toBe(true); - expect(isNonMemberApprover(policy, employeeEmail)).toBe(false); - }); - - it('does not flag the owner when their employee entry is missing', () => { - // Given a workspace whose employeeList has no entry for the owner - // When checking the owner as an approver - // Then they still count as a member, since the owner always belongs to the workspace - expect(isNonMemberApprover(policy, ownerEmail)).toBe(false); - }); - }); - describe('isPolicyMemberWithoutPendingDelete', () => { it('should return true if the policy member is not pending delete', () => { const policy = createMock({ diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index faa97ef3cd73..1c73ddb46634 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -2920,157 +2920,6 @@ describe('ReportActionsUtils', () => { }); }); - describe('isDeletedReportPreviewWithError', () => { - const errorKey = CONST.IOU.PAY_FAILURE_PREVIEW_ERROR_KEY; - // An unrelated error, keyed by the microtime it was recorded at. - const otherErrorKey = 1737000000000; - - /** A report preview marked deleted the way the server marks one when its report is removed. */ - function buildDeletedReportPreview(errors?: ReportAction['errors']): ReportAction { - return { - actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, - reportActionID: '1', - created: '2025-09-29', - message: [{html: 'owes $10.00', type: 'COMMENT', text: 'owes $10.00', deleted: '2025-09-30 10:00:00.000'}], - originalMessage: {linkedReportID: '2'}, - errors, - }; - } - - it('should return true for a deleted report preview that still carries an error', () => { - // Given a report preview whose report was deleted while the payer's payment sat in the offline queue, - // leaving the payment failure error behind on the preview - const reportAction = buildDeletedReportPreview({[errorKey]: 'This payment failed because the expense was deleted.'}); - - // When we ask whether it is an errored deleted preview - const actual = ReportActionsUtils.isDeletedReportPreviewWithError(reportAction); - - // Then it is, because that error is the payer's only feedback and needs somewhere to render - expect(actual).toBe(true); - }); - - it('should return false for a deleted report preview with no error', () => { - // Given an ordinary deleted report preview, i.e. the expense was deleted and nothing failed - const reportAction = buildDeletedReportPreview(); - - // When we ask whether it is an errored deleted preview - const actual = ReportActionsUtils.isDeletedReportPreviewWithError(reportAction); - - // Then it is not, so it stays hidden as before - expect(actual).toBe(false); - }); - - it('should return false for a deleted report preview whose errors were dismissed', () => { - // Given a preview whose error the payer already dismissed, which clears the values rather than the keys - const reportAction = buildDeletedReportPreview({[errorKey]: null}); - - // When we ask whether it is an errored deleted preview - const actual = ReportActionsUtils.isDeletedReportPreviewWithError(reportAction); - - // Then it is not, so dismissing removes the row instead of leaving an empty one behind - expect(actual).toBe(false); - }); - - it('should return false for a deleted preview carrying an unrelated error', () => { - // Given a deleted preview whose only error came from something other than a failed payment - const reportAction = buildDeletedReportPreview({[otherErrorKey]: 'Some other error'}); - - // When we ask whether it is an errored deleted preview - const actual = ReportActionsUtils.isDeletedReportPreviewWithError(reportAction); - - // Then it is not, so it is neither revived nor relabelled as a payment failure - expect(actual).toBe(false); - }); - - it('should hide the payment failure on a preview whose report still exists', () => { - // Given a live report preview carrying the payment failure, i.e. the payment was rejected for some reason - // other than the expense being deleted - const reportAction: ReportAction = { - actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, - reportActionID: '1', - created: '2025-09-29', - message: [{html: 'owes $10.00', type: 'COMMENT', text: 'owes $10.00'}], - originalMessage: {linkedReportID: '2'}, - errors: {[errorKey]: 'Unexpected error. Please try again later.'}, - }; - - // When we ask which errors a consumer should act on - const actual = ReportActionsUtils.getVisibleReportActionErrors(reportAction); - - // Then the payment failure is dropped, because the error still has a home on the pay action inside the - // expense report and the chat should not be red-dotted twice - expect(actual).toEqual({}); - }); - - it('should keep other errors on a preview whose report still exists', () => { - // Given a live preview carrying the error left by a failed delete - const reportAction: ReportAction = { - actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, - reportActionID: '1', - created: '2025-09-29', - message: [{html: 'owes $10.00', type: 'COMMENT', text: 'owes $10.00'}], - originalMessage: {linkedReportID: '2'}, - errors: {[otherErrorKey]: 'Unexpected error deleting this expense. Please try again later.'}, - }; - - // When we ask which errors a consumer should act on - const actual = ReportActionsUtils.getVisibleReportActionErrors(reportAction); - - // Then it is untouched, i.e. the suppression is scoped to the payment failure - expect(actual).toEqual({[otherErrorKey]: 'Unexpected error deleting this expense. Please try again later.'}); - }); - - it('should keep the payment failure once the preview is deleted', () => { - // Given the same payment failure on a preview the server has since marked deleted - const reportAction = buildDeletedReportPreview({[errorKey]: 'Unexpected error. Please try again later.'}); - - // When we ask which errors a consumer should act on - const actual = ReportActionsUtils.getVisibleReportActionErrors(reportAction); - - // Then it survives, because this is the payer's only remaining feedback - expect(actual).toEqual({[errorKey]: 'Unexpected error. Please try again later.'}); - }); - - it('should return false for a non-preview deleted action that carries an error', () => { - // Given a deleted comment with an error, which must not be revived by this rule - const reportAction: ReportAction = { - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - reportActionID: '1', - created: '2025-09-29', - message: [{html: '', type: 'COMMENT', text: ''}], - errors: {[errorKey]: 'Some error'}, - }; - - // When we ask whether it is an errored deleted preview - const actual = ReportActionsUtils.isDeletedReportPreviewWithError(reportAction); - - // Then it is not, because the rule is scoped to report previews - expect(actual).toBe(false); - }); - - it('should keep an errored deleted report preview visible', () => { - // Given a deleted report preview carrying a payment failure error - const reportAction = buildDeletedReportPreview({[errorKey]: 'This payment failed because the expense was deleted.'}); - - // When the report view decides whether to render it - const actual = ReportActionsUtils.shouldReportActionBeVisible(reportAction, reportAction.reportActionID, true); - - // Then it is rendered, so the RBR beneath it has a preview to attach to - expect(actual).toBe(true); - }); - - it('should keep hiding a deleted report preview with no error', () => { - // Given the same preview without an error - const reportAction = buildDeletedReportPreview(); - - // When the report view decides whether to render it - const actual = ReportActionsUtils.shouldReportActionBeVisible(reportAction, reportAction.reportActionID, true); - - // Then it stays hidden, i.e. this fix did not make every deleted preview visible - expect(actual).toBe(false); - }); - }); - describe('getPolicyChangeLogUpdateEmployee', () => { it('should remove SMS domain when the email is a phone number', () => { const email = '+919383833920@expensify.sms'; diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 2bb31d5de554..6e6819628a71 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -61,7 +61,6 @@ import { canBeAutoReimbursed, canCreateRequest, canCreateTaskInReport, - canDeleteCardTransaction, canDeleteMoneyRequestReport, canDeleteReportAction, canDeleteTransaction, @@ -6331,70 +6330,16 @@ describe('ReportUtils', () => { expect(canDeleteMoneyRequestReport(draftReport, [transaction], [iouAction], currentUserAccountID, undefined, memberPolicy, undefined)).toBe(false); }); - it('should allow an admin to delete a draft report holding cash and third-party card expenses regardless of liability', () => { - const cashTransaction = {...createRandomTransaction(904), reportID: draftReport.reportID, managedCard: false}; - const cardTransaction = { - ...createRandomTransaction(905), + it('should allow an admin to delete a draft report holding a card transaction with restricted liability, since the expenses become unreported rather than deleted', () => { + const transaction = { + ...createRandomTransaction(904), reportID: draftReport.reportID, managedCard: true, - bank: 'third-party-bank', comment: {liabilityType: CONST.TRANSACTION.LIABILITY_TYPE.RESTRICT}, }; - const cashIOUAction = buildIOUActionForTransaction(draftReport.reportID, cashTransaction.transactionID, 777); - const cardIOUAction = buildIOUActionForTransaction(draftReport.reportID, cardTransaction.transactionID, 777); + const iouAction = buildIOUActionForTransaction(draftReport.reportID, transaction.transactionID, currentUserAccountID); - expect( - canDeleteMoneyRequestReport( - draftReport, - [cashTransaction, cardTransaction], - [cashIOUAction, cardIOUAction], - currentUserAccountID, - undefined, - adminPolicy, - undefined, - true, - ), - ).toBe(true); - }); - - it('should not allow an admin to delete a draft report containing an Expensify Card transaction', () => { - const cashTransaction = {...createRandomTransaction(906), reportID: draftReport.reportID, managedCard: false}; - const expensifyCardTransaction = { - ...createRandomTransaction(907), - reportID: draftReport.reportID, - managedCard: true, - bank: CONST.EXPENSIFY_CARD.BANK, - comment: {liabilityType: CONST.TRANSACTION.LIABILITY_TYPE.RESTRICT}, - }; - const cashIOUAction = buildIOUActionForTransaction(draftReport.reportID, cashTransaction.transactionID, 777); - const cardIOUAction = buildIOUActionForTransaction(draftReport.reportID, expensifyCardTransaction.transactionID, 777); - - expect( - canDeleteMoneyRequestReport( - draftReport, - [cashTransaction, expensifyCardTransaction], - [cashIOUAction, cardIOUAction], - currentUserAccountID, - undefined, - adminPolicy, - undefined, - true, - ), - ).toBe(false); - }); - - it('should not allow a member to delete their own draft report containing an Expensify Card transaction', () => { - const ownDraftReport = {...draftReport, ownerAccountID: currentUserAccountID}; - const expensifyCardTransaction = { - ...createRandomTransaction(908), - reportID: ownDraftReport.reportID, - managedCard: false, - bank: CONST.EXPENSIFY_CARD.BANK, - comment: {liabilityType: CONST.TRANSACTION.LIABILITY_TYPE.RESTRICT}, - }; - const iouAction = buildIOUActionForTransaction(ownDraftReport.reportID, expensifyCardTransaction.transactionID, currentUserAccountID); - - expect(canDeleteMoneyRequestReport(ownDraftReport, [expensifyCardTransaction], [iouAction], currentUserAccountID, undefined, memberPolicy, undefined, true)).toBe(false); + expect(canDeleteMoneyRequestReport(draftReport, [transaction], [iouAction], currentUserAccountID, undefined, adminPolicy, undefined, true)).toBe(true); }); }); @@ -10306,22 +10251,6 @@ describe('ReportUtils', () => { }); describe('canDeleteReportAction', () => { - it('should not allow an Expensify Card transaction to be deleted when managedCard is missing', () => { - // Given an Expensify Card transaction whose managed-card metadata is unavailable - const transaction = { - ...createRandomTransaction(1000), - managedCard: false, - bank: CONST.EXPENSIFY_CARD.BANK, - comment: {liabilityType: CONST.TRANSACTION.LIABILITY_TYPE.RESTRICT}, - }; - - // When checking whether the transaction can be deleted - const canDeleteTransactionByLiability = canDeleteCardTransaction(transaction, undefined, undefined); - - // Then the bank identifies it as an Expensify Card transaction and deletion stays blocked - expect(canDeleteTransactionByLiability).toBe(false); - }); - it('should return false for delete button visibility if transaction is not allowed to be deleted', async () => { // Given a restricted managed-card expense on an open expense report owned by the current user const expenseReport = { @@ -16513,86 +16442,6 @@ describe('ReportUtils', () => { reportAction: reportActionWithError, }); }); - it('should count an errored deleted report preview so the chat still requires attention', async () => { - // Given a report preview the server marked deleted, still carrying the error left by a payment that was - // rejected because the expense was deleted while it sat in the offline queue - const errorKey = CONST.IOU.PAY_FAILURE_PREVIEW_ERROR_KEY; - const deletedPreviewWithError: ReportAction = { - ...createRandomReportAction(5), - reportID: report.reportID, - actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, - message: [{html: 'owes $10.00', type: 'COMMENT', text: 'owes $10.00', deleted: '2025-09-30 10:00:00.000'}], - errors: {[errorKey]: 'This payment failed because the expense was deleted.'}, - }; - const reportActionsWithDeletedPreview = { - ...reportActions, - [deletedPreviewWithError.reportActionID]: deletedPreviewWithError, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, { - [deletedPreviewWithError.reportActionID]: deletedPreviewWithError, - }); - await waitForBatchedUpdates(); - - // When the chat's errors are aggregated - const {errors} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActionsWithDeletedPreview, allTransactions, currentUserAccountID); - - // Then the error counts, so the DM gets an RBR pointing the payer at the failure. Deleted actions are - // normally filtered out, which would have hidden the only feedback the payer gets. - expect(errors).toEqual({[errorKey]: 'This payment failed because the expense was deleted.'}); - }); - - it('should not mark the chat as requiring attention for a payment failure on a live preview', async () => { - // Given a payment rejected for some reason other than the expense being deleted, so the preview is intact - const livePreviewWithPayFailure: ReportAction = { - ...createRandomReportAction(7), - reportID: report.reportID, - actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, - message: [{html: 'owes $10.00', type: 'COMMENT', text: 'owes $10.00'}], - errors: {[CONST.IOU.PAY_FAILURE_PREVIEW_ERROR_KEY]: 'Unexpected error. Please try again later.'}, - }; - const reportActionsWithLivePreview = { - ...reportActions, - [livePreviewWithPayFailure.reportActionID]: livePreviewWithPayFailure, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, { - [livePreviewWithPayFailure.reportActionID]: livePreviewWithPayFailure, - }); - await waitForBatchedUpdates(); - - // When the chat's errors are aggregated - const {errors} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActionsWithLivePreview, allTransactions, currentUserAccountID); - - // Then the chat is not red-dotted, because the error still has a home on the pay action inside the - // expense report the payer can still reach - expect(errors).toEqual({}); - }); - - it('should keep ignoring a deleted action that is not an errored report preview', async () => { - // Given an ordinary deleted comment whose stale error should not nag the user - const errorKey = 1737000000001; - const deletedCommentWithError: ReportAction = { - ...createRandomReportAction(6), - reportID: report.reportID, - actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, - message: [{html: '', type: 'COMMENT', text: ''}], - errors: {[errorKey]: 'Stale error'}, - }; - const reportActionsWithDeletedComment = { - ...reportActions, - [deletedCommentWithError.reportActionID]: deletedCommentWithError, - }; - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, { - [deletedCommentWithError.reportActionID]: deletedCommentWithError, - }); - await waitForBatchedUpdates(); - - // When the chat's errors are aggregated - const {errors} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActionsWithDeletedComment, allTransactions, currentUserAccountID); - - // Then it is still ignored, i.e. the fix did not widen the filter beyond report previews - expect(errors).toEqual({}); - }); - it('should return smart scan error for the top-most parent report with smart scan error', async () => { const transactionID = '12345'; await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportAction1.reportID}`, { diff --git a/tests/unit/SearchEditMultipleTagPageTest.tsx b/tests/unit/SearchEditMultipleTagPageTest.tsx index c0a249b444a2..75145d17d75f 100644 --- a/tests/unit/SearchEditMultipleTagPageTest.tsx +++ b/tests/unit/SearchEditMultipleTagPageTest.tsx @@ -17,7 +17,6 @@ import React from 'react'; import Onyx from 'react-native-onyx'; import createRandomPolicy from '../utils/collections/policies'; -import {getGlobalFetchMock} from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; const POLICY_ID = 'A1B2C3'; @@ -123,8 +122,6 @@ async function renderAndTap({ describe('SearchEditMultipleTagPage saveTag (bulk-edit tag deselect, #100538)', () => { beforeEach(async () => { - // The tag picker sends OpenPolicyTagsPage when it mounts, so mock fetch to keep that request off the real network. - global.fetch = getGlobalFetchMock(); mockSelectionList.mockClear(); mockUpdateBulkEditDraftTransaction.mockClear(); await Onyx.clear(); diff --git a/tests/unit/SessionUtilsTest.ts b/tests/unit/SessionUtilsTest.ts index 6c6b89e430bc..392dd016dc60 100644 --- a/tests/unit/SessionUtilsTest.ts +++ b/tests/unit/SessionUtilsTest.ts @@ -1,4 +1,4 @@ -import {checkIfShouldUseNewPartnerName, getEmailFromTransitionURL, getPartnerCredentials, isAgentEmail, isLoggingInAsDelegate, isLoggingInAsNewUser} from '@src/libs/SessionUtils'; +import {checkIfShouldUseNewPartnerName, getPartnerCredentials, isAgentEmail, isLoggingInAsDelegate} from '@src/libs/SessionUtils'; function mockHybridAppConfig(isHybridApp: boolean): () => void { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -111,51 +111,4 @@ describe('SessionUtils', () => { expect(isLoggingInAsDelegate(transitionURL)).toBe(expectedResult); }); }); - - describe('isLoggingInAsNewUser', () => { - test.each([ - ['should return false when email matches sessionEmail directly', '?email=user@example.com', 'user@example.com', false], - ['should return false when delegatorEmail matches sessionEmail directly', '?delegatorEmail=user@example.com', 'user@example.com', false], - ['should return true when email does not match sessionEmail', '?email=other@example.com', 'user@example.com', true], - ['should return true when transitionURL has no email or delegatorEmail param', '?shortLivedAuthToken=abc123', 'user@example.com', true], - ['should return true when transitionURL is undefined', undefined, 'user@example.com', true], - [ - 'should return false for full URL where URLSearchParams mangles the first param key but the regex-extracted email matches', - 'https://example.com?email=user@example.com', - 'user@example.com', - false, - ], - ['should return true for full URL where the regex-extracted email does not match sessionEmail', 'https://example.com?email=other@example.com', 'user@example.com', true], - [ - 'should return false for full URL where the regex-extracted delegatorEmail matches sessionEmail', - 'https://example.com?delegatorEmail=user@example.com', - 'user@example.com', - false, - ], - ['should return true when the encoded email does not match sessionEmail', '?email=other%40example.com', 'user@example.com', true], - ['should return false when the encoded email matches sessionEmail after decoding', '?email=user%40example.com', 'user@example.com', false], - ])('%s', (_description, transitionURL, sessionEmail, expectedResult) => { - expect(isLoggingInAsNewUser(transitionURL, sessionEmail)).toBe(expectedResult); - }); - }); - - describe('getEmailFromTransitionURL', () => { - test.each([ - ['should return undefined when url is undefined', undefined, undefined], - ['should return undefined when url is empty', '', undefined], - ['should return undefined when url has no query string', 'https://example.com/transition', undefined], - ['should return undefined when email param is absent', '?shortLivedAuthToken=abc123', undefined], - ['should return the email when present as the only param', '?email=user@example.com', 'user@example.com'], - ['should return the email when it is a subsequent param', '?shortLivedAuthToken=abc123&email=user@example.com', 'user@example.com'], - ['should stop at the next query param delimiter', '?email=user@example.com&exitTo=%2Fhome', 'user@example.com'], - ['should return the decoded email for a full URL where URLSearchParams would mangle the first param key', 'https://example.com?email=user%40example.com', 'user@example.com'], - ['should decode a plus-encoded email', '?email=user%2Btag%40example.com', 'user+tag@example.com'], - ['should return the raw value unchanged when it contains an un-encoded percent that is not a valid escape sequence', '?email=100%off@example.com', '100%off@example.com'], - ['should ignore a fragment after the query string', '?email=user@example.com#section', 'user@example.com'], - ['should return undefined when email only appears after a hash fragment', '?shortLivedAuthToken=abc123#email=user@example.com', undefined], - ['should strip a zero-width space from the email', '?email=user@exa​mple.com', 'user@example.com'], - ])('%s', (_description, url, expectedResult) => { - expect(getEmailFromTransitionURL(url)).toBe(expectedResult); - }); - }); }); diff --git a/tests/unit/SignUpWelcomeFormTest.tsx b/tests/unit/SignUpWelcomeFormTest.tsx index 57630c297766..251eaff59663 100644 --- a/tests/unit/SignUpWelcomeFormTest.tsx +++ b/tests/unit/SignUpWelcomeFormTest.tsx @@ -107,7 +107,7 @@ describe('SignUpWelcomeForm', () => { fireEvent.press(screen.getByLabelText('I agree to receive marketing texts from Expensify')); fireEvent.press(screen.getByText('Join')); - expect(signUpUserSpy).toHaveBeenCalledWith(PHONE_LOGIN, undefined, true, undefined); + expect(signUpUserSpy).toHaveBeenCalledWith(PHONE_LOGIN, undefined, true); }); it('passes hasSMSMarketingConsent=false to signUpUser when checkbox is unchecked and Join is pressed', async () => { @@ -117,7 +117,7 @@ describe('SignUpWelcomeForm', () => { fireEvent.press(screen.getByText('Join')); - expect(signUpUserSpy).toHaveBeenCalledWith(PHONE_LOGIN, undefined, false, undefined); + expect(signUpUserSpy).toHaveBeenCalledWith(PHONE_LOGIN, undefined, false); }); it('passes hasSMSMarketingConsent=false when checkbox is checked then unchecked', async () => { @@ -129,7 +129,7 @@ describe('SignUpWelcomeForm', () => { fireEvent.press(screen.getByLabelText('I agree to receive marketing texts from Expensify')); fireEvent.press(screen.getByText('Join')); - expect(signUpUserSpy).toHaveBeenCalledWith(PHONE_LOGIN, undefined, false, undefined); + expect(signUpUserSpy).toHaveBeenCalledWith(PHONE_LOGIN, undefined, false); }); it('omits the consent param when signing up with an email', async () => { @@ -139,24 +139,6 @@ describe('SignUpWelcomeForm', () => { fireEvent.press(screen.getByText('Join')); - expect(signUpUserSpy).toHaveBeenCalledWith(EMAIL_LOGIN, undefined, undefined, undefined); - }); - - it('passes the stored marketing attribution to signUpUser', async () => { - // Given marketing attribution captured from a Google ad - // eslint-disable-next-line @typescript-eslint/naming-convention - const attribution = {utm_source: 'google', gclid: 'testGclid'}; - await setCredentialsLogin(EMAIL_LOGIN); - await act(async () => { - await Onyx.set(ONYXKEYS.MARKETING_ATTRIBUTION, attribution); - }); - await renderForm(); - await waitForBatchedUpdatesWithAct(); - - // When the user presses Join - fireEvent.press(screen.getByText('Join')); - - // Then the attribution is passed to signUpUser - expect(signUpUserSpy).toHaveBeenCalledWith(EMAIL_LOGIN, undefined, undefined, attribution); + expect(signUpUserSpy).toHaveBeenCalledWith(EMAIL_LOGIN, undefined, undefined); }); }); diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 4c578eb45c51..6dca6b46da92 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -4038,41 +4038,6 @@ describe('TransactionUtils', () => { }); }); - describe('getConvertedTaxAmount', () => { - it('should return the absolute converted tax amount if the transaction is not from an expense report', () => { - // Given an IOU transaction whose converted tax is stored as a negative value - const transaction = generateTransaction({convertedTaxAmount: -100}); - - // When we read the converted tax amount for a non-expense report - const convertedTaxAmount = TransactionUtils.getConvertedTaxAmount(transaction, false); - - // Then it is returned unsigned, because IOU requests cannot have negative values - expect(convertedTaxAmount).toBe(100); - }); - - it('should return the opposite sign if the transaction is from an expense report', () => { - // Given an expense-report transaction whose converted tax is stored with the opposite sign of what we display - const transaction = generateTransaction({convertedTaxAmount: 182}); - - // When we read the converted tax amount for an expense report - const convertedTaxAmount = TransactionUtils.getConvertedTaxAmount(transaction, true); - - // Then the sign is flipped back, so a refund's tax displays as negative - expect(convertedTaxAmount).toBe(-182); - }); - - it('should return 0 instead of -0 when there is no converted tax amount', () => { - // Given an expense-report transaction with no converted tax - const transaction = generateTransaction({convertedTaxAmount: 0}); - - // When we read the converted tax amount - const convertedTaxAmount = TransactionUtils.getConvertedTaxAmount(transaction, true); - - // Then we get 0 rather than -0, so we never render "-0.00" - expect(Object.is(convertedTaxAmount, 0)).toBe(true); - }); - }); - describe('hasTaxRateWithMatchingValue', () => { const policy: Policy = { ...createRandomPolicy(0), diff --git a/tests/unit/WorkflowUtilsTest.ts b/tests/unit/WorkflowUtilsTest.ts index 9c7fe114808f..b69ef798b0c1 100644 --- a/tests/unit/WorkflowUtilsTest.ts +++ b/tests/unit/WorkflowUtilsTest.ts @@ -14,12 +14,10 @@ import { getApprovalLimitDescription, getOpenConnectedToPolicyBusinessBankAccounts, getApprovalWorkflowSource, - getNonMemberApproverError, getOverLimitForwardsToDisplayName, getRulesSubmitterToFirstApprover, getRulesSubmitterToWorkflowKey, hasApprovalWorkflowRules, - hasApprovalWorkflowWithNonMemberApprover, includesEveryWorkspaceMember, isApprovalWorkflowLockedByIntegration, mergeWorkflowMembersWithAvailableMembers, @@ -368,28 +366,6 @@ describe('WorkflowUtils', () => { expect(approvers).toEqual([buildApprover(1, {approvalLimit: null, overLimitForwardsTo: ''})]); }); - - it('Should keep an approver who left the workspace at the end of the chain, flagged, when the policy is passed', () => { - // Given a chain 1 > 2 > 9 where 9 is no longer on the workspace - const employees: PolicyEmployeeList = { - '1@example.com': {email: '1@example.com', forwardsTo: '2@example.com'}, - '2@example.com': {email: '2@example.com', forwardsTo: '9@example.com'}, - }; - const policy = createMock({id: '1', owner: '1@example.com', employeeList: employees}); - - // When the chain is calculated with and without the policy - const approversWithPolicy = calculateApprovers({employees, firstEmail: '1@example.com', personalDetailsByEmail, policy}); - const approversWithoutPolicy = calculateApprovers({employees, firstEmail: '1@example.com', personalDetailsByEmail}); - - // Then with the policy, 9 is kept and flagged so the admin can see 2 still forwards to them, and without it - // the chain stops before 9 as callers that don't show flags expect - expect(approversWithPolicy).toEqual([ - buildApprover(1, {forwardsTo: '2@example.com'}), - buildApprover(2, {forwardsTo: '9@example.com'}), - buildApprover(9, {isNotWorkspaceMember: true}), - ]); - expect(approversWithoutPolicy).toEqual([buildApprover(1, {forwardsTo: '2@example.com'}), buildApprover(2, {forwardsTo: '9@example.com'})]); - }); }); describe('getOverLimitForwardsToDisplayName', () => { @@ -445,8 +421,7 @@ describe('WorkflowUtils', () => { expect(approvalWorkflows).toEqual([]); }); - it('Should show users that submit to a non-employee user under that approver, flagged as not a workspace member', () => { - // Given a member who submits to someone no longer on the workspace + it('Should not include users that submit to non-employee user', () => { const employees: PolicyEmployeeList = { '1@example.com': { email: '1@example.com', @@ -457,103 +432,13 @@ describe('WorkflowUtils', () => { const defaultApprover = '1@example.com'; const policy = createMockPolicy(employees, defaultApprover); - // When the workflows are built - const {approvalWorkflows} = convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, localeCompare}); - - // Then the member shows in a workflow under that approver, flagged so the admin can see and fix it, - // instead of being hidden from every workflow - expect(approvalWorkflows).toEqual([ - buildWorkflow([], [1], {isDefault: true}), - {members: [buildMember(1)], approvers: [buildApprover(2, {isNotWorkspaceMember: true})], isDefault: false, pendingAction: undefined}, - ]); - }); - - it('Should not flag a non-employee approver in HR advanced mode', () => { - // Given an HR integration in advanced (manager) mode, whose final approver doesn't have to be a member - const employees: PolicyEmployeeList = { - '1@example.com': { - email: '1@example.com', - submitsTo: '2@example.com', - }, - }; - const policy = createMock({ - ...createMockPolicy(employees, '1@example.com'), - connections: { - [CONST.POLICY.CONNECTIONS.NAME.MERGE_HR]: { - config: {approvalMode: CONST.MERGE.APPROVAL_MODE.MANAGER, finalApprover: '3@example.com', integration: 'workday', groups: []}, - }, - }, - }); - - // When the workflows are built - const {approvalWorkflows} = convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, localeCompare}); - - // Then the non-member approver isn't flagged, since HR mode routes outside employeeList on purpose - const approvers = approvalWorkflows.flatMap((workflow) => workflow.approvers); - expect(approvers.some((approver) => approver.email === '2@example.com')).toBe(true); - expect(approvers.some((approver) => approver.isNotWorkspaceMember)).toBe(false); - }); - - it('Should keep a non-employee Expensify team approver when Expensify team members are filtered out', () => { - // Given a customer workspace where a member submits to an Expensify team email that is no longer a member - const employees: PolicyEmployeeList = { - 'alice@example.com': { - email: 'alice@example.com', - submitsTo: 'guide@expensify.com', - }, - 'bob@example.com': { - email: 'bob@example.com', - submitsTo: 'bob@example.com', - }, - }; - const policy = createMock({ - ...createMockPolicy(employees, 'bob@example.com'), - owner: 'bob@example.com', + const {approvalWorkflows} = convertPolicyEmployeesToApprovalWorkflows({ + policy, + personalDetails, + localeCompare, }); - // When the workflows are built for a non-Expensify user - const {approvalWorkflows} = convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, localeCompare, currentUserLogin: 'bob@example.com'}); - - // Then the broken workflow keeps its flagged approver instead of ending up with no approvers - const brokenWorkflow = approvalWorkflows.find((workflow) => workflow.members.some((member) => member.email === 'alice@example.com')); - expect(brokenWorkflow?.approvers).toHaveLength(1); - expect(brokenWorkflow?.approvers.at(0)).toMatchObject({email: 'guide@expensify.com', isNotWorkspaceMember: true}); - }); - - it('Should flag an approver later in the chain who is no longer a workspace member', () => { - // Given a workflow 1 > 2 > 9 for member 3, where 9 was removed without 2's forwardsTo being updated - const employees: PolicyEmployeeList = { - '1@example.com': {email: '1@example.com', submitsTo: '1@example.com', forwardsTo: '2@example.com'}, - '2@example.com': {email: '2@example.com', submitsTo: '1@example.com', forwardsTo: '9@example.com'}, - '3@example.com': {email: '3@example.com', submitsTo: '1@example.com'}, - }; - const policy = createMockPolicy(employees, '1@example.com'); - - // When the workflows are built - const {approvalWorkflows} = convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, localeCompare}); - - // Then 9 is shown flagged at the end of the chain, instead of the workflow looking like it ends at 2 - expect(approvalWorkflows.at(0)?.approvers).toEqual([ - buildApprover(1, {forwardsTo: '2@example.com'}), - buildApprover(2, {forwardsTo: '9@example.com'}), - buildApprover(9, {isNotWorkspaceMember: true}), - ]); - }); - - it('Should not flag the owner as a non-member approver when their employee entry is missing', () => { - // Given a member who submits to the owner, whose employeeList entry is missing - const employees: PolicyEmployeeList = { - '1@example.com': {email: '1@example.com', submitsTo: 'owner@example.com'}, - }; - const policy = createMockPolicy(employees, '1@example.com'); - - // When the workflows are built - const {approvalWorkflows} = convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, localeCompare}); - - // Then the member is shown under the owner, who isn't flagged since the owner always belongs to the workspace - const ownerWorkflow = approvalWorkflows.find((workflow) => workflow.members.some((member) => member.email === '1@example.com')); - expect(ownerWorkflow?.approvers.map((approver) => approver.email)).toEqual(['owner@example.com']); - expect(ownerWorkflow?.approvers.at(0)?.isNotWorkspaceMember).toBeUndefined(); + expect(approvalWorkflows).toEqual([]); }); it('Should transform all users into one default workflow', () => { @@ -818,7 +703,8 @@ describe('WorkflowUtils', () => { }); it('Should include members with orphaned submitsTo in availableMembers', () => { - // Given a member with submitsTo pointing to a non-member (not in employeeList) + // Member with submitsTo pointing to non-member (not in employeeList) won't appear in any + // workflow, but should still appear in availableMembers so admins can fix the chain. const employees: PolicyEmployeeList = { 'alice@example.com': { email: 'alice@example.com', @@ -843,18 +729,14 @@ describe('WorkflowUtils', () => { }, }; - // When the workflows are built const {approvalWorkflows, availableMembers} = convertPolicyEmployeesToApprovalWorkflows({ policy, personalDetails: personalDetailsForTest, localeCompare, }); - // Then the member shows in their own flagged workflow and is still selectable in availableMembers, so admins can fix the chain - expect(approvalWorkflows).toHaveLength(2); - expect(approvalWorkflows.at(0)?.members.map((member) => member.email)).toEqual(['bob@example.com']); - expect(approvalWorkflows.at(1)?.members.map((member) => member.email)).toEqual(['alice@example.com']); - expect(approvalWorkflows.at(1)?.approvers.at(0)).toMatchObject({email: 'nonexistent@example.com', isNotWorkspaceMember: true}); + expect(approvalWorkflows).toHaveLength(1); + expect(approvalWorkflows.at(0)?.members).toHaveLength(1); const memberEmails = availableMembers.map((m) => m.email).sort(); expect(memberEmails).toEqual(['alice@example.com', 'bob@example.com']); }); @@ -1510,51 +1392,9 @@ describe('WorkflowUtils', () => { overLimitForwardsTo: 'update', }); }); - - it('Should not send an employee entry for an approver who is not a workspace member', () => { - // Given a workflow whose approver is no longer on the workspace, as the Members pages pass it when another approver is removed - const previousEmployeeList: PolicyEmployeeList = { - '1@example.com': {email: '1@example.com', submitsTo: '9@example.com'}, - }; - const approvalWorkflow: ApprovalWorkflow = { - members: [buildMember(1)], - approvers: [buildApprover(9, {isNotWorkspaceMember: true})], - isDefault: false, - }; - - // When it is converted for an update, and again with that approver being replaced - const convertedEmployees = convertApprovalWorkflowToPolicyEmployees({previousEmployeeList, approvalWorkflow, type: 'update'}); - const replacedEmployees = convertApprovalWorkflowToPolicyEmployees({ - previousEmployeeList, - approvalWorkflow: {...approvalWorkflow, approvers: [buildApprover(2)]}, - approversToRemove: approvalWorkflow.approvers, - type: 'update', - }); - - // Then no entry is created for the non-member, which would otherwise add a fake employee - expect(convertedEmployees).toEqual({}); - expect(replacedEmployees['9@example.com']).toBeUndefined(); - expect(replacedEmployees['1@example.com']?.submitsTo).toBe('2@example.com'); - }); }); describe('updateWorkflowDataOnApproverRemoval', () => { - it('Should fall back to the owner email when the owner has no personal details', () => { - // Given a workflow whose only approver is removed, while the owner's personal details aren't loaded - const approvalWorkflow1 = buildWorkflow([], [3], {isDefault: true}); - const approvalWorkflow2 = buildWorkflow([1], [2]); - - // When the approver is removed - const result = updateWorkflowDataOnApproverRemoval({ - approvalWorkflows: [approvalWorkflow1, approvalWorkflow2], - removedApproverEmail: '2@example.com', - ownerEmail: 'owner@example.com', - }); - - // Then the owner still replaces the removed approver, so the members are reassigned instead of left on a non-member - expect(result.at(1)?.approvers).toEqual([{...buildApprover(2), email: 'owner@example.com', avatar: '', displayName: 'owner@example.com'}]); - }); - it('Should remove Workflow 2 if its approvers are removed and it has no approvers, with Workspace (default) having the approver as the Workspace Owner.', () => { const approvalWorkflow1: ApprovalWorkflow = { members: [buildMember(1), buildMember(2)], @@ -1576,8 +1416,7 @@ describe('WorkflowUtils', () => { const updateWorkflowDataOnApproverRemovalResult = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1, approvalWorkflow2], - removedApproverEmail: removedApprover.login ?? '', - ownerEmail: ownerDetails.login ?? '', + removedApprover, ownerDetails, }); @@ -1604,8 +1443,7 @@ describe('WorkflowUtils', () => { const updateWorkflowDataOnApproverRemovalResult = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1, approvalWorkflow2], - removedApproverEmail: removedApprover.login ?? '', - ownerEmail: ownerDetails.login ?? '', + removedApprover, ownerDetails, }); @@ -1632,8 +1470,7 @@ describe('WorkflowUtils', () => { const updateWorkflowDataOnApproverRemovalResult = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1, approvalWorkflow2], - removedApproverEmail: removedApprover.login ?? '', - ownerEmail: ownerDetails.login ?? '', + removedApprover, ownerDetails, }); @@ -1663,8 +1500,7 @@ describe('WorkflowUtils', () => { const updateWorkflowDataOnApproverRemovalResult = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1, approvalWorkflow2], - removedApproverEmail: removedApprover.login ?? '', - ownerEmail: ownerDetails.login ?? '', + removedApprover, ownerDetails, }); @@ -1697,8 +1533,7 @@ describe('WorkflowUtils', () => { const updateWorkflowDataOnApproverRemovalResult = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1, approvalWorkflow2], - removedApproverEmail: removedApprover.login ?? '', - ownerEmail: ownerDetails.login ?? '', + removedApprover, ownerDetails, }); @@ -1731,8 +1566,7 @@ describe('WorkflowUtils', () => { const updateWorkflowDataOnApproverRemovalResult = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1, approvalWorkflow2], - removedApproverEmail: removedApprover.login ?? '', - ownerEmail: ownerDetails.login ?? '', + removedApprover, ownerDetails, }); @@ -1756,8 +1590,7 @@ describe('WorkflowUtils', () => { const result = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1], - removedApproverEmail: removedApprover.login ?? '', - ownerEmail: ownerDetails.login ?? '', + removedApprover, ownerDetails, }); @@ -1793,8 +1626,7 @@ describe('WorkflowUtils', () => { const result = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1], - removedApproverEmail: removedApprover.login ?? '', - ownerEmail: ownerDetails.login ?? '', + removedApprover, ownerDetails, }); @@ -1822,8 +1654,7 @@ describe('WorkflowUtils', () => { const result = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1], - removedApproverEmail: removedApprover.login ?? '', - ownerEmail: ownerDetails.login ?? '', + removedApprover, ownerDetails, }); @@ -1859,8 +1690,7 @@ describe('WorkflowUtils', () => { const result = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1], - removedApproverEmail: removedApprover.login ?? '', - ownerEmail: ownerDetails.login ?? '', + removedApprover, ownerDetails, }); @@ -1888,8 +1718,7 @@ describe('WorkflowUtils', () => { const result = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1], - removedApproverEmail: removedApprover.login ?? '', - ownerEmail: ownerDetails.login ?? '', + removedApprover, ownerDetails, }); @@ -1926,8 +1755,7 @@ describe('WorkflowUtils', () => { const result = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1], - removedApproverEmail: removedApprover.login ?? '', - ownerEmail: ownerDetails.login ?? '', + removedApprover, ownerDetails, }); @@ -1971,8 +1799,7 @@ describe('WorkflowUtils', () => { const result = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1], - removedApproverEmail: removedApprover.login ?? '', - ownerEmail: ownerDetails.login ?? '', + removedApprover, ownerDetails, }); @@ -2006,8 +1833,7 @@ describe('WorkflowUtils', () => { const result = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [emptyDefaultWorkflow, approvalWorkflow], - removedApproverEmail: removedApprover.login ?? '', - ownerEmail: ownerDetails.login ?? '', + removedApprover, ownerDetails, }); @@ -2700,25 +2526,6 @@ describe('WorkflowUtils', () => { approver: defaultApprover, }); - it('Should show a member who submits to a non-member under that approver, flagged', () => { - // Given a member no rule covers, whose submitsTo points at someone no longer on the workspace - const employees: PolicyEmployeeList = { - '1@example.com': {email: '1@example.com', submitsTo: '1@example.com'}, - '5@example.com': {email: '5@example.com', submitsTo: '9@example.com'}, - }; - const policy = createPolicy(employees, '1@example.com'); - - // When the workflows are built from rules - const {approvalWorkflows} = convertApprovalWorkflowRulesToWorkflows({policy, personalDetails, localeCompare, rules: {}}); - - // Then the member is shown under the non-member approver, flagged, instead of being hidden - const brokenWorkflow = approvalWorkflows.find((workflow) => workflow.members.some((member) => member.email === '5@example.com')); - expect(brokenWorkflow?.isDefault).toBe(false); - expect(brokenWorkflow?.approvers).toHaveLength(1); - expect(brokenWorkflow?.approvers.at(0)).toMatchObject({email: '9@example.com', isNotWorkspaceMember: true}); - expect(approvalWorkflows.find((workflow) => workflow.isDefault)?.approvers.at(0)?.isNotWorkspaceMember).toBeUndefined(); - }); - it('Should reconstruct a multi-approver chain from rules', () => { const rules = keyRules(buildApprovalWorkflowRules(buildWorkflow([5], [1, 2]))); const employees: PolicyEmployeeList = { @@ -3210,241 +3017,6 @@ describe('WorkflowUtils', () => { }); }); - describe('hasApprovalWorkflowWithNonMemberApprover', () => { - const ownerEmail = 'owner@example.com'; - const removedEmail = '9@example.com'; - const buildPolicy = (policy: Partial = {}) => - createMock({ - id: '1', - type: CONST.POLICY.TYPE.CORPORATE, - role: CONST.POLICY.ROLE.ADMIN, - owner: ownerEmail, - approver: ownerEmail, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - employeeList: { - [ownerEmail]: {email: ownerEmail, role: CONST.POLICY.ROLE.ADMIN, submitsTo: ownerEmail}, - '1@example.com': {email: '1@example.com', role: CONST.POLICY.ROLE.USER, submitsTo: removedEmail}, - }, - ...policy, - }); - const keyRules = (rules: ApprovalWorkflowRule[]): Record => Object.fromEntries(rules.map((rule, index) => [`rule${index}`, rule])); - - it('flags a workspace where a member submits to someone no longer on it', () => { - // Given an advanced workspace where member 1's approver was removed without reassigning them - const policy = buildPolicy(); - - // When the owner checks whether a workflow needs fixing - // Then it does, since the Workflows page shows that workflow flagged - expect(hasApprovalWorkflowWithNonMemberApprover({policy, currentUserLogin: ownerEmail, isMultipleApproversBetaEnabled: false})).toBe(true); - }); - - it('flags an approver later in the chain who is no longer on the workspace', () => { - // Given everyone submits to the owner, who forwards to someone no longer on the workspace - const policy = buildPolicy({ - employeeList: { - [ownerEmail]: {email: ownerEmail, role: CONST.POLICY.ROLE.ADMIN, submitsTo: ownerEmail, forwardsTo: removedEmail}, - '1@example.com': {email: '1@example.com', role: CONST.POLICY.ROLE.USER, submitsTo: ownerEmail}, - }, - }); - - // When the owner checks whether a workflow needs fixing - // Then it does, matching the flagged approver shown at the end of the chain - expect(hasApprovalWorkflowWithNonMemberApprover({policy, currentUserLogin: ownerEmail, isMultipleApproversBetaEnabled: false})).toBe(true); - }); - - it('flags a non-member that only a later approver forwards to', () => { - // Given everyone submits to the owner, who forwards to member 1, and only member 1 forwards to someone no - // longer on the workspace, so the non-member is reachable through a forwardsTo alone - const policy = buildPolicy({ - employeeList: { - [ownerEmail]: {email: ownerEmail, role: CONST.POLICY.ROLE.ADMIN, submitsTo: ownerEmail, forwardsTo: '1@example.com'}, - '1@example.com': {email: '1@example.com', role: CONST.POLICY.ROLE.ADMIN, submitsTo: ownerEmail, forwardsTo: removedEmail}, - '2@example.com': {email: '2@example.com', role: CONST.POLICY.ROLE.USER, submitsTo: ownerEmail}, - }, - }); - - // When the owner checks whether a workflow needs fixing - // Then it does, since the quick check over every submitsTo and forwardsTo still finds the non-member - expect(hasApprovalWorkflowWithNonMemberApprover({policy, currentUserLogin: ownerEmail, isMultipleApproversBetaEnabled: false})).toBe(true); - }); - - it('flags a non-member that reports over an approval limit forward to', () => { - // Given every submitsTo and forwardsTo points at a member, but reports over the owner's approval limit go - // to someone no longer on the workspace - const policy = buildPolicy({ - employeeList: { - [ownerEmail]: {email: ownerEmail, role: CONST.POLICY.ROLE.ADMIN, submitsTo: ownerEmail, approvalLimit: 1000, overLimitForwardsTo: removedEmail}, - '1@example.com': {email: '1@example.com', role: CONST.POLICY.ROLE.USER, submitsTo: ownerEmail}, - }, - }); - - // When the owner checks whether a workflow needs fixing - // Then it does, since reports over the limit still route to the non-member - expect(hasApprovalWorkflowWithNonMemberApprover({policy, currentUserLogin: ownerEmail, isMultipleApproversBetaEnabled: false})).toBe(true); - - // And the workflow flags that approver so the page shows the error on them - const {approvalWorkflows} = convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, localeCompare}); - expect(approvalWorkflows.at(0)?.approvers.at(0)).toMatchObject({email: ownerEmail, isOverLimitForwardsToNotWorkspaceMember: true}); - }); - - it('does not flag a workspace where every route points at a member', () => { - // Given the owner isn't on employeeList but is still the default approver, and every submitsTo and - // forwardsTo points at someone on the workspace. The same workspace with one forwardsTo pointing at a - // removed approver is the control, so the result isn't coming from the permission checks. - const employeeList: PolicyEmployeeList = { - '1@example.com': {email: '1@example.com', role: CONST.POLICY.ROLE.ADMIN, submitsTo: ownerEmail, forwardsTo: '2@example.com'}, - '2@example.com': {email: '2@example.com', role: CONST.POLICY.ROLE.USER, submitsTo: '1@example.com'}, - }; - const policy = buildPolicy({employeeList}); - const brokenPolicy = buildPolicy({employeeList: {...employeeList, '2@example.com': {...employeeList['2@example.com'], forwardsTo: removedEmail}}}); - - // When an admin checks whether a workflow needs fixing - // Then only the control is flagged, since the owner always counts as a member and no other route points - // at a non-member - expect(hasApprovalWorkflowWithNonMemberApprover({policy, currentUserLogin: '1@example.com', isMultipleApproversBetaEnabled: false})).toBe(false); - expect(hasApprovalWorkflowWithNonMemberApprover({policy: brokenPolicy, currentUserLogin: '1@example.com', isMultipleApproversBetaEnabled: false})).toBe(true); - }); - - it('does not flag an Expensify team member that a customer admin does not see', () => { - // Given a guide who submits to someone no longer on a customer's workspace - const policy = buildPolicy({ - employeeList: { - [ownerEmail]: {email: ownerEmail, role: CONST.POLICY.ROLE.ADMIN, submitsTo: ownerEmail}, - 'guide@team.expensify.com': {email: 'guide@team.expensify.com', role: CONST.POLICY.ROLE.ADMIN, submitsTo: removedEmail}, - }, - }); - - // When the customer owner and an Expensify agent check whether a workflow needs fixing - // Then only the agent, who sees the guide's workflow, is told to fix it - expect(hasApprovalWorkflowWithNonMemberApprover({policy, currentUserLogin: ownerEmail, isMultipleApproversBetaEnabled: false})).toBe(false); - expect(hasApprovalWorkflowWithNonMemberApprover({policy, currentUserLogin: 'agent@expensify.com', isMultipleApproversBetaEnabled: false})).toBe(true); - }); - - it('only flags the workflows the approval mode enforces', () => { - // Given a basic workspace where a member still submits to someone no longer on it, and another where the - // default approver itself is no longer on it - const basicPolicy = buildPolicy({approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC}); - const basicPolicyWithRemovedDefaultApprover = buildPolicy({ - approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, - approver: removedEmail, - employeeList: { - [ownerEmail]: {email: ownerEmail, role: CONST.POLICY.ROLE.ADMIN, submitsTo: ownerEmail}, - }, - }); - - // When the owner checks whether a workflow needs fixing - // Then only the removed default approver is flagged, since basic mode runs only the default workflow - expect(hasApprovalWorkflowWithNonMemberApprover({policy: basicPolicy, currentUserLogin: ownerEmail, isMultipleApproversBetaEnabled: false})).toBe(false); - expect(hasApprovalWorkflowWithNonMemberApprover({policy: basicPolicyWithRemovedDefaultApprover, currentUserLogin: ownerEmail, isMultipleApproversBetaEnabled: false})).toBe(true); - }); - - it('routes from the rules under the multiple approvers beta', () => { - // Given member 1 has a stale submitsTo but a rule routing them to the owner, and member 2 has a rule chain - // whose second approver is no longer on the workspace - const coveredPolicy = buildPolicy(); - const coveredRules = keyRules(buildApprovalWorkflowRules({members: [buildMember(1)], approvers: [buildApprover(1, {email: ownerEmail})], isDefault: false})); - const brokenChainPolicy = buildPolicy({ - employeeList: { - [ownerEmail]: {email: ownerEmail, role: CONST.POLICY.ROLE.ADMIN, submitsTo: ownerEmail}, - '2@example.com': {email: '2@example.com', role: CONST.POLICY.ROLE.USER, submitsTo: ownerEmail}, - }, - }); - const brokenChainRules = keyRules( - buildApprovalWorkflowRules({members: [buildMember(2)], approvers: [buildApprover(1, {email: ownerEmail}), buildApprover(9)], isDefault: false}), - ); - - // When the owner checks whether a workflow needs fixing - // Then the rules decide: the covered member isn't flagged, and the non-member in the chain is - expect(hasApprovalWorkflowWithNonMemberApprover({policy: coveredPolicy, currentUserLogin: ownerEmail, rules: coveredRules, isMultipleApproversBetaEnabled: true})).toBe(false); - expect(hasApprovalWorkflowWithNonMemberApprover({policy: brokenChainPolicy, currentUserLogin: ownerEmail, rules: brokenChainRules, isMultipleApproversBetaEnabled: true})).toBe( - true, - ); - }); - - it('flags a rule that sends reports over the limit to a non-member under the multiple approvers beta', () => { - // Given member 2 is routed by a rule to the owner, whose reports over the limit go to someone no longer on - // the workspace - const policy = buildPolicy({ - employeeList: { - [ownerEmail]: {email: ownerEmail, role: CONST.POLICY.ROLE.ADMIN, submitsTo: ownerEmail}, - '2@example.com': {email: '2@example.com', role: CONST.POLICY.ROLE.USER, submitsTo: ownerEmail}, - }, - }); - const rules = keyRules( - buildApprovalWorkflowRules({ - members: [buildMember(2)], - approvers: [buildApprover(1, {email: ownerEmail, approvalLimit: 1000, overLimitForwardsTo: removedEmail})], - isDefault: false, - }), - ); - - // When the owner checks whether a workflow needs fixing - // Then it does, since reports over the limit still route to the non-member - expect(hasApprovalWorkflowWithNonMemberApprover({policy, currentUserLogin: ownerEmail, rules, isMultipleApproversBetaEnabled: true})).toBe(true); - }); - - it('does not flag a workspace the user cannot fix', () => { - // Given the same broken workflow, seen by a member without write access to approvals, with approvals off, - // and with the workflows owned by a connected recruiting integration - const policy = buildPolicy({ - employeeList: { - [ownerEmail]: {email: ownerEmail, role: CONST.POLICY.ROLE.ADMIN, submitsTo: ownerEmail}, - '1@example.com': {email: '1@example.com', role: CONST.POLICY.ROLE.USER, submitsTo: removedEmail}, - '2@example.com': {email: '2@example.com', role: CONST.POLICY.ROLE.USER, submitsTo: ownerEmail}, - }, - }); - const approvalsOffPolicy = buildPolicy({approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL}); - const lockedPolicy = buildPolicy({ - connections: { - [CONST.POLICY.CONNECTIONS.NAME.MERGE_ATS]: { - config: { - integration: 'greenhouse', - approvalMode: CONST.MERGE.APPROVAL_MODE.BASIC, - approverField: CONST.MERGE.ATS_APPROVER_FIELD.RECRUITER, - finalApprover: null, - filters: null, - }, - }, - }, - }); - - // When each checks whether a workflow needs fixing - // Then none is flagged, since the Workflows page gives none of them an Edit button to fix it - expect( - hasApprovalWorkflowWithNonMemberApprover({policy: {...policy, role: CONST.POLICY.ROLE.USER}, currentUserLogin: '2@example.com', isMultipleApproversBetaEnabled: false}), - ).toBe(false); - expect(hasApprovalWorkflowWithNonMemberApprover({policy: approvalsOffPolicy, currentUserLogin: ownerEmail, isMultipleApproversBetaEnabled: false})).toBe(false); - expect(hasApprovalWorkflowWithNonMemberApprover({policy: lockedPolicy, currentUserLogin: ownerEmail, isMultipleApproversBetaEnabled: false})).toBe(false); - }); - }); - - describe('getNonMemberApproverError', () => { - it('only offers deleting the workflow when it is not the default one', () => { - // Given a flagged approver on a custom workflow and on the default workflow - // When the error copy is picked - // Then the default workflow, which can't be deleted, only asks for a new approver - const removedApprover = buildApprover(9, {isNotWorkspaceMember: true}); - expect(getNonMemberApproverError(removedApprover, false)).toBe('workflowsPage.approverNotWorkspaceMember'); - expect(getNonMemberApproverError(removedApprover, true)).toBe('workflowsPage.defaultWorkflowApproverNotWorkspaceMember'); - }); - - it('asks for a new additional approver when only the over-limit one left the workspace', () => { - // Given an approver who is a member, but whose additional approver for reports over the limit is not - const approver = buildApprover(1, {approvalLimit: 1000, overLimitForwardsTo: '9@example.com', isOverLimitForwardsToNotWorkspaceMember: true}); - - // When the error copy is picked - // Then it points at the additional approver rather than at the first approver - expect(getNonMemberApproverError(approver, false)).toBe('workflowsPage.overLimitApproverNotWorkspaceMember'); - }); - - it('returns no error when every route points at a member', () => { - // Given an approver with no flags - // When the error copy is picked - // Then there is nothing to fix - expect(getNonMemberApproverError(buildApprover(1), false)).toBeUndefined(); - }); - }); - describe('approval workflows owned by a connected integration', () => { const POLICY_ID = 'ats-policy'; diff --git a/tests/unit/WorkspaceDuplicateSelectFeaturesFormTest.tsx b/tests/unit/WorkspaceDuplicateSelectFeaturesFormTest.tsx deleted file mode 100644 index 364f0901cde8..000000000000 --- a/tests/unit/WorkspaceDuplicateSelectFeaturesFormTest.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import {act, render} from '@testing-library/react-native'; - -import OnyxListItemProvider from '@components/OnyxListItemProvider'; -import type {ConfirmButtonOptions, ListItem} from '@components/SelectionList/types'; - -import {toIndexMap} from '@libs/RuleUtils'; - -import WorkspaceDuplicateSelectFeaturesForm from '@pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm'; - -import {duplicateWorkspace} from '@userActions/Policy/Policy'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy} from '@src/types/onyx'; -import type Rule from '@src/types/onyx/Rule'; - -import React from 'react'; -import Onyx from 'react-native-onyx'; - -import createRandomPolicy from '../utils/collections/policies'; -import * as TestHelper from '../utils/TestHelper'; -import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; - -const POLICY_ID = '1'; -const TARGET_POLICY_ID = '2'; - -type CapturedSelectionListProps = { - data: ListItem[]; - onSelectRow: (item: ListItem) => void; - confirmButtonOptions: ConfirmButtonOptions; -}; -let mockSelectionListProps: CapturedSelectionListProps | undefined; - -// The list itself isn't under test, so it is replaced by a stub that captures the rows, the row handler and the Continue button. -jest.mock('@components/SelectionList', () => ({ - __esModule: true, - default: (props: CapturedSelectionListProps) => { - mockSelectionListProps = props; - return null; - }, -})); - -jest.mock('@react-navigation/native', () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const actualNav = jest.requireActual('@react-navigation/native'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return { - ...actualNav, - useRoute: () => ({params: {policyID: POLICY_ID}}), - }; -}); - -jest.mock('@libs/Navigation/Navigation', () => ({ - navigate: jest.fn(), - goBack: jest.fn(), - closeRHPFlow: jest.fn(), -})); - -jest.mock('@hooks/useConfirmModal', () => ({ - __esModule: true, - default: () => ({showConfirmModal: jest.fn(() => Promise.resolve({action: 'cancel'}))}), -})); - -// The form fires openDuplicatePolicyPage on mount and duplicateWorkspace on Continue. Both are stubbed so the test can read the parts the form sends. -jest.mock('@userActions/Policy/Policy', () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const actual = jest.requireActual('@userActions/Policy/Policy'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return { - ...actual, - openDuplicatePolicyPage: jest.fn(), - duplicateWorkspace: jest.fn(), - }; -}); - -const MERCHANT_RULE: Rule = { - scope: CONST.RULES.SCOPE.POLICY, - scopeID: POLICY_ID, - triggers: toIndexMap([CONST.RULES.TRIGGERS.CREATE_TRANSACTION]), - filters: {left: CONST.RULES.EXPENSE_DEFAULT.FIELD.MERCHANT, operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, right: 'Acme'}, - actions: toIndexMap([{name: CONST.RULES.ACTIONS.SET, field: CONST.RULES.EXPENSE_DEFAULT.FIELD.CATEGORY, value: 'Travel'}]), -}; - -/** A Control workspace whose only possible workspace-level rule is eReceipts, so the test decides whether the Rules row is shown. */ -function createSourcePolicy(hasWorkspaceRules: boolean): Policy { - return { - ...createRandomPolicy(Number(POLICY_ID), CONST.POLICY.TYPE.CORPORATE), - preventSelfApproval: false, - maxExpenseAmountNoReceipt: CONST.DISABLED_MAX_EXPENSE_VALUE, - maxExpenseAmount: CONST.DISABLED_MAX_EXPENSE_VALUE, - maxExpenseAge: CONST.DISABLED_MAX_EXPENSE_VALUE, - eReceipts: hasWorkspaceRules, - }; -} - -async function renderForm(hasWorkspaceRules: boolean) { - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, createSourcePolicy(hasWorkspaceRules)); - await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}merchantRule`, MERCHANT_RULE); - await Onyx.set(ONYXKEYS.RAM_ONLY_HAS_RULES_DATA_BEEN_FETCHED, true); - await Onyx.set(ONYXKEYS.DUPLICATE_WORKSPACE, {name: 'Duplicate', policyID: TARGET_POLICY_ID}); - await waitForBatchedUpdates(); - - render( - - - , - ); - await waitForBatchedUpdates(); -} - -function getRow(key: string): ListItem | undefined { - return mockSelectionListProps?.data.find((item) => item.keyForList === key); -} - -async function pressRow(key: string) { - const row = getRow(key); - expect(row).toBeDefined(); - if (!row) { - return; - } - act(() => { - mockSelectionListProps?.onSelectRow(row); - }); - await waitForBatchedUpdates(); -} - -function pressContinue() { - act(() => { - mockSelectionListProps?.confirmButtonOptions.onConfirm?.(); - }); -} - -function getSentParts(): Record | undefined { - return jest.mocked(duplicateWorkspace).mock.calls.at(0)?.[1].parts; -} - -describe('WorkspaceDuplicateSelectFeaturesForm Rules and Merchant rules', () => { - beforeAll(() => { - Onyx.init({keys: ONYXKEYS}); - }); - - beforeEach(() => { - // Without a fetch mock, background requests reach the real fetch and fail in the test environment. - global.fetch = TestHelper.createGlobalFetchMock(); - mockSelectionListProps = undefined; - }); - - afterEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdates(); - jest.clearAllMocks(); - }); - - it('deselects Merchant rules when Rules is deselected, so neither is copied', async () => { - // Given a source with workspace-level rules and a merchant rule, where every row starts selected - await renderForm(true); - expect(getRow('rules')?.isSelected).toBe(true); - expect(getRow('codingRules')?.isSelected).toBe(true); - - // When the admin deselects Rules and continues - await pressRow('rules'); - pressContinue(); - - // Then Merchant rules is deselected too, because merchant rules live under Rules and can't be copied without it - expect(getRow('codingRules')?.isSelected).toBe(false); - expect(getSentParts()).toMatchObject({expenses: false, codingRules: false}); - }); - - it('selects Rules when Merchant rules is selected while Rules is deselected', async () => { - // Given Rules and Merchant rules were both deselected - await renderForm(true); - await pressRow('rules'); - - // When the admin selects Merchant rules and continues - await pressRow('codingRules'); - pressContinue(); - - // Then Rules is selected too, so the merchant rules are copied along with the Rules feature they belong to - expect(getRow('rules')?.isSelected).toBe(true); - expect(getRow('codingRules')?.isSelected).toBe(true); - expect(getSentParts()).toMatchObject({expenses: true, codingRules: true}); - }); - - it('keeps Rules selected when only Merchant rules is deselected', async () => { - // Given every row starts selected - await renderForm(true); - - // When the admin deselects only Merchant rules and continues - await pressRow('codingRules'); - pressContinue(); - - // Then the workspace-level rules are still copied without the merchant rules, because Rules doesn't depend on Merchant rules - expect(getRow('rules')?.isSelected).toBe(true); - expect(getSentParts()).toMatchObject({expenses: true, codingRules: false}); - }); - - it('copies merchant rules on their own when the source has no workspace-level rules', async () => { - // Given a source with a merchant rule but no workspace-level rules, so the Rules row is hidden - await renderForm(false); - expect(getRow('rules')).toBeUndefined(); - expect(getRow('codingRules')?.isSelected).toBe(true); - - // When the admin continues - pressContinue(); - - // Then the merchant rules are still copied, because there is no Rules row they could depend on - expect(getSentParts()).toMatchObject({expenses: false, codingRules: true}); - }); -}); diff --git a/tests/unit/applyColumnWidthOverridesTest.ts b/tests/unit/applyColumnWidthOverridesTest.ts deleted file mode 100644 index 1aad17700536..000000000000 --- a/tests/unit/applyColumnWidthOverridesTest.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type {ColumnWidthOverrideColumn} from '@components/Table/columnResize/applyColumnWidthOverrides'; -import applyColumnWidthOverrides from '@components/Table/columnResize/applyColumnWidthOverrides'; - -/** A headed column sized from its content, which is the only kind whose edge drags. */ -function contentColumn(key: string): ColumnWidthOverrideColumn { - return {key, label: key, hasDeclaredWidth: false}; -} - -/** A headed column that declared a width of its own, e.g. a status or a switch. */ -function fixedColumn(key: string): ColumnWidthOverrideColumn { - return {key, label: key, hasDeclaredWidth: true}; -} - -/** A column with no heading, e.g. a trailing arrow. */ -function headlessColumn(key: string): ColumnWidthOverrideColumn { - return {key, label: '', hasDeclaredWidth: false}; -} - -describe('applyColumnWidthOverrides', () => { - it('applies a stored width to its column and paints it before any drag', () => { - // Given three columns where the user widened the first by 60px - const columns = [contentColumn('name'), contentColumn('email'), contentColumn('role')]; - const baseColumnWidths = {name: 200, email: 200, role: 200}; - - // When the stored width is applied - const {columnWidths, columnWidthValues} = applyColumnWidthOverrides({columns, baseColumnWidths, columnWidthOverrides: {name: 260}}); - - // Then only that column changes, and its CSS value falls back to the stored width so it paints at 260px with no drag running - expect(columnWidths).toEqual({name: 260, email: 200, role: 200}); - expect(columnWidthValues.at(0)).toContain('260px'); - }); - - it('rounds a stored width to whole px', () => { - // Given a stored width with a fraction - const columns = [contentColumn('name'), contentColumn('role')]; - - // When it is applied - const {columnWidths} = applyColumnWidthOverrides({columns, baseColumnWidths: {name: 200, role: 120}, columnWidthOverrides: {name: 240.6}}); - - // Then the column gets whole px, so its grid track never lands on a sub-pixel - expect(columnWidths).toEqual({name: 241, role: 120}); - }); - - it('ignores a width stored for a column that declared its own', () => { - // Given a stored width for a column that has since declared a fixed width - const columns = [fixedColumn('status')]; - - // When it is applied - const {columnWidths} = applyColumnWidthOverrides({columns, baseColumnWidths: {status: 80}, columnWidthOverrides: {status: 300}}); - - // Then the declared width wins, since that column can no longer be dragged and the stored width is stale - expect(columnWidths).toEqual({status: 80}); - }); - - it('gives an edge only to headed columns sized from their content', () => { - // Given a table mixing content-sized, fixed and headless columns - const columns = [contentColumn('name'), fixedColumn('status'), contentColumn('email'), headlessColumn('arrow')]; - const baseColumnWidths = {name: 200, status: 80, email: 200, arrow: 40}; - - // When the resizable columns are worked out - const {resizableColumnKeys} = applyColumnWidthOverrides({columns, baseColumnWidths, columnWidthOverrides: undefined}); - - // Then only the content-sized ones get an edge, since fixed and headless columns hold fixed-size content - expect(resizableColumnKeys).toEqual(['name', 'email']); - }); -}); diff --git a/tests/unit/calculateDynamicColumnWidthsTest.ts b/tests/unit/calculateDynamicColumnWidthsTest.ts index 7376ecc22c39..34b044fe53a1 100644 --- a/tests/unit/calculateDynamicColumnWidthsTest.ts +++ b/tests/unit/calculateDynamicColumnWidthsTest.ts @@ -1,5 +1,5 @@ import type {DynamicColumnConstraints} from '@components/Table/calculateDynamicColumnWidths'; -import calculateDynamicColumnWidths, {distributeEqualWidths} from '@components/Table/calculateDynamicColumnWidths'; +import calculateDynamicColumnWidths from '@components/Table/calculateDynamicColumnWidths'; /** * A free-text column: squeezable down to `minWidth`, and uncapped, which is what the hook passes when a column doesn't @@ -229,22 +229,3 @@ describe('calculateDynamicColumnWidths', () => { }); }); }); - -describe('distributeEqualWidths', () => { - // Given no columns to share the width - // When equal widths are distributed - // Then nothing is produced, so a caller can't read a width for a column that isn't there - it('produces nothing for no columns', () => { - expect(distributeEqualWidths(0, 900)).toEqual([]); - }); - - // Given a width that does not divide evenly - // When equal widths are distributed - // Then the remainder goes to the first column, so the columns still sum exactly to the available width - it('hands the rounding remainder to the first column', () => { - const widths = distributeEqualWidths(3, 901); - - expect(widths).toEqual([301, 300, 300]); - expect(sumOf(widths)).toBe(901); - }); -}); diff --git a/tests/unit/columnWidthExpressionsTest.ts b/tests/unit/columnWidthExpressionsTest.ts deleted file mode 100644 index e7fd7b9192c5..000000000000 --- a/tests/unit/columnWidthExpressionsTest.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {getColumnWidthVariableName, getColumnsWidthExpression} from '@components/Table/columnResize/columnWidthExpressions'; - -describe('columnWidthExpressions', () => { - // Given a column key holding characters CSS does not allow in a custom property name - // When it is turned into a custom property name - // Then each of them is replaced, because a name CSS cannot parse is a width nothing can read - it('replaces characters that are not valid in a custom property name', () => { - expect(getColumnWidthVariableName('tax.rate value')).toBe('--table-column-width-tax_rate_value'); - }); - - // Given a table with no columns to sum - // When its width is built - // Then it is still a valid expression floored at the given floor, since an empty `calc()` would drop the whole width - it('floors a table with no columns at the floor it was given', () => { - expect(getColumnsWidthExpression([], 64, '100%')).toBe('max(100%, 64px)'); - }); -}); diff --git a/tests/unit/components/ExpenseFieldDropdown.test.tsx b/tests/unit/components/ExpenseFieldDropdown.test.tsx index dfca3286fb12..edc22989b19e 100644 --- a/tests/unit/components/ExpenseFieldDropdown.test.tsx +++ b/tests/unit/components/ExpenseFieldDropdown.test.tsx @@ -1,6 +1,6 @@ import {act, fireEvent, render, screen} from '@testing-library/react-native'; -import type {ExpenseFieldDropdownHandle, ExpenseFieldDropdownRenderProps} from '@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown'; +import type {ExpenseFieldDropdownRenderProps} from '@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown'; import ExpenseFieldDropdown from '@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown'; import Text from '@components/Text'; @@ -10,7 +10,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {ReactNode} from 'react'; -import React, {createRef} from 'react'; +import React from 'react'; import {View} from 'react-native'; import Onyx from 'react-native-onyx'; @@ -85,51 +85,6 @@ describe('ExpenseFieldDropdown', () => { />, ); - describe('open()', () => { - const renderFieldWithRef = (shouldOpenInDropdown: boolean, onPress: () => void) => { - const ref = createRef(); - render( - , - ); - return ref; - }; - - it('opens the list in the container without a press', () => { - // Given a field whose list can open in place, opened by its parent rather than by the user - const onPress = jest.fn(); - mockRowAt(ROW_TOP); - const ref = renderFieldWithRef(true, onPress); - - // When the parent opens it - act(() => ref.current?.open()); - - // Then the list opens in the container and the full-page selector is never reached - expect(screen.getByText(DROPDOWN_TEXT)).toBeOnTheScreen(); - expect(onPress).not.toHaveBeenCalled(); - }); - - it('falls back to the full-page selector when the list cannot open in place', () => { - // Given a field whose list cannot open in place, e.g. because there is no workspace to list categories from - const onPress = jest.fn(); - mockRowAt(ROW_TOP); - const ref = renderFieldWithRef(false, onPress); - - // When the parent opens it - act(() => ref.current?.open()); - - // Then it does what a press would and hands over to the full-page selector, never mounting the list - expect(onPress).toHaveBeenCalledTimes(1); - expect(renderCount).toBe(0); - }); - }); - it('does not mount the list until the row is pressed', () => { // Given a field whose list opens in the container anchored to the row mockRowAt(ROW_TOP); diff --git a/tests/unit/components/MoneyRequestConfirmationList/CategoryField.test.tsx b/tests/unit/components/MoneyRequestConfirmationList/CategoryField.test.tsx index 42c6d56662af..ebcc9f3e4f39 100644 --- a/tests/unit/components/MoneyRequestConfirmationList/CategoryField.test.tsx +++ b/tests/unit/components/MoneyRequestConfirmationList/CategoryField.test.tsx @@ -1,17 +1,11 @@ -import {fireEvent, render, screen, waitFor} from '@testing-library/react-native'; +import {render, screen, waitFor} from '@testing-library/react-native'; import ConfirmationFieldsProvider from '@components/MoneyRequestConfirmationFields/Provider'; import CategoryField from '@components/MoneyRequestConfirmationList/sections/CategoryField'; -import type {ExpenseFieldDropdownHandle} from '@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown'; -import ExpenseFormLayoutContext, {dropdownRowsExpenseFormLayout} from '@components/MoneyRequestConfirmationList/sections/ExpenseFormLayoutContext'; - -import Navigation from '@libs/Navigation/Navigation'; -import type {CancelHandle} from '@libs/Navigation/TransitionTracker'; import CONST from '@src/CONST'; import type {IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import type {Policy} from '@src/types/onyx'; import React from 'react'; @@ -33,53 +27,6 @@ jest.mock('@components/MenuItemWithTopDescription', () => { jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); jest.mock('@hooks/useThemeStyles', () => () => ({})); -// Only the anchored dropdowns beta, so the tests that render the plain menu row keep every other beta off. -jest.mock('@hooks/usePermissions', () => () => ({ - isBetaEnabled: (beta: string) => beta === jest.requireActual<{default: typeof CONST}>('@src/CONST').default.BETAS.ANCHORED_FIELD_DROPDOWNS, -})); - -let mockIsFocused = true; -// Mirrors `useFocusEffect`: runs the effect while the screen is focused, and again whenever the effect changes. -jest.mock('@react-navigation/native', () => { - const {useEffect} = jest.requireActual('react'); - return { - ...jest.requireActual>('@react-navigation/native'), - useIsFocused: () => mockIsFocused, - useFocusEffect: (effect: () => void | (() => void)) => { - const isFocused = mockIsFocused; - useEffect(() => (isFocused ? effect() : undefined), [effect, isFocused]); - }, - }; -}); - -jest.mock('@libs/Navigation/Navigation', () => ({navigate: jest.fn()})); - -// Run the post-transition work right away, since no screen actually closes in a unit test. -jest.mock('@libs/Navigation/TransitionTracker', () => ({ - runAfterTransitions: ({callback}: {callback: () => void}): CancelHandle => { - callback(); - return {cancel: () => {}}; - }, -})); - -const mockOpenDropdown = jest.fn(); -let mockShouldOpenInDropdown: boolean | undefined; -jest.mock('@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown', () => { - const {useImperativeHandle} = jest.requireActual('react'); - const {Pressable, Text} = jest.requireActual void; role?: string}>>>('react-native'); - return ({name, onPress, shouldOpenInDropdown, ref}: {name: string; onPress: () => void; shouldOpenInDropdown: boolean; ref?: React.Ref}) => { - mockShouldOpenInDropdown = shouldOpenInDropdown; - useImperativeHandle(ref, () => ({open: mockOpenDropdown})); - return ( - - {name} - - ); - }; -}); const TRANSACTION_ID = '1'; const REPORT_ID = 'reportID'; @@ -120,8 +67,6 @@ describe('CategoryField', () => { }); beforeEach(async () => { - // `jest.clearAllMocks()` does not touch this, so a value left by an earlier render could satisfy a later assertion. - mockShouldOpenInDropdown = undefined; await Onyx.clear(); await waitForBatchedUpdates(); }); @@ -209,232 +154,6 @@ describe('CategoryField', () => { expect(screen.queryByText('common.automatic')).toBeNull(); }); - describe('after an upgrade started from the category row', () => { - const renderDropdownCategoryField = ({ - shouldNavigateToUpgradePath, - isPolicyLoaded = !shouldNavigateToUpgradePath, - didConfirm = false, - }: { - shouldNavigateToUpgradePath: boolean; - /** False while the workspace the upgrade created has not reached Onyx yet */ - isPolicyLoaded?: boolean; - didConfirm?: boolean; - }) => ( - - - - - - ); - - beforeEach(() => { - mockIsFocused = true; - jest.clearAllMocks(); - }); - - it('opens the category list in place once the user is back with a workspace', async () => { - // Given an expense with no workspace, whose category row sends the user to upgrade - await givenManualExpense(); - const {rerender} = render(renderDropdownCategoryField({shouldNavigateToUpgradePath: true})); - - // When the user presses the row, upgrades on the upgrade screen, and comes back to the form - fireEvent.press(screen.getByText('common.category')); - mockIsFocused = false; - rerender(renderDropdownCategoryField({shouldNavigateToUpgradePath: false})); - mockIsFocused = true; - rerender(renderDropdownCategoryField({shouldNavigateToUpgradePath: false})); - - // Then the upgrade screen is told to return to the form rather than open the full-page Category step, - // and the form opens the anchored list itself, so the user lands where they were heading - expect(jest.mocked(Navigation.navigate).mock.calls.at(0)?.at(0)).toContain('shouldReturnToConfirmation=true'); - await waitFor(() => { - expect(mockOpenDropdown).toHaveBeenCalledTimes(1); - }); - }); - - it('does not open the category list when the user backs out of the upgrade', async () => { - // Given an expense with no workspace, whose category row sends the user to upgrade - await givenManualExpense(); - const {rerender} = render(renderDropdownCategoryField({shouldNavigateToUpgradePath: true})); - - // When the user presses the row but closes the upgrade screen without upgrading - fireEvent.press(screen.getByText('common.category')); - mockIsFocused = false; - rerender(renderDropdownCategoryField({shouldNavigateToUpgradePath: true})); - mockIsFocused = true; - rerender(renderDropdownCategoryField({shouldNavigateToUpgradePath: true})); - await waitForBatchedUpdates(); - - // Then nothing opens, because there is still no workspace to pick a category from - expect(mockOpenDropdown).not.toHaveBeenCalled(); - }); - - it('does not open anything once the expense is being confirmed', async () => { - // Given an expense with no workspace, whose category row sends the user to upgrade - await givenManualExpense(); - const {rerender} = render(renderDropdownCategoryField({shouldNavigateToUpgradePath: true})); - - // When the user upgrades, comes back, and presses Submit before the list gets to open - fireEvent.press(screen.getByText('common.category')); - mockIsFocused = false; - rerender(renderDropdownCategoryField({shouldNavigateToUpgradePath: false})); - mockIsFocused = true; - rerender(renderDropdownCategoryField({shouldNavigateToUpgradePath: false, didConfirm: true})); - await waitForBatchedUpdates(); - - // Then the list is not asked to open: the row would refuse and push the full-page Category step over a - // confirming expense instead - expect(mockOpenDropdown).not.toHaveBeenCalled(); - expect(jest.mocked(Navigation.navigate)).toHaveBeenCalledTimes(1); - }); - - it('forgets the upgrade once the user leaves the form again', async () => { - // Given an expense with no workspace, whose category row sends the user to upgrade - await givenManualExpense(); - const {rerender} = render(renderDropdownCategoryField({shouldNavigateToUpgradePath: true})); - - // When the user upgrades and comes back before the new workspace has landed, then visits another step - // and returns once it has - fireEvent.press(screen.getByText('common.category')); - mockIsFocused = false; - rerender(renderDropdownCategoryField({shouldNavigateToUpgradePath: false, isPolicyLoaded: false})); - mockIsFocused = true; - rerender(renderDropdownCategoryField({shouldNavigateToUpgradePath: false, isPolicyLoaded: false})); - mockIsFocused = false; - rerender(renderDropdownCategoryField({shouldNavigateToUpgradePath: false})); - mockIsFocused = true; - rerender(renderDropdownCategoryField({shouldNavigateToUpgradePath: false})); - await waitForBatchedUpdates(); - - // Then the list stays closed: the return from the upgrade is the only focus that may open it, not a - // later one the user never pressed Category for - expect(mockOpenDropdown).not.toHaveBeenCalled(); - }); - }); - - it('opens the list in place for a draft workspace, whose categories only exist as drafts', async () => { - // Given an expense on the draft workspace "Submit to my employer" creates, which stores its categories only as drafts - await givenManualExpense(); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT}${enabledPolicy.id}`, {Travel: {name: 'Travel', enabled: true}}); - await waitForBatchedUpdates(); - - // When the confirmation form renders the category row for that flow, which submits rather than creates, so - // nothing but the draft categories can make the row open in place - render( - - - - - , - ); - - // Then the row opens its list in place, because the draft categories count as categories to pick from - await waitFor(() => { - expect(mockShouldOpenInDropdown).toBe(true); - }); - }); - - describe('while creating an expense', () => { - const renderCreateCategoryField = ({policy, shouldSelectPolicy}: {policy: Policy | undefined; shouldSelectPolicy: boolean}) => ( - - - - - - ); - - beforeEach(() => { - mockIsFocused = true; - jest.clearAllMocks(); - }); - - it('opens the list in place even before the workspace categories are loaded', async () => { - // Given a workspace whose categories have not reached Onyx yet, which the list loads on its own - await givenManualExpense(); - - // When the confirmation form renders the category row - render(renderCreateCategoryField({policy: enabledPolicy, shouldSelectPolicy: false})); - - // Then the row still opens its list in place rather than the full-page Category step - await waitFor(() => { - expect(mockShouldOpenInDropdown).toBe(true); - }); - }); - - it('opens the list in place once the user is back from picking a default workspace', async () => { - // Given a member of several workspaces who has not picked a default one yet - await givenManualExpense(); - const {rerender} = render(renderCreateCategoryField({policy: undefined, shouldSelectPolicy: true})); - - // When the user presses the row, picks a workspace, and comes back to the form - fireEvent.press(screen.getByText('common.category')); - mockIsFocused = false; - rerender(renderCreateCategoryField({policy: enabledPolicy, shouldSelectPolicy: false})); - mockIsFocused = true; - rerender(renderCreateCategoryField({policy: enabledPolicy, shouldSelectPolicy: false})); - - // Then the workspace picker is opened without a destination, so it returns here, and the list opens in place - expect(jest.mocked(Navigation.navigate)).toHaveBeenCalledWith(ROUTES.SET_DEFAULT_WORKSPACE.getRoute()); - await waitFor(() => { - expect(mockOpenDropdown).toHaveBeenCalledTimes(1); - }); - }); - }); - it('does not promise an automatic category on an invoice', async () => { // Given a manual invoice on a workspace that categorizes new expenses await givenManualExpense(); diff --git a/tests/unit/components/NavigationAwareCameraTest.tsx b/tests/unit/components/NavigationAwareCameraTest.tsx deleted file mode 100644 index 671afe33beef..000000000000 --- a/tests/unit/components/NavigationAwareCameraTest.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import {render} from '@testing-library/react-native'; - -import NavigationAwareCamera from '@pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/Camera'; - -import type {CameraDevice} from 'react-native-vision-camera'; - -import React from 'react'; - -const mockUseIsFocused = jest.fn(); -jest.mock('@react-navigation/native', () => ({ - useIsFocused: () => mockUseIsFocused(), -})); - -const mockVisionCamera = jest.fn(); -jest.mock('react-native-vision-camera', () => ({ - Camera: (props: {isActive: boolean}) => { - mockVisionCamera(props); - return null; - }, -})); - -// The wrapper passes the device straight through to a camera that is stubbed out here, so it only has to -// satisfy the prop type. -// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -const DEVICE = {id: 'back'} as unknown as CameraDevice; - -/** The session state the camera ends up in for one combination of focus and the two overrides. */ -function renderIsActive({isFocused, forceInactive, shouldStayActiveWhenBlurred}: {isFocused: boolean; forceInactive?: boolean; shouldStayActiveWhenBlurred?: boolean}): boolean { - mockUseIsFocused.mockReturnValue(isFocused); - mockVisionCamera.mockClear(); - - render( - , - ); - - return !!mockVisionCamera.mock.calls.at(0)?.at(0)?.isActive; -} - -describe('NavigationAwareCamera', () => { - it('runs the session while the scan tab is focused and nothing has forced it shut', () => { - expect(renderIsActive({isFocused: true})).toBe(true); - expect(renderIsActive({isFocused: false})).toBe(false); - }); - - it('keeps the session running once a capture navigates away and the screen blurs', () => { - // Closing it here cancels an in-flight `takePhoto` with "Camera is closed.", which is the whole - // reason a full-resolution capture can outlive the shutter. - expect(renderIsActive({isFocused: false, shouldStayActiveWhenBlurred: true})).toBe(true); - }); - - it('lets forceInactive win over a capture that is still running, since it is the harder stop', () => { - expect(renderIsActive({isFocused: true, forceInactive: true})).toBe(false); - expect(renderIsActive({isFocused: false, forceInactive: true, shouldStayActiveWhenBlurred: true})).toBe(false); - expect(renderIsActive({isFocused: true, forceInactive: true, shouldStayActiveWhenBlurred: true})).toBe(false); - }); -}); diff --git a/tests/unit/components/Table/TableSemanticContainerTest.tsx b/tests/unit/components/Table/TableSemanticContainerTest.tsx index dbd993330736..d4216c3f223f 100644 --- a/tests/unit/components/Table/TableSemanticContainerTest.tsx +++ b/tests/unit/components/Table/TableSemanticContainerTest.tsx @@ -76,9 +76,7 @@ function renderContainer( rendersBodyWhenEmpty={rendersBodyWhenEmpty} shouldUseDynamicColumns={shouldUseDynamicColumns} scrollWidth={undefined} - measureWidthRef={undefined} onLayout={onLayout} - onScopeElement={undefined} > {children} , @@ -180,9 +178,7 @@ describe('TableSemanticContainer', () => { rendersBodyWhenEmpty={false} shouldUseDynamicColumns={false} scrollWidth={undefined} - measureWidthRef={undefined} onLayout={undefined} - onScopeElement={undefined} > @@ -221,9 +217,7 @@ describe('TableSemanticContainer', () => { rendersBodyWhenEmpty={false} shouldUseDynamicColumns scrollWidth={undefined} - measureWidthRef={undefined} onLayout={isWideLayout ? onLayout : undefined} - onScopeElement={undefined} > @@ -241,32 +235,4 @@ describe('TableSemanticContainer', () => { expect(mockTrackedFilterBarUnmount).not.toHaveBeenCalled(); expect(mockTrackedFilterBarMount).toHaveBeenCalledTimes(1); }); - - it('hands the measured node to the measure ref, so the table is measured before its first paint', () => { - // Given a resizable table with a measure ref - const measureWidthRef = jest.fn(); - - // When it mounts - render( - - - - , - ); - - // Then the ref receives the mounted node that `onLayout` measures - expect(measureWidthRef).toHaveBeenCalledWith(expect.anything()); - }); }); diff --git a/tests/unit/components/Table/buildTableListDataTest.ts b/tests/unit/components/Table/buildTableListDataTest.ts deleted file mode 100644 index 522e9fbdae97..000000000000 --- a/tests/unit/components/Table/buildTableListDataTest.ts +++ /dev/null @@ -1,72 +0,0 @@ -import {getTableListMetadata} from '@components/Table/buildTableListData'; - -import CONST from '@src/CONST'; - -const {COLUMN_HEADER_PLACEMENT} = CONST.TABLES; - -const pageHeader = 'Page header'; - -describe('getTableListMetadata', () => { - // Given a table with a page header whose columns fit and can't be resized - // When the column header is placed - // Then it rides FlashList's sticky-row overlay, which is the cheapest way to keep it stuck - it('places the column header in the sticky row when there is no horizontal scroller', () => { - const metadata = getTableListMetadata({ - listHeaderElement: pageHeader, - hasColumnHeaderElement: true, - hasRows: true, - isColumnHeaderHiddenInNarrowLayout: false, - hasHorizontalScrollContainer: false, - }); - - expect(metadata.columnHeaderPlacement).toBe(COLUMN_HEADER_PLACEMENT.STICKY_ROW); - expect(metadata.syntheticRowsBeforeData).toBe(1); - }); - - // Given a table with a page header that sits in a horizontal scroller, which every resizable table does even while its columns fit - // When the column header is placed - // Then it goes in the list header so it scrolls sideways with the columns, and doesn't move once a drag starts overflowing them - it('places the column header in the list header when there is a horizontal scroller', () => { - const metadata = getTableListMetadata({ - listHeaderElement: pageHeader, - hasColumnHeaderElement: true, - hasRows: true, - isColumnHeaderHiddenInNarrowLayout: false, - hasHorizontalScrollContainer: true, - }); - - expect(metadata.columnHeaderPlacement).toBe(COLUMN_HEADER_PLACEMENT.LIST_HEADER); - expect(metadata.syntheticRowsBeforeData).toBe(0); - }); - - // Given a table without a page header - // When the column header is placed, with or without a horizontal scroller - // Then it stays outside the list either way, since the ancestor scroller already carries it with the columns - it('keeps the column header outside the list when there is no page header', () => { - for (const hasHorizontalScrollContainer of [false, true]) { - const metadata = getTableListMetadata({ - hasColumnHeaderElement: true, - hasRows: true, - isColumnHeaderHiddenInNarrowLayout: false, - hasHorizontalScrollContainer, - }); - - expect(metadata.columnHeaderPlacement).toBe(COLUMN_HEADER_PLACEMENT.OUTSIDE_LIST); - } - }); - - // Given a table with a horizontal scroller but no rows - // When the column header is placed - // Then there is none, since a header over an empty table only labels nothing - it('renders no column header when there are no rows', () => { - const metadata = getTableListMetadata({ - listHeaderElement: pageHeader, - hasColumnHeaderElement: true, - hasRows: false, - isColumnHeaderHiddenInNarrowLayout: false, - hasHorizontalScrollContainer: true, - }); - - expect(metadata.columnHeaderPlacement).toBe(COLUMN_HEADER_PLACEMENT.NONE); - }); -}); diff --git a/tests/unit/components/Table/useColumnResizeTest.ts b/tests/unit/components/Table/useColumnResizeTest.ts deleted file mode 100644 index e253a428d273..000000000000 --- a/tests/unit/components/Table/useColumnResizeTest.ts +++ /dev/null @@ -1,285 +0,0 @@ -import {act, renderHook} from '@testing-library/react-native'; - -import {RESIZE_INDICATOR_OPACITY_VARIABLE, getColumnWidthVariableName} from '@components/Table/columnResize/columnWidthExpressions'; -import type UseColumnResize from '@components/Table/columnResize/useColumnResize'; -import type {UseColumnResizeParams} from '@components/Table/columnResize/useColumnResize/types'; - -import {setTableColumnWidth} from '@libs/actions/TableColumnWidths'; - -import type React from 'react'; - -// Jest resolves the native no-op, so the web implementation is loaded by its file name. -const {default: useColumnResize} = jest.requireActual<{default: typeof UseColumnResize}>('@components/Table/columnResize/useColumnResize/index.ts'); - -// Jest resolves the native cursor styles, which are empty, so the web ones are loaded by their file name too. -jest.mock('@styles/utils/cursor', () => jest.requireActual>('@styles/utils/cursor/index.ts')); - -jest.mock('@libs/actions/TableColumnWidths', () => ({ - setTableColumnWidth: jest.fn(), -})); - -const COLUMN_RESIZING_ID = 'testTable'; - -const NAME_COLUMN_KEY = 'name'; - -const resolvedColumnWidths = {name: 200, email: 200, role: 200}; - -type PointerEventInit = {clientX: number; button?: number}; - -/** A pointer event carrying only what the hook reads, aimed at the given handle. */ -function createPointerEvent(handleElement: HTMLDivElement, {clientX, button = 0}: PointerEventInit): React.PointerEvent { - const event = {button, clientX, clientY: 0, pointerId: 1, currentTarget: handleElement, preventDefault: jest.fn(), stopPropagation: jest.fn()}; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the hook only reads the fields above - return event as unknown as React.PointerEvent; -} - -/** Renders the hook with a scope element and one handle inside it, the way the table mounts them. */ -function renderColumnResize(params?: Partial) { - const scopeElement = document.createElement('div'); - const handleElement = document.createElement('div'); - - scopeElement.appendChild(handleElement); - // jsdom has no pointer capture. - const setPointerCapture = jest.fn(); - - handleElement.setPointerCapture = setPointerCapture; - handleElement.releasePointerCapture = jest.fn(); - handleElement.hasPointerCapture = jest.fn(() => true); - - const initialProps: UseColumnResizeParams = { - columnResizingID: COLUMN_RESIZING_ID, - resizableColumnKeys: [NAME_COLUMN_KEY], - resolvedColumnWidths, - columnGap: 12, - ...params, - }; - const hook = renderHook((props: UseColumnResizeParams) => useColumnResize(props), {initialProps}); - - hook.result.current?.setScopeElement(scopeElement); - - const getHandleProps = () => { - const controller = hook.result.current; - - if (!controller) { - throw new Error('Expected the hook to return a controller'); - } - - const handleProps = controller.getHandleProps(NAME_COLUMN_KEY); - - if (!handleProps) { - throw new Error('Expected the column to be resizable'); - } - - return handleProps; - }; - - const readWidth = (columnKey: string) => scopeElement.style.getPropertyValue(getColumnWidthVariableName(columnKey)); - - return {...hook, initialProps, scopeElement, handleElement, setPointerCapture, getHandleProps, readWidth}; -} - -describe('useColumnResize', () => { - beforeEach(() => { - jest.clearAllMocks(); - document.body.style.cursor = ''; - }); - - it('returns no controller when resizing is off', () => { - // Given a table with no resizing ID, which is how native, narrow layouts and tables that didn't opt in render it - const params: UseColumnResizeParams = {columnResizingID: undefined, resizableColumnKeys: [NAME_COLUMN_KEY], resolvedColumnWidths, columnGap: 12}; - - // When the hook runs - const {result} = renderHook(() => useColumnResize(params)); - - // Then it hands back no controller, so the header renders no handles - expect(result.current).toBeUndefined(); - }); - - it('returns a controller before the columns are measured', () => { - // Given a resizable table on its first render, before layout, when no column is resizable yet - const params: UseColumnResizeParams = {columnResizingID: COLUMN_RESIZING_ID, resizableColumnKeys: [], resolvedColumnWidths: {}, columnGap: 12}; - - // When the hook runs - const {result} = renderHook(() => useColumnResize(params)); - - // Then it still hands back a controller, so the scope element renders from the start and the header and list - // don't remount once the table is measured, while no column gets a handle yet - expect(result.current).toBeDefined(); - expect(result.current?.getHandleProps(NAME_COLUMN_KEY)).toBeUndefined(); - }); - - it('paints a drag onto the scope and stores the dragged column on release', () => { - // Given a 200px column with two 200px columns after it - const {handleElement, getHandleProps, readWidth} = renderColumnResize(); - - // When its edge is dragged 60px right - act(() => { - getHandleProps().onPointerDown?.(createPointerEvent(handleElement, {clientX: 100})); - getHandleProps().onPointerMove?.(createPointerEvent(handleElement, {clientX: 160})); - }); - - // Then the width is painted straight onto the scope without a React render, and later columns keep their widths - expect(readWidth('name')).toBe('260px'); - expect(readWidth('email')).toBe(''); - expect(readWidth('role')).toBe(''); - expect(document.body.style.cursor).toBe('col-resize'); - - // When the pointer is released - act(() => { - getHandleProps().onPointerUp?.(createPointerEvent(handleElement, {clientX: 160})); - }); - - // Then the dragged column is stored, once - expect(setTableColumnWidth).toHaveBeenCalledTimes(1); - expect(setTableColumnWidth).toHaveBeenCalledWith(COLUMN_RESIZING_ID, 'name', 260); - expect(document.body.style.cursor).toBe(''); - }); - - it('stores nothing and clears the scope when a drag ends where it started', () => { - // Given a column being dragged - const {handleElement, getHandleProps, readWidth} = renderColumnResize(); - - act(() => { - getHandleProps().onPointerDown?.(createPointerEvent(handleElement, {clientX: 100})); - getHandleProps().onPointerMove?.(createPointerEvent(handleElement, {clientX: 160})); - }); - - // When the pointer comes back to where it started and is released - act(() => { - getHandleProps().onPointerMove?.(createPointerEvent(handleElement, {clientX: 100})); - getHandleProps().onPointerUp?.(createPointerEvent(handleElement, {clientX: 100})); - }); - - // Then nothing is stored, and the scope is cleared since no render follows to clear it - expect(setTableColumnWidth).not.toHaveBeenCalled(); - expect(readWidth('name')).toBe(''); - }); - - it('stores the width when the pointer capture is lost mid-drag', () => { - // Given a column being dragged 40px right - const {handleElement, getHandleProps} = renderColumnResize(); - - act(() => { - getHandleProps().onPointerDown?.(createPointerEvent(handleElement, {clientX: 100})); - getHandleProps().onPointerMove?.(createPointerEvent(handleElement, {clientX: 140})); - }); - - // When the browser takes the pointer capture back, e.g. because the window lost focus - act(() => { - getHandleProps().onLostPointerCapture?.(createPointerEvent(handleElement, {clientX: 140})); - }); - - // Then the drag ends the same way a release does, so the width the user already sees is kept - expect(setTableColumnWidth).toHaveBeenCalledTimes(1); - expect(setTableColumnWidth).toHaveBeenCalledWith(COLUMN_RESIZING_ID, 'name', 240); - expect(document.body.style.cursor).toBe(''); - }); - - it('shows the edge line only while dragging, not on hover', () => { - // Given a resizable column - const {handleElement, getHandleProps} = renderColumnResize(); - const readLineOpacity = () => handleElement.style.getPropertyValue(RESIZE_INDICATOR_OPACITY_VARIABLE); - - // When its edge is pressed - act(() => { - getHandleProps().onPointerDown?.(createPointerEvent(handleElement, {clientX: 100})); - }); - - // Then the tall line appears, so the user sees which edge is moving - expect(readLineOpacity()).toBe('1'); - - // When the pointer is released - act(() => { - getHandleProps().onPointerUp?.(createPointerEvent(handleElement, {clientX: 100})); - }); - - // Then the line goes away, even with the pointer still over the edge - expect(readLineOpacity()).toBe('0'); - }); - - it('keeps a dragged width above its floor', () => { - // Given a 200px column that may not shrink below 180px - const {handleElement, getHandleProps, readWidth} = renderColumnResize({dragMinWidths: {[NAME_COLUMN_KEY]: 180}}); - - // When its edge is dragged far past the left of the window - act(() => { - getHandleProps().onPointerDown?.(createPointerEvent(handleElement, {clientX: 100})); - getHandleProps().onPointerMove?.(createPointerEvent(handleElement, {clientX: -1000})); - }); - - // Then it stops at its floor, so the last column can't pull the row in from the table's edge - expect(readWidth(NAME_COLUMN_KEY)).toBe('180px'); - - // When the same drag carries on far past the right - act(() => { - getHandleProps().onPointerMove?.(createPointerEvent(handleElement, {clientX: 5000})); - }); - - // Then it follows the pointer with no upper bound, since the table scrolls to reach later columns - expect(readWidth(NAME_COLUMN_KEY)).toBe('5100px'); - }); - - it('ignores the secondary button', () => { - // Given a resizable column - const {handleElement, setPointerCapture, getHandleProps} = renderColumnResize(); - - // When its edge is pressed with the secondary button, which opens a context menu - act(() => { - getHandleProps().onPointerDown?.(createPointerEvent(handleElement, {clientX: 100, button: 2})); - }); - - // Then no drag starts, so the context menu isn't fighting a captured pointer - expect(setPointerCapture).not.toHaveBeenCalled(); - expect(document.body.style.cursor).toBe(''); - }); - - it('resets the cursor when unmounted mid-drag', () => { - // Given a column being dragged, which puts the resize cursor on the whole page - const columnResize = renderColumnResize(); - const {handleElement, getHandleProps} = columnResize; - - act(() => { - getHandleProps().onPointerDown?.(createPointerEvent(handleElement, {clientX: 100})); - }); - - expect(document.body.style.cursor).toBe('col-resize'); - - // When the table unmounts, e.g. because the user navigated away mid-drag - columnResize.unmount(); - - // Then the resize cursor doesn't stay stuck on the page - expect(document.body.style.cursor).toBe(''); - }); - - it('leaves the cursor alone when unmounted with no drag', () => { - // Given a cursor some other part of the page put on the body, and a table that isn't being dragged - document.body.style.cursor = 'grabbing'; - const columnResize = renderColumnResize(); - - // When the table unmounts - columnResize.unmount(); - - // Then the cursor is untouched, since only a drag of this table set it - expect(document.body.style.cursor).toBe('grabbing'); - }); - - it('clears the painted widths once the stored width renders', () => { - // Given a drag that was released and whose width was stored - const {handleElement, getHandleProps, readWidth, rerender, initialProps} = renderColumnResize(); - - act(() => { - getHandleProps().onPointerDown?.(createPointerEvent(handleElement, {clientX: 100})); - getHandleProps().onPointerMove?.(createPointerEvent(handleElement, {clientX: 160})); - getHandleProps().onPointerUp?.(createPointerEvent(handleElement, {clientX: 160})); - }); - - expect(readWidth('name')).toBe('260px'); - - // When the stored width comes back from Onyx and the table resolves it - rerender({...initialProps, resolvedColumnWidths: {...resolvedColumnWidths, name: 260}}); - - // Then the painted width steps aside, so the column paints the fallback React rendered and later resolved widths aren't masked - expect(readWidth('name')).toBe(''); - }); -}); diff --git a/tests/unit/components/reportDetails/DynamicReportDetailsPageTest.tsx b/tests/unit/components/reportDetails/DynamicReportDetailsPageTest.tsx index e0573b89ab97..5af103f904e6 100644 --- a/tests/unit/components/reportDetails/DynamicReportDetailsPageTest.tsx +++ b/tests/unit/components/reportDetails/DynamicReportDetailsPageTest.tsx @@ -92,6 +92,12 @@ describe('DynamicReportDetailsPage', () => { actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, } as ReportAction; + const report: Report = { + ...createRandomReport(Number(reportID), undefined), + parentReportID, + parentReportActionID: parentActionID, + }; + await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, createRandomReport(Number(parentReportID), undefined)); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { @@ -103,7 +109,12 @@ describe('DynamicReportDetailsPage', () => { @@ -171,7 +182,12 @@ describe('DynamicReportDetailsPage', () => { @@ -231,7 +247,12 @@ describe('DynamicReportDetailsPage', () => { diff --git a/tests/unit/components/withReportOrNotFound.test.tsx b/tests/unit/components/withReportOrNotFound.test.tsx deleted file mode 100644 index fa96b446f98d..000000000000 --- a/tests/unit/components/withReportOrNotFound.test.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import {act, render, screen} from '@testing-library/react-native'; - -import OnyxListItemProvider from '@components/OnyxListItemProvider'; -import ReportOrNotFoundGuard from '@components/ReportOrNotFoundGuard'; -import Text from '@components/Text'; - -import {openReport} from '@libs/actions/Report'; -import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {ReportDetailsNavigatorParamList} from '@libs/Navigation/types'; - -import type {WithReportOrNotFoundProps} from '@pages/inbox/report/withReportOrNotFound'; -import withReportOrNotFound from '@pages/inbox/report/withReportOrNotFound'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type SCREENS from '@src/SCREENS'; -import type {Report} from '@src/types/onyx'; - -import {useIsFocused} from '@react-navigation/native'; -import React from 'react'; -import Onyx from 'react-native-onyx'; - -import {createRandomReport} from '../../utils/collections/reports'; -import createMock from '../../utils/createMock'; -import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; - -jest.mock('@libs/actions/Report', () => ({ - ...jest.requireActual>('@libs/actions/Report'), - openReport: jest.fn(), -})); -jest.mock('@components/FullscreenLoadingIndicator', () => { - const {View} = jest.requireActual>>>('react-native'); - return () => ; -}); -jest.mock('@pages/ErrorPage/NotFoundPage', () => { - const {View} = jest.requireActual>>>('react-native'); - return () => ; -}); -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual>('@react-navigation/native'), - useIsFocused: jest.fn(() => true), -})); - -const mockOpenReport = jest.mocked(openReport); -const mockUseIsFocused = jest.mocked(useIsFocused); - -type ScreenProps = PlatformStackScreenProps; - -const reportID = '100'; -const report: Report = {...createRandomReport(Number(reportID), CONST.REPORT.CHAT_TYPE.GROUP), reportName: 'From Onyx', errorFields: undefined}; -const callerReport: Report = {...report, reportName: 'From caller props'}; - -function Content() { - return guarded; -} - -function ForwardedReportName({report: forwardedReport}: WithReportOrNotFoundProps) { - return {forwardedReport.reportName}; -} - -const WrappedContent = withReportOrNotFound()(ForwardedReportName); - -function renderWithHOC() { - render( - - ({})} - route={createMock({params: {reportID}})} - report={callerReport} - policy={undefined} - reportMetadata={undefined} - reportLoadingState={undefined} - isLoadingReportData={false} - /> - , - ); -} - -function renderWithGuard() { - render( - - - - - , - ); -} - -async function seedFinishedLoading() { - await act(async () => { - await Onyx.merge(ONYXKEYS.IS_LOADING_REPORT_DATA, false); - await Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`, {isLoadingInitialReportActions: false}); - }); -} - -async function seedReport() { - await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); - }); -} - -async function removeReport() { - await act(async () => { - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, null); - }); - await waitForBatchedUpdatesWithAct(); -} - -describe('withReportOrNotFound and ReportOrNotFoundGuard', () => { - beforeAll(() => { - Onyx.init({keys: ONYXKEYS}); - }); - - beforeEach(() => { - mockOpenReport.mockClear(); - mockUseIsFocused.mockReturnValue(true); - }); - - afterEach(async () => { - await act(async () => { - await Onyx.clear(); - }); - }); - - describe.each([ - ['withReportOrNotFound', renderWithHOC], - ['ReportOrNotFoundGuard', renderWithGuard], - ])('%s', (_, renderSubject) => { - it('should render the loading indicator while the report is missing and report data is still loading', async () => { - // Given no report in Onyx and no finished OpenReport call - - // When the screen renders - renderSubject(); - await waitForBatchedUpdatesWithAct(); - - // Then the loading indicator shows instead of the content - expect(screen.getByTestId('loading')).toBeTruthy(); - expect(screen.queryByTestId('content')).toBeNull(); - }); - - it('should render the not found page when loading finished without a report', async () => { - // Given loading has finished and the report never arrived - await seedFinishedLoading(); - - // When the screen renders - renderSubject(); - await waitForBatchedUpdatesWithAct(); - - // Then the not found page shows - expect(screen.getByTestId('notFound')).toBeTruthy(); - expect(screen.queryByTestId('content')).toBeNull(); - }); - - it('should render the content once the report is in Onyx', async () => { - // Given the report is loaded - await seedFinishedLoading(); - await seedReport(); - - // When the screen renders - renderSubject(); - await waitForBatchedUpdatesWithAct(); - - // Then the content shows - expect(screen.getByTestId('content')).toBeTruthy(); - }); - - it('should render nothing when a shown report disappears while the screen is unfocused', async () => { - // Given the content was shown and the screen then lost focus, as when navigating away from a deleted report - await seedFinishedLoading(); - await seedReport(); - renderSubject(); - await waitForBatchedUpdatesWithAct(); - expect(screen.getByTestId('content')).toBeTruthy(); - mockUseIsFocused.mockReturnValue(false); - - // When the report is removed - await removeReport(); - - // Then neither the not found page nor the loading indicator flashes during the transition - expect(screen.queryByTestId('content')).toBeNull(); - expect(screen.queryByTestId('notFound')).toBeNull(); - expect(screen.queryByTestId('loading')).toBeNull(); - }); - - it('should render the not found page when a shown report disappears while the screen stays focused', async () => { - // Given the content was shown and the screen keeps focus with no delete navigation in flight - await seedFinishedLoading(); - await seedReport(); - renderSubject(); - await waitForBatchedUpdatesWithAct(); - expect(screen.getByTestId('content')).toBeTruthy(); - - // When the report is removed - await removeReport(); - - // Then the suppression does not apply and the not found page shows - expect(screen.getByTestId('notFound')).toBeTruthy(); - }); - - it('should render nothing when a shown report disappears while a delete-transaction navigation is in flight', async () => { - // Given the content was shown and the screen keeps focus while a delete-transaction navigation is pending, as when deleting an invoice - await seedFinishedLoading(); - await seedReport(); - renderSubject(); - await waitForBatchedUpdatesWithAct(); - expect(screen.getByTestId('content')).toBeTruthy(); - await act(async () => { - await Onyx.set(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL, 'r/1'); - }); - - // When the report is removed - await removeReport(); - - // Then the not found page does not flash before the navigation completes - expect(screen.queryByTestId('content')).toBeNull(); - expect(screen.queryByTestId('notFound')).toBeNull(); - expect(screen.queryByTestId('loading')).toBeNull(); - }); - - it('should call OpenReport when the report is missing and has not been fetched yet', async () => { - // Given a deep link to a report that is not in Onyx and has no finished OpenReport call - - // When the screen renders - renderSubject(); - await waitForBatchedUpdatesWithAct(); - - // Then the report is requested from the server - expect(mockOpenReport).toHaveBeenCalledWith(expect.objectContaining({reportID})); - }); - - it('should not call OpenReport when the report is already loaded', async () => { - // Given the report is already in Onyx - await seedReport(); - - // When the screen renders - renderSubject(); - await waitForBatchedUpdatesWithAct(); - - // Then no request is made - expect(mockOpenReport).not.toHaveBeenCalled(); - }); - }); - - it('should forward the report read from Onyx rather than the one passed by the caller', async () => { - // Given a report in Onyx that differs from the one the caller passes as a prop - await seedFinishedLoading(); - await seedReport(); - - // When the wrapped screen renders - renderWithHOC(); - await waitForBatchedUpdatesWithAct(); - - // Then the wrapper overrides the caller's report with its own Onyx read - expect(screen.getByTestId('content')).toHaveTextContent('From Onyx'); - expect(screen.queryByText('From caller props')).toBeNull(); - }); -}); diff --git a/tests/unit/getImageRecyclingKeyTest.ts b/tests/unit/getImageRecyclingKeyTest.ts index cb3e63bd6266..cadf9bf56cec 100644 --- a/tests/unit/getImageRecyclingKeyTest.ts +++ b/tests/unit/getImageRecyclingKeyTest.ts @@ -13,26 +13,6 @@ describe('getImageRecyclingKey', () => { expect(getImageRecyclingKey({uri: 'https://example.com/image.png'})).toBe('https://example.com/image.png'); }); - test('ignores the URI fragment so a forced reload of the same file keeps the key', () => { - // Given a local receipt that was upgraded in place, so its URI only gains a fragment to force a reload - const original = {uri: 'file:///receipts/receipt.jpg'}; - const upgraded = {uri: 'file:///receipts/receipt.jpg#upgraded1'}; - - // When both sources are turned into recycling keys - // Then the keys match, so expo-image keeps showing the old image instead of blanking the view while the new one decodes - expect(getImageRecyclingKey(upgraded)).toBe(getImageRecyclingKey(original)); - expect(getImageRecyclingKey(upgraded)).toBe('file:///receipts/receipt.jpg'); - }); - - test('keeps a data URI whole because a raw # in it is part of the content', () => { - // Given an inline SVG whose colors use unencoded # characters - const source = {uri: 'data:image/svg+xml;utf8,'}; - - // When the source is turned into a recycling key - // Then nothing is cut off, so two different SVGs never share a key - expect(getImageRecyclingKey(source)).toBe(source.uri); - }); - test('returns URI from first element of array source', () => { const source = [{uri: 'https://example.com/first.png'}, {uri: 'https://example.com/second.png'}]; expect(getImageRecyclingKey(source)).toBe('https://example.com/first.png'); diff --git a/tests/unit/getResizableColumnLayoutTest.ts b/tests/unit/getResizableColumnLayoutTest.ts deleted file mode 100644 index a6eab5f48338..000000000000 --- a/tests/unit/getResizableColumnLayoutTest.ts +++ /dev/null @@ -1,104 +0,0 @@ -import getResizableColumnLayout from '@components/Table/columnResize/getResizableColumnLayout'; -import type {TableColumn} from '@components/Table/types'; - -import CONST from '@src/CONST'; - -const {MIN_WIDTH} = CONST.TABLES.COLUMN_RESIZE; - -/** No selection, gaps, margin or padding, so the row is exactly the column sum. */ -const rowChromeWidths = {selectionColumnWidth: 0, totalGapWidth: 0, rowMarginWidth: 0, rowPaddingWidth: 0}; - -function column(key: string, label = key): TableColumn { - return {key, label, sortable: false}; -} - -describe('getResizableColumnLayout', () => { - it('keeps the last column from shrinking while the row fills the table exactly', () => { - // Given three 200px columns filling a 600px table - const columns = [column('name'), column('email'), column('role')]; - - // When the layout is built - const {dragMinWidths} = getResizableColumnLayout({ - columns, - resolvedColumnWidths: {name: 200, email: 200, role: 200}, - columnWidthOverrides: undefined, - tableWidth: 600, - rowChromeWidths, - }); - - // Then the last column can't shrink, since that would pull the row in from the table's right edge - expect(dragMinWidths).toEqual({role: 200}); - }); - - it('lets the last column shrink only by the width that overflows the table', () => { - // Given columns widened 50px past a 600px table - const columns = [column('name'), column('email'), column('role')]; - - // When the layout is built - const {dragMinWidths} = getResizableColumnLayout({ - columns, - resolvedColumnWidths: {name: 200, email: 200, role: 250}, - columnWidthOverrides: undefined, - tableWidth: 600, - rowChromeWidths, - }); - - // Then the last column can give back the overflow and no more, so the row ends exactly at the table's edge - expect(dragMinWidths).toEqual({role: 200}); - }); - - it('falls back to the default drag bound once the overflow is larger than the column', () => { - // Given columns far wider than the table - const columns = [column('name'), column('email'), column('role')]; - - // When the layout is built - const {dragMinWidths} = getResizableColumnLayout({ - columns, - resolvedColumnWidths: {name: 800, email: 800, role: 200}, - columnWidthOverrides: undefined, - tableWidth: 600, - rowChromeWidths, - }); - - // Then the last column shrinks as far as any other, so its edge stays reachable - expect(dragMinWidths).toEqual({role: MIN_WIDTH}); - }); - - it('keeps the last headed column from shrinking when headless columns trail it', () => { - // Given a trailing headless arrow column, with the row filling a 600px table exactly - const columns = [column('name'), column('role'), column('arrow', '')]; - - // When the layout is built - const {dragMinWidths} = getResizableColumnLayout({ - columns, - resolvedColumnWidths: {name: 280, role: 280, arrow: 40}, - columnWidthOverrides: undefined, - tableWidth: 600, - rowChromeWidths, - }); - - // Then the last headed column can't shrink, since the freed room would stretch the fixed-size arrow column - expect(dragMinWidths).toEqual({role: 280}); - }); - - it('grows the last headed column into leftover room, not the trailing headless column', () => { - // Given columns 160px narrower than a 600px table, with a trailing headless arrow column - const columns = [column('name'), column('role'), column('arrow', '')]; - - // When the layout is built - const {gridTemplateColumns, resolvedColumnWidths, dragMinWidths} = getResizableColumnLayout({ - columns, - resolvedColumnWidths: {name: 200, role: 200, arrow: 40}, - columnWidthOverrides: undefined, - tableWidth: 600, - rowChromeWidths, - }); - - // Then only the last headed column's track grows, so the arrow keeps its size - expect(gridTemplateColumns.map((track) => track.includes('1fr'))).toEqual([false, true, false]); - - // And a drag on it starts from the 360px it's painted at, and can't shrink it, so the arrow never stretches - expect(resolvedColumnWidths.role).toBe(360); - expect(dragMinWidths).toEqual({role: 360}); - }); -}); diff --git a/tests/unit/getWorkspaceMenuItemsTest.ts b/tests/unit/getWorkspaceMenuItemsTest.ts index a4b061e9d80e..0be6c2a0dfd0 100644 --- a/tests/unit/getWorkspaceMenuItemsTest.ts +++ b/tests/unit/getWorkspaceMenuItemsTest.ts @@ -267,25 +267,6 @@ describe('getWorkspaceMenuItems', () => { expect(items.find((item) => item.translationKey === 'workspace.common.workflows')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); }); - it('shows an error indicator on workflows only when a workflow the user can fix has an approver who left the workspace', () => { - // Given a workspace with workflows enabled - const policy = createMock({...buildPolicy(CONST.POLICY.ROLE.ADMIN), areWorkflowsEnabled: true}); - const getWorkflowsIndicator = (hasApprovalWorkflowWithNonMemberApprover: boolean) => - getWorkspaceMenuItems({ - policy, - policyID: policy.id, - currentUserLogin, - icons, - hasApprovalWorkflowWithNonMemberApprover, - convertToDisplayString: () => '', - }).find((item) => item.translationKey === 'workspace.common.workflows')?.brickRoadIndicator; - - // When the menu is built with and without such a workflow - // Then only the broken workflow puts an error on the Workflows row - expect(getWorkflowsIndicator(true)).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); - expect(getWorkflowsIndicator(false)).toBeUndefined(); - }); - it('shows an error indicator when a merchant rule failed to save', () => { const policy = createMock({...buildPolicy(CONST.POLICY.ROLE.ADMIN), areRulesEnabled: true}); diff --git a/tests/unit/hooks/useEditComposerToggle.test.ts b/tests/unit/hooks/useEditComposerToggle.test.ts index 365ec0f2b7f5..6b70a8023a1b 100644 --- a/tests/unit/hooks/useEditComposerToggle.test.ts +++ b/tests/unit/hooks/useEditComposerToggle.test.ts @@ -227,35 +227,6 @@ describe('useEditComposerToggle', () => { expect(onValueChange).not.toHaveBeenCalled(); }); - it('in the RHP on a wide screen, when editing starts, leaves composer value unchanged (inline editor handles edit)', () => { - // Given a report opened in the RHP on a wide screen, where shouldUseNarrowLayout is true but the screen is not small - mockUseResponsiveLayout.mockReturnValue({...wideLayoutResult(), shouldUseNarrowLayout: true, isInNarrowPaneModal: true}); - - const onValueChange = jest.fn(); - const onFocus = jest.fn(); - const composerRef = makeComposerRef(); - - const {rerender} = renderHook(() => - useEditComposerToggle({ - selection: {start: 0, end: 0}, - composerRef, - onValueChange, - onFocus, - }), - ); - - // When editing starts - composerEditStateRef.current = defaultComposerEditState({ - editingState: CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING, - editingMessage: 'from RHP', - }); - rerender({}); - - // Then the edited message is not copied into the main composer, because desktop edits inline even in the RHP - expect(onValueChange).not.toHaveBeenCalled(); - expect(onFocus).not.toHaveBeenCalled(); - }); - it('on narrow, when edit ends, restores prior draft and selection', () => { const onValueChange = jest.fn(); const onSelectionChange = jest.fn(); diff --git a/tests/unit/hooks/useLocalReceiptThumbnail.test.ts b/tests/unit/hooks/useLocalReceiptThumbnail.test.ts deleted file mode 100644 index 7ea6425187b8..000000000000 --- a/tests/unit/hooks/useLocalReceiptThumbnail.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import {act, renderHook} from '@testing-library/react-native'; - -import useLocalReceiptThumbnail, {precacheReceiptImage} from '@hooks/useLocalReceiptThumbnail'; - -import {finish, recordUpgrade, start} from '@libs/ReceiptStorage/receiptUpgrades'; - -const mockGenerateThumbnail = jest.fn, [string]>(); -jest.mock('@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio', () => ({ - generateThumbnail: (sourceUri: string) => mockGenerateThumbnail(sourceUri), -})); - -const FOLDER = 'file:///var/mobile/Containers/Data/Application/AAAA/Documents/Receipts-Upload'; - -/** Each test uses its own receipt, since the thumbnail cache outlives a single hook. */ -function buildReceipt(name: string) { - return {name, uri: `${FOLDER}/${name}`}; -} - -function upgradeReceipt(name: string) { - act(() => { - start(name); - recordUpgrade(name); - finish(name); - }); -} - -describe('useLocalReceiptThumbnail', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockGenerateThumbnail.mockResolvedValue(null); - }); - - it('hands back the seeded receipt untouched while nothing has replaced it', () => { - const {uri} = buildReceipt('receipt_untouched.jpg'); - precacheReceiptImage(uri); - - const {result} = renderHook(() => useLocalReceiptThumbnail(uri, true)); - - expect(result.current.thumbnailUri).toBe(uri); - expect(result.current.isGenerating).toBe(false); - }); - - it('gives the same file a source the image loaders treat as new once a better capture replaces it', () => { - const {name, uri} = buildReceipt('receipt_replaced.jpg'); - precacheReceiptImage(uri); - - const {result} = renderHook(() => useLocalReceiptThumbnail(uri, true)); - expect(result.current.thumbnailUri).toBe(uri); - - upgradeReceipt(name); - - // The path never changes, so without this the loader would serve the decode it already had. - expect(result.current.thumbnailUri).toBe(`${uri}#upgraded1`); - }); - - it('moves the marker on rather than stacking it when the same receipt is replaced twice', () => { - const {name, uri} = buildReceipt('receipt_replaced_twice.jpg'); - precacheReceiptImage(uri); - - const {result} = renderHook(() => useLocalReceiptThumbnail(uri, true)); - - upgradeReceipt(name); - expect(result.current.thumbnailUri).toBe(`${uri}#upgraded1`); - - upgradeReceipt(name); - // One marker, not two, so the source stays a path the loader can still open. - expect(result.current.thumbnailUri).toBe(`${uri}#upgraded2`); - }); - - it('leaves a receipt that only finished an upgrade alone, since its bytes never moved', () => { - const {name, uri} = buildReceipt('receipt_still_the_snapshot.jpg'); - precacheReceiptImage(uri); - - const {result} = renderHook(() => useLocalReceiptThumbnail(uri, true)); - - act(() => { - start(name); - finish(name); - }); - - expect(result.current.thumbnailUri).toBe(uri); - }); -}); diff --git a/tests/unit/hooks/usePhotoUpgrade.test.ts b/tests/unit/hooks/usePhotoUpgrade.test.ts deleted file mode 100644 index 0e0335bc08b6..000000000000 --- a/tests/unit/hooks/usePhotoUpgrade.test.ts +++ /dev/null @@ -1,625 +0,0 @@ -import {act, renderHook} from '@testing-library/react-native'; - -import usePhotoUpgrade from '@pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade'; - -import CONST from '@src/CONST'; - -import type {Camera, Orientation, PhotoFile} from 'react-native-vision-camera'; - -const mockReplace = jest.fn, [string, string, (() => boolean) | undefined]>(); -const mockDiscard = jest.fn, [string]>(); -const mockStartUpgrade = jest.fn(); -const mockFinishUpgrade = jest.fn(); -const mockRecordUpgrade = jest.fn(); -const mockWasClaimed = jest.fn(); - -jest.mock('@libs/ReceiptStorage/receiptUpgrades', () => ({ - start: (name: string) => mockStartUpgrade(name), - finish: (name: string) => mockFinishUpgrade(name), - recordUpgrade: (name: string) => mockRecordUpgrade(name), - isClaimedForRead: (name: string) => mockWasClaimed(name), -})); - -jest.mock('@libs/ReceiptStorage', () => ({ - __esModule: true, - default: { - overwrite: (durableName: string, path: string, shouldAbort?: () => boolean) => mockReplace(durableName, path, shouldAbort), - discard: (path: string) => mockDiscard(path), - }, -})); - -const mockStartSpan = jest.fn]>(); -const mockEndSpanWithAttributes = jest.fn]>(); -const mockSetAttributes = jest.fn]>(); -const mockGetSpan = jest.fn<{setAttributes: (attributes: Record) => void} | undefined, [string]>(); - -jest.mock('@libs/telemetry/activeSpans', () => ({ - startSpan: (spanId: string, options: Record) => mockStartSpan(spanId, options), - endSpanWithAttributes: (spanId: string, attributes: Record) => mockEndSpanWithAttributes(spanId, attributes), - getSpan: (spanId: string) => mockGetSpan(spanId), -})); - -const mockRotate = jest.fn, [string, Orientation | undefined]>(); - -jest.mock('@pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright', () => ({ - __esModule: true, - default: (path: string, orientation?: Orientation) => mockRotate(path, orientation), -})); - -jest.mock('react-native-fs', () => ({TemporaryDirectoryPath: '/tmp'})); - -const UPGRADE_SPAN_ID = `${CONST.TELEMETRY.SPAN_RECEIPT_UPGRADE}_receipt_1234.jpg`; -const DURABLE_NAME = 'receipt_1234.jpg'; -const PHOTO_PATH = '/tmp/still.jpg'; -const UPRIGHT_PATH = '/tmp/ImageManipulator/upright.jpg'; -const PHOTO_CAPTURE_TIMEOUT_MS = 3000; -const ROTATE_TIMEOUT_MS = 5000; - -function buildPhoto(path = PHOTO_PATH): PhotoFile { - // A portrait capture off a landscape sensor reports `landscape-left`, per VisionCamera's own docs. - return {path, width: 1920, height: 1440, isRawPhoto: false, orientation: 'landscape-left', isMirrored: false}; -} - -/** A camera whose captures the test resolves or rejects on demand, in the order they were started. */ -function buildCamera() { - const started: Array<{resolve: (photo: PhotoFile) => void; reject: (error: Error) => void}> = []; - const takePhoto = jest.fn( - () => - new Promise((resolve, reject) => { - started.push({resolve, reject}); - }), - ); - - return { - // The hook only ever calls `takePhoto`, so a stub of the one method is all the test needs. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - camera: {takePhoto} as unknown as Camera, - takePhoto, - landPhoto: async (photo = buildPhoto(), index = 0) => { - await act(async () => { - started.at(index)?.resolve(photo); - }); - }, - failCapture: async (index = 0) => { - await act(async () => { - started.at(index)?.reject(new Error('Camera is closed.')); - }); - }, - }; -} - -describe('usePhotoUpgrade', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.useFakeTimers(); - mockReplace.mockResolvedValue(DURABLE_NAME); - mockDiscard.mockResolvedValue(undefined); - mockRotate.mockResolvedValue(UPRIGHT_PATH); - mockWasClaimed.mockReturnValue(false); - mockGetSpan.mockReturnValue({setAttributes: mockSetAttributes}); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('holds the camera session open from the shutter until the photo lands', async () => { - const {camera, landPhoto} = buildCamera(); - const {result} = renderHook(() => usePhotoUpgrade()); - - expect(result.current.hasPendingPhotoCapture).toBe(false); - - act(() => { - result.current.startPhotoCapture(camera); - }); - expect(result.current.hasPendingPhotoCapture).toBe(true); - - await landPhoto(); - expect(result.current.hasPendingPhotoCapture).toBe(false); - }); - - it('swaps the upright photo onto the receipt and cleans up the file it rotated from', async () => { - const {camera, landPhoto} = buildCamera(); - const {result} = renderHook(() => usePhotoUpgrade()); - - act(() => { - result.current.startPhotoCapture(camera); - }); - act(() => { - result.current.upgradeReceiptWithPhoto(DURABLE_NAME); - }); - await landPhoto(); - - // The hold the photo was taken in decides which way it is turned, so it has to be passed along. - expect(mockRotate).toHaveBeenCalledWith(PHOTO_PATH, 'landscape-left'); - expect(mockReplace).toHaveBeenCalledWith(DURABLE_NAME, UPRIGHT_PATH, expect.any(Function)); - expect(mockDiscard).toHaveBeenCalledWith(PHOTO_PATH); - // Only a receipt whose bytes really changed gets a new version, which is what makes a view reload it. - expect(mockRecordUpgrade).toHaveBeenCalledWith(DURABLE_NAME); - // The upload waits on this hold, so it has to be let go once the receipt is upgraded. - expect(mockFinishUpgrade).toHaveBeenCalledWith(DURABLE_NAME); - }); - - it('swaps the photo as captured when it needs no rotation, leaving nothing to clean up', async () => { - mockRotate.mockResolvedValue(undefined); - const {camera, landPhoto} = buildCamera(); - const {result} = renderHook(() => usePhotoUpgrade()); - - act(() => { - result.current.startPhotoCapture(camera); - }); - act(() => { - result.current.upgradeReceiptWithPhoto(DURABLE_NAME); - }); - await landPhoto(); - - expect(mockReplace).toHaveBeenCalledWith(DURABLE_NAME, PHOTO_PATH, expect.any(Function)); - expect(mockDiscard).not.toHaveBeenCalled(); - }); - - it('keeps the snapshot and releases the session when the photo misses the deadline', async () => { - const {camera, landPhoto} = buildCamera(); - const {result} = renderHook(() => usePhotoUpgrade()); - - act(() => { - result.current.startPhotoCapture(camera); - }); - act(() => { - result.current.upgradeReceiptWithPhoto(DURABLE_NAME); - }); - - await act(async () => { - jest.advanceTimersByTime(PHOTO_CAPTURE_TIMEOUT_MS); - }); - - expect(result.current.hasPendingPhotoCapture).toBe(false); - expect(mockReplace).not.toHaveBeenCalled(); - expect(mockFinishUpgrade).toHaveBeenCalledWith(DURABLE_NAME); - // The file never moved, so nothing that shows it should be told to load it again. - expect(mockRecordUpgrade).not.toHaveBeenCalled(); - - // A photo that turns up after the deadline has no consumer, so it must not stay on disk. - await landPhoto(); - expect(mockDiscard).toHaveBeenCalledWith(PHOTO_PATH); - expect(mockReplace).not.toHaveBeenCalled(); - }); - - it('leaves the receipt alone once an upload has claimed it', async () => { - mockWasClaimed.mockReturnValue(true); - const {camera, landPhoto} = buildCamera(); - const {result} = renderHook(() => usePhotoUpgrade()); - - act(() => { - result.current.startPhotoCapture(camera); - }); - act(() => { - result.current.upgradeReceiptWithPhoto(DURABLE_NAME); - }); - await landPhoto(); - - // Renaming the file now would change it under the upload that claimed it. - expect(mockReplace).not.toHaveBeenCalled(); - expect(mockRecordUpgrade).not.toHaveBeenCalled(); - expect(mockDiscard).toHaveBeenCalledWith(PHOTO_PATH); - expect(mockDiscard).toHaveBeenCalledWith(UPRIGHT_PATH); - }); - - it('keeps the snapshot when the rotate never settles, rather than holding the upgrade open', async () => { - mockRotate.mockReturnValue(new Promise(() => {})); - const {camera, landPhoto} = buildCamera(); - const {result} = renderHook(() => usePhotoUpgrade()); - - act(() => { - result.current.startPhotoCapture(camera); - }); - act(() => { - result.current.upgradeReceiptWithPhoto(DURABLE_NAME); - }); - await landPhoto(); - - expect(mockFinishUpgrade).not.toHaveBeenCalled(); - - await act(async () => { - jest.runOnlyPendingTimers(); - }); - - // Left registered, the receipt would count as upgrading for the rest of the launch. - expect(mockFinishUpgrade).toHaveBeenCalledWith(DURABLE_NAME); - expect(mockReplace).not.toHaveBeenCalled(); - }); - - it('deletes a rotated copy that lands after the rotate deadline, which nothing else would claim', async () => { - let landRotate: (path: string) => void = () => {}; - mockRotate.mockReturnValue( - new Promise((resolve) => { - landRotate = resolve; - }), - ); - const {camera, landPhoto} = buildCamera(); - const {result} = renderHook(() => usePhotoUpgrade()); - - act(() => { - result.current.startPhotoCapture(camera); - }); - act(() => { - result.current.upgradeReceiptWithPhoto(DURABLE_NAME); - }); - await landPhoto(); - - // The rotate misses its cap, so the swap gives up and keeps the snapshot. - await act(async () => { - jest.advanceTimersByTime(ROTATE_TIMEOUT_MS); - }); - expect(mockReplace).not.toHaveBeenCalled(); - - // The manipulator still finishes and writes its JPEG, with nothing left pointing at it. - await act(async () => { - landRotate(UPRIGHT_PATH); - }); - - expect(mockDiscard).toHaveBeenCalledWith(UPRIGHT_PATH); - }); - - it('hands the swap a guard that still reports a claim made after the swap began', async () => { - const {camera, landPhoto} = buildCamera(); - const {result} = renderHook(() => usePhotoUpgrade()); - - act(() => { - result.current.startPhotoCapture(camera); - }); - act(() => { - result.current.upgradeReceiptWithPhoto(DURABLE_NAME); - }); - await landPhoto(); - - const firstSwap = mockReplace.mock.calls.at(0); - expect(firstSwap).toBeDefined(); - const shouldAbort = firstSwap?.[2]; - expect(shouldAbort?.()).toBe(false); - - // The swap yields several times before it renames anything, so a reader can give up in between. - mockWasClaimed.mockReturnValue(true); - expect(shouldAbort?.()).toBe(true); - }); - - it('records a swap that backed out for an upload as claimed rather than failed', async () => { - mockReplace.mockRejectedValue(new Error('a reader stopped waiting')); - mockWasClaimed.mockReturnValue(false); - const {camera, landPhoto} = buildCamera(); - const {result} = renderHook(() => usePhotoUpgrade()); - - act(() => { - result.current.startPhotoCapture(camera); - }); - act(() => { - result.current.upgradeReceiptWithPhoto(DURABLE_NAME); - }); - - // Decided while the swap was running, which is the case the in-swap guard exists for. - mockWasClaimed.mockReturnValue(true); - await landPhoto(); - - expect(mockRecordUpgrade).not.toHaveBeenCalled(); - expect(mockFinishUpgrade).toHaveBeenCalledWith(DURABLE_NAME); - }); - - it('deletes the rotated copy when the swap fails, so failed upgrades do not pile up', async () => { - mockReplace.mockRejectedValue(new Error('no space left on device')); - const {camera, landPhoto} = buildCamera(); - const {result} = renderHook(() => usePhotoUpgrade()); - - act(() => { - result.current.startPhotoCapture(camera); - }); - act(() => { - result.current.upgradeReceiptWithPhoto(DURABLE_NAME); - }); - await landPhoto(); - - expect(mockDiscard).toHaveBeenCalledWith(UPRIGHT_PATH); - expect(mockRecordUpgrade).not.toHaveBeenCalled(); - }); - - it('keeps the snapshot when the capture itself fails', async () => { - const {camera, failCapture} = buildCamera(); - const {result} = renderHook(() => usePhotoUpgrade()); - - act(() => { - result.current.startPhotoCapture(camera); - }); - act(() => { - result.current.upgradeReceiptWithPhoto(DURABLE_NAME); - }); - await failCapture(); - - expect(result.current.hasPendingPhotoCapture).toBe(false); - expect(mockReplace).not.toHaveBeenCalled(); - expect(mockDiscard).not.toHaveBeenCalled(); - // A capture that failed must not leave the upload waiting on it. - expect(mockFinishUpgrade).toHaveBeenCalledWith(DURABLE_NAME); - }); - - it('deletes the photo when the receipt it was meant for never arrived', async () => { - const {camera, landPhoto} = buildCamera(); - const {result} = renderHook(() => usePhotoUpgrade()); - - act(() => { - result.current.startPhotoCapture(camera); - }); - act(() => { - result.current.discardPendingPhoto(); - }); - await landPhoto(); - - expect(mockReplace).not.toHaveBeenCalled(); - expect(mockDiscard).toHaveBeenCalledWith(PHOTO_PATH); - }); - - it('deletes the photo of a capture the next shutter replaced before it was claimed', async () => { - const {camera, takePhoto, landPhoto} = buildCamera(); - const {result} = renderHook(() => usePhotoUpgrade()); - - // Two shutters, and the first was never handed a receipt — so nothing else will clean up after it. - act(() => { - result.current.startPhotoCapture(camera); - }); - act(() => { - result.current.startPhotoCapture(camera); - }); - - // The second shutter replaces the first capture without starting one of its own, since the camera - // is still busy with it. - expect(takePhoto).toHaveBeenCalledTimes(1); - - // The abandoned photo still lands, and is still deleted, even though nothing replaced it. - await landPhoto(buildPhoto('/tmp/orphaned.jpg'), 0); - expect(mockDiscard).toHaveBeenCalledWith('/tmp/orphaned.jpg'); - expect(mockRotate).not.toHaveBeenCalled(); - }); - - it('deletes an unclaimed photo when the scan screen goes away', async () => { - const {camera, landPhoto} = buildCamera(); - const {result, unmount} = renderHook(() => usePhotoUpgrade()); - - act(() => { - result.current.startPhotoCapture(camera); - }); - unmount(); - - await landPhoto(); - expect(mockDiscard).toHaveBeenCalledWith(PHOTO_PATH); - expect(mockReplace).not.toHaveBeenCalled(); - }); - - it('does not start a second photo over one the camera is still taking, since the next snapshot pays for it', async () => { - const {camera, takePhoto, landPhoto} = buildCamera(); - const {result} = renderHook(() => usePhotoUpgrade()); - - act(() => { - result.current.startPhotoCapture(camera); - }); - act(() => { - result.current.upgradeReceiptWithPhoto(DURABLE_NAME); - }); - - // The user came back to the camera and shot again before the first photo landed. Starting a - // second capture here is what made a later `takeSnapshot` take seconds on device. - act(() => { - result.current.startPhotoCapture(camera); - }); - expect(takePhoto).toHaveBeenCalledTimes(1); - - // The receipt this shutter produced keeps its snapshot rather than waiting on a contended camera. - act(() => { - result.current.upgradeReceiptWithPhoto('receipt_5678.jpg'); - }); - expect(mockStartUpgrade).not.toHaveBeenCalledWith('receipt_5678.jpg'); - - await landPhoto(buildPhoto(), 0); - }); - - it('takes a photo again once the camera has finished the previous one', async () => { - const {camera, takePhoto, landPhoto} = buildCamera(); - const {result} = renderHook(() => usePhotoUpgrade()); - - act(() => { - result.current.startPhotoCapture(camera); - }); - act(() => { - result.current.upgradeReceiptWithPhoto(DURABLE_NAME); - }); - await landPhoto(buildPhoto(), 0); - - // The camera is free, so the guard lets the next shutter upgrade as normal. - act(() => { - result.current.startPhotoCapture(camera); - }); - expect(takePhoto).toHaveBeenCalledTimes(2); - }); - describe('telemetry', () => { - /** The outcome of the last ended upgrade span, which is what a dashboard groups by. */ - function readOutcome() { - const call = mockEndSpanWithAttributes.mock.calls.at(-1); - return {spanId: call?.[0], attributes: call?.[1]}; - } - - it('reports an upgrade that landed, with the resolution it won', async () => { - const {camera, landPhoto} = buildCamera(); - const {result} = renderHook(() => usePhotoUpgrade()); - - act(() => { - result.current.startPhotoCapture(camera); - }); - act(() => { - result.current.upgradeReceiptWithPhoto(DURABLE_NAME); - }); - // Named per receipt, so a later capture cannot end this one's span. - expect(mockStartSpan).toHaveBeenCalledWith(UPGRADE_SPAN_ID, expect.objectContaining({name: CONST.TELEMETRY.SPAN_RECEIPT_UPGRADE})); - - await landPhoto(); - - expect(readOutcome()).toEqual({ - spanId: UPGRADE_SPAN_ID, - attributes: { - [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.UPGRADED, - [CONST.TELEMETRY.ATTRIBUTE_PHOTO_WIDTH]: 1920, - [CONST.TELEMETRY.ATTRIBUTE_PHOTO_HEIGHT]: 1440, - }, - }); - }); - - it('separates a capture that missed its deadline from one that failed', async () => { - const {camera, failCapture} = buildCamera(); - const {result} = renderHook(() => usePhotoUpgrade()); - - act(() => { - result.current.startPhotoCapture(camera); - }); - act(() => { - result.current.upgradeReceiptWithPhoto(DURABLE_NAME); - }); - await act(async () => { - jest.advanceTimersByTime(PHOTO_CAPTURE_TIMEOUT_MS); - }); - - expect(readOutcome().attributes).toEqual({[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.CAPTURE_TIMED_OUT}); - - mockEndSpanWithAttributes.mockClear(); - const second = buildCamera(); - const {result: secondResult} = renderHook(() => usePhotoUpgrade()); - act(() => { - secondResult.current.startPhotoCapture(second.camera); - }); - act(() => { - secondResult.current.upgradeReceiptWithPhoto(DURABLE_NAME); - }); - await second.failCapture(); - await failCapture(); - - expect(readOutcome().attributes).toEqual({[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.CAPTURE_FAILED}); - }); - - it('reports a receipt an upload claimed, which is a backed-out swap rather than a failure', async () => { - const {camera, landPhoto} = buildCamera(); - const {result} = renderHook(() => usePhotoUpgrade()); - - act(() => { - result.current.startPhotoCapture(camera); - }); - act(() => { - result.current.upgradeReceiptWithPhoto(DURABLE_NAME); - }); - mockWasClaimed.mockReturnValue(true); - await landPhoto(); - - expect(readOutcome().attributes).toEqual({[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.CLAIMED_FOR_UPLOAD}); - }); - - it('reports a rotate that never settled, the one failure seen repeatedly on device', async () => { - const {camera, landPhoto} = buildCamera(); - const {result} = renderHook(() => usePhotoUpgrade()); - mockRotate.mockReturnValue(new Promise(() => {})); - - act(() => { - result.current.startPhotoCapture(camera); - }); - act(() => { - result.current.upgradeReceiptWithPhoto(DURABLE_NAME); - }); - await landPhoto(); - await act(async () => { - jest.advanceTimersByTime(ROTATE_TIMEOUT_MS); - }); - - expect(readOutcome().attributes).toEqual({[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.ROTATE_TIMED_OUT}); - }); - - it('reports a rotate that failed outright apart from the swap, so the outcomes stay comparable', async () => { - // Given a rotate that rejects before the swap starts, e.g. the photo size cannot be read - const {camera, landPhoto} = buildCamera(); - const {result} = renderHook(() => usePhotoUpgrade()); - mockRotate.mockRejectedValue(new Error('could not read the image size')); - - // When the photo lands and the upgrade runs - act(() => { - result.current.startPhotoCapture(camera); - }); - act(() => { - result.current.upgradeReceiptWithPhoto(DURABLE_NAME); - }); - await landPhoto(); - - // Then the outcome names the rotate rather than counting it as a failed swap - expect(readOutcome().attributes).toEqual({[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.ROTATE_FAILED}); - expect(mockReplace).not.toHaveBeenCalled(); - }); - - it('marks the attempt on the capture span, so a lost outcome is still countable', () => { - const {camera} = buildCamera(); - const {result} = renderHook(() => usePhotoUpgrade()); - - act(() => { - result.current.startPhotoCapture(camera); - }); - - // The capture span ends in milliseconds, so it survives the background that would cancel the - // upgrade span. It carries the denominator the success rate is computed against. - expect(mockGetSpan).toHaveBeenCalledWith(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE); - expect(mockSetAttributes).toHaveBeenCalledWith({[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_ATTEMPTED]: true}); - }); - - it('reports one outcome per upgrade, even when the swap rejects after it already reported', async () => { - const {camera, landPhoto} = buildCamera(); - const {result} = renderHook(() => usePhotoUpgrade()); - // `recordUpgrade` runs in the same `then` as the reported outcome, so throwing there is what - // actually reaches the outer catch. A rejected `discard` would not: `discardPhoto` swallows it. - mockReplace.mockResolvedValue(DURABLE_NAME); - // `Once`, because `clearAllMocks` between tests clears calls but keeps implementations. - mockRecordUpgrade.mockImplementationOnce(() => { - throw new Error('bookkeeping failed after the bytes had already changed'); - }); - - act(() => { - result.current.startPhotoCapture(camera); - }); - act(() => { - result.current.upgradeReceiptWithPhoto(DURABLE_NAME); - }); - await landPhoto(); - - // The catch below runs and computes `swap_failed`, but the upgrade already reported and the - // receipt really was upgraded, so the first outcome has to stand. - expect(mockRecordUpgrade).toHaveBeenCalled(); - expect(mockEndSpanWithAttributes).toHaveBeenCalledTimes(1); - expect(readOutcome().attributes).toEqual({ - [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.UPGRADED, - [CONST.TELEMETRY.ATTRIBUTE_PHOTO_WIDTH]: 1920, - [CONST.TELEMETRY.ATTRIBUTE_PHOTO_HEIGHT]: 1440, - }); - }); - - it('records a shutter that skipped the upgrade on the capture span, since no receipt exists yet', () => { - const {camera} = buildCamera(); - const {result} = renderHook(() => usePhotoUpgrade()); - - act(() => { - result.current.startPhotoCapture(camera); - }); - act(() => { - result.current.upgradeReceiptWithPhoto(DURABLE_NAME); - }); - // The first capture is still running, so this shutter keeps its snapshot. - act(() => { - result.current.startPhotoCapture(camera); - }); - - expect(mockGetSpan).toHaveBeenCalledWith(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE); - expect(mockSetAttributes).toHaveBeenCalledWith({ - [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_ATTEMPTED]: false, - [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.STACKED_CAPTURE_SKIPPED, - }); - // A skipped shutter must never end the span of the upgrade already running. - expect(mockEndSpanWithAttributes).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/tests/unit/hooks/useReceiptUpgradeCount.test.ts b/tests/unit/hooks/useReceiptUpgradeCount.test.ts deleted file mode 100644 index 8067e8867cb3..000000000000 --- a/tests/unit/hooks/useReceiptUpgradeCount.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import {act, renderHook} from '@testing-library/react-native'; - -import useReceiptUpgradeCount from '@hooks/useReceiptUpgradeCount'; - -import {finish, recordUpgrade, start} from '@libs/ReceiptStorage/receiptUpgrades'; - -const FOLDER = 'file:///var/mobile/Containers/Data/Application/AAAA/Documents/Receipts-Upload'; -const DURABLE_NAME = 'receipt_1234.jpg'; -const RECEIPT_URI = `${FOLDER}/${DURABLE_NAME}`; - -describe('useReceiptUpgradeCount', () => { - it('reports nothing for a receipt that has never been upgraded', () => { - const {result} = renderHook(() => useReceiptUpgradeCount(`${FOLDER}/receipt_untouched.jpg`)); - - expect(result.current).toBe(0); - }); - - it('reports nothing for a view that has no source yet', () => { - const {result} = renderHook(() => useReceiptUpgradeCount(undefined)); - - expect(result.current).toBe(0); - }); - - it('reports nothing for a bundled asset, which is a numeric module ID on native', () => { - // Given a source that is a bundled image such as ReceiptGeneric, which native resolves to a numeric asset ID - const bundledAsset = 42; - - // When the hook reads its upgrade count - const {result} = renderHook(() => useReceiptUpgradeCount(bundledAsset)); - - // Then it reports no upgrade instead of throwing on `split` - expect(result.current).toBe(0); - }); - - it('goes up when the receipt behind the path is replaced, since the path itself never changes', () => { - const {result} = renderHook(() => useReceiptUpgradeCount(RECEIPT_URI)); - - expect(result.current).toBe(0); - - act(() => { - start(DURABLE_NAME); - recordUpgrade(DURABLE_NAME); - finish(DURABLE_NAME); - }); - - expect(result.current).toBe(1); - }); - - it('stays put for an upgrade that finished without replacing the file', () => { - const name = 'receipt_kept_its_snapshot.jpg'; - const {result} = renderHook(() => useReceiptUpgradeCount(`${FOLDER}/${name}`)); - - act(() => { - start(name); - finish(name); - }); - - expect(result.current).toBe(0); - }); - - it('reads the same count back from a source it has already handed out', () => { - const name = 'receipt_handed_back.jpg'; - act(() => { - start(name); - recordUpgrade(name); - finish(name); - }); - - // A view passes the source it was given straight back in. Keying on the fragment or the query would - // make that look like a different receipt every time the file changed. - const {result: plain} = renderHook(() => useReceiptUpgradeCount(`${FOLDER}/${name}`)); - const {result: withFragment} = renderHook(() => useReceiptUpgradeCount(`${FOLDER}/${name}#upgraded1`)); - const {result: withQuery} = renderHook(() => useReceiptUpgradeCount(`${FOLDER}/${name}?v=1`)); - - expect(plain.current).toBe(1); - expect(withFragment.current).toBe(1); - expect(withQuery.current).toBe(1); - }); -}); diff --git a/tests/unit/hooks/useSearchBulkActionsTest.ts b/tests/unit/hooks/useSearchBulkActionsTest.ts index 91818fa4c099..e61cd7a12049 100644 --- a/tests/unit/hooks/useSearchBulkActionsTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsTest.ts @@ -220,13 +220,6 @@ const expenseReportQueryJSON: SearchQueryJSON = { filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: 'type', right: 'expense-report'}, }; -const invoiceQueryJSON: SearchQueryJSON = { - ...baseQueryJSON, - inputQuery: 'type:invoice status:all', - type: CONST.SEARCH.DATA_TYPES.INVOICE, - filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: 'type', right: 'invoice'}, -}; - const groupedExpenseQueryJSON: SearchQueryJSON = { ...baseQueryJSON, inputQuery: 'type:expense sortBy:groupMerchant sortOrder:asc groupBy:merchant', @@ -293,33 +286,6 @@ describe('useSearchBulkActions - CSV export flow', () => { await Onyx.clear(); }); - it('uses the unreported-expenses warning for a single expense report', async () => { - // Given a single selected expense report - mockSelectedTransactions = {report1: makeSelectedTransaction()}; - - // When the bulk actions are created for an expense-report search - renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}), {wrapper: OnyxListItemProvider}); - - // Then the report deletion prompt explains that its expenses become unreported - await waitFor(() => { - expect(mockTranslate).toHaveBeenCalledWith('iou.deleteExpenseReportConfirmation'); - }); - }); - - it('uses the generic report confirmation for a single invoice', async () => { - // Given a single selected invoice - mockSelectedTransactions = {report1: makeSelectedTransaction()}; - - // When the bulk actions are created for an invoice search - renderHook(() => useSearchBulkActions({queryJSON: invoiceQueryJSON}), {wrapper: OnyxListItemProvider}); - - // Then the report deletion prompt does not claim that expenses become unreported - await waitFor(() => { - expect(mockTranslate).toHaveBeenCalledWith('iou.deleteReportConfirmation', {count: 1}); - }); - expect(mockTranslate).not.toHaveBeenCalledWith('iou.deleteExpenseReportConfirmation'); - }); - it('handleBasicExport with select-all tracks the export', async () => { mockAreAllMatchingItemsSelected = true; mockSelectedTransactions = {tx1: makeSelectedTransaction()}; diff --git a/tests/unit/libs/prepareRequestPayloadNativeTest.ts b/tests/unit/libs/prepareRequestPayloadNativeTest.ts index c9fab238f83e..9e5853d42ff6 100644 --- a/tests/unit/libs/prepareRequestPayloadNativeTest.ts +++ b/tests/unit/libs/prepareRequestPayloadNativeTest.ts @@ -24,22 +24,13 @@ jest.mock('@libs/telemetry/ReceiptObservability', () => ({ })); const RECEIPTS_FOLDER = '/Containers/Data/Application/CURRENT/Documents/Receipts-Upload'; -const mockSettle = jest.fn, [string]>(() => Promise.resolve()); - -const mockResolveReceiptUri = (source?: string) => { - const name = source?.includes('/Receipts-Upload/') ? source.split('/').pop() : undefined; - return name ? `file://${RECEIPTS_FOLDER}/${name}` : source; -}; - jest.mock('@libs/ReceiptStorage', () => ({ __esModule: true, default: { - locate: (source?: string) => { - const uri = mockResolveReceiptUri(source); - return mockCheckFileExists(uri).then(({exists}) => (exists ? uri : undefined)); + resolve: (source?: string) => { + const name = source?.includes('/Receipts-Upload/') ? source.split('/').pop() : undefined; + return name ? `file://${RECEIPTS_FOLDER}/${name}` : source; }, - resolve: (source?: string) => mockResolveReceiptUri(source), - settle: (durableName: string) => mockSettle(durableName), }, })); @@ -158,70 +149,4 @@ describe('prepareRequestPayload (native)', () => { expect(formData.get('amount')).toBe('100'); expect(formData.has('undefinedField')).toBe(false); }); - - describe('a receipt sent as a file object without a source, as a request queued by an older app version sends it', () => { - /** `clearAllMocks` keeps a `mockReturnValue`, so a deferred one would leak into the next test. */ - beforeEach(() => { - mockSettle.mockReturnValue(Promise.resolve()); - }); - - /** The payload walks one key per microtask, so settle the whole queue rather than counting hops. */ - const flushMicrotasks = () => - new Promise((resolve) => { - setImmediate(resolve); - }); - - it('claims the file before reading it, and waits only while a claimed swap finishes renaming', async () => { - // Given a swap that has already committed to renaming the receipt file - let releaseCommittedSwap: () => void = () => {}; - mockSettle.mockReturnValue( - new Promise((resolve) => { - releaseCommittedSwap = resolve; - }), - ); - - // When an old queued ReplaceReceipt request is prepared with only the file object - let hasPrepared = false; - const prepared = prepareRequestPayload( - 'ReplaceReceipt', - { - transactionID: '1', - receipt: {uri: `file://${RECEIPTS_FOLDER}/CAM-1.jpg`, name: 'CAM-1.jpg', type: 'image/jpeg'}, - }, - false, - ).then((formData) => { - hasPrepared = true; - return formData; - }); - await flushMicrotasks(); - - // Then the file is claimed by name and the payload waits for the swap to finish - expect(mockSettle).toHaveBeenCalledWith('CAM-1.jpg'); - // `settle` only holds the payload while a swap has already committed to its two renames, since - // reading the receipt mid-rename would send the old bytes, the new ones, or nothing at all. - expect(hasPrepared).toBe(false); - - releaseCommittedSwap(); - await prepared; - expect(hasPrepared).toBe(true); - expect(mockValidateFormDataParameter).toHaveBeenCalledWith('ReplaceReceipt', 'receipt', expect.objectContaining({name: 'CAM-1.jpg'})); - }); - - it('never reaches locate, since a file object carries no receipt source to resolve', async () => { - // Given an old queued ReplaceReceipt request whose receipt has a uri but no source - // When the payload is prepared - await prepareRequestPayload( - 'ReplaceReceipt', - { - transactionID: '1', - receipt: {uri: `file://${RECEIPTS_FOLDER}/CAM-1.jpg`, name: 'CAM-1.jpg', type: 'image/jpeg'}, - }, - false, - ); - - // Then nothing checks the filesystem or reports the receipt as dropped, because there is no source to locate - expect(mockCheckFileExists).not.toHaveBeenCalled(); - expect(mockLogReceiptDropped).not.toHaveBeenCalled(); - }); - }); }); diff --git a/tests/unit/libs/receiptReadDuringSwapTest.ts b/tests/unit/libs/receiptReadDuringSwapTest.ts deleted file mode 100644 index 4d3bd2db6207..000000000000 --- a/tests/unit/libs/receiptReadDuringSwapTest.ts +++ /dev/null @@ -1,275 +0,0 @@ -import {checkIfLocalFileIsAccessible, navigateToStartStepIfScanFileCannotBeRead} from '@libs/actions/IOU/Receipt'; -import type ValidateReceiptFileNative from '@libs/fileDownload/validateReceiptFile/index.native'; -import fileURIToPath from '@libs/fileURIToPath'; -import ReceiptStorage from '@libs/ReceiptStorage'; - -import CONST from '@src/CONST'; - -const FOLDER = '/var/mobile/Containers/Data/Application/AAAA-1111/Documents/Receipts-Upload'; -const RECEIPT = 'CAM-1.jpg'; -const RECEIPT_PATH = `${FOLDER}/${RECEIPT}`; -const RECEIPT_URI = `file://${RECEIPT_PATH}`; - -const mockExisting = new Set(); -const mockDirectories = new Set(); -const mockIsOnDisk = (path?: string) => mockExisting.has(fileURIToPath(path ?? '')); - -let mockHoldRead: Promise | undefined; -const mockStat = (path?: string) => (mockHoldRead ? mockHoldRead.then(() => mockIsOnDisk(path)) : Promise.resolve(mockIsOnDisk(path))); - -jest.mock('react-native-fs', () => ({ - exists: (path: string) => Promise.resolve(mockExisting.has(path) || mockDirectories.has(path)), - moveFile: jest.fn(), - mkdir: () => Promise.resolve(), - unlink: (path: string) => { - mockExisting.delete(path); - return Promise.resolve(); - }, - readDir: () => Promise.resolve([]), - writeFile: () => Promise.resolve(), -})); - -jest.mock('@libs/fileDownload/checkFileExists', () => ({ - __esModule: true, - default: (path?: string) => mockStat(path), - checkFileExistsWithReason: (path?: string) => mockStat(path).then((exists) => ({exists})), -})); - -jest.mock('@libs/fileDownload/FileUtils', () => ({ - ...jest.requireActual>('@libs/fileDownload/FileUtils'), - readFileAsync: (path: string, fileName: string, onSuccess: (file: {uri: string}) => void, onFailure: () => void) => { - if (mockIsOnDisk(path)) { - onSuccess({uri: path}); - } else { - onFailure(); - } - return Promise.resolve(); - }, -})); - -jest.mock('@libs/getReceiptsUploadFolderPath', () => ({ - __esModule: true, - default: () => FOLDER, -})); - -// Jest resolves the bare specifier to the web implementation, which has no swap to race. -jest.mock('@libs/ReceiptStorage', () => jest.requireActual>('@libs/ReceiptStorage/index.native.ts')); - -jest.mock('@libs/Navigation/Navigation', () => ({navigate: jest.fn()})); - -jest.mock('@libs/IOUUtils', () => ({ - ...jest.requireActual>('@libs/IOUUtils'), - navigateToStartMoneyRequestStep: jest.fn(), -})); - -const {default: validateReceiptFile}: {default: typeof ValidateReceiptFileNative} = jest.requireActual('@libs/fileDownload/validateReceiptFile/index.native.ts'); - -const mockMoveFile: jest.Mock, [string, string]> = jest.requireMock<{moveFile: jest.Mock, [string, string]>}>('react-native-fs').moveFile; - -async function holdSwapWithReceiptMissing() { - let release: () => void = () => {}; - const receiptMissing = new Promise((resolve) => { - mockMoveFile.mockImplementation((from: string, to: string) => { - mockExisting.delete(from); - if (from === `${RECEIPT_PATH}.receipt-swap-staged` && to === RECEIPT_PATH) { - resolve(); - return new Promise((settle) => { - release = () => { - mockExisting.add(to); - settle(); - }; - }); - } - mockExisting.add(to); - return Promise.resolve(); - }); - }); - - const swap = ReceiptStorage.overwrite(RECEIPT, '/var/mobile/tmp/still.jpg'); - await receiptMissing; - - return { - finishSwap: () => { - release(); - return swap; - }, - }; -} - -describe('reading a receipt while an upgrade swaps it', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockExisting.clear(); - mockDirectories.clear(); - mockExisting.add(RECEIPT_PATH); - mockExisting.add('/var/mobile/tmp/still.jpg'); - mockHoldRead = undefined; - }); - - const flushPendingWork = () => - new Promise((resolve) => { - setImmediate(resolve); - }); - - it('confirms the receipt is readable instead of reporting it unreadable', async () => { - // Given an upgrade swap caught between its two renames, with nothing at the receipt's own path - const {finishSwap} = await holdSwapWithReceiptMissing(); - expect(mockExisting.has(RECEIPT_PATH)).toBe(false); - - // When the confirmation page checks the receipt can be read - const onSuccess = jest.fn(); - const onFailure = jest.fn(); - const check = checkIfLocalFileIsAccessible(RECEIPT, RECEIPT_URI, 'image/jpeg', onSuccess, onFailure); - await finishSwap(); - await check; - - // Then the check sees the upgraded receipt. A failure here restarts the request and drops the receipt. - expect(onFailure).not.toHaveBeenCalled(); - expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({uri: RECEIPT_URI})); - }); - - it('keeps the receipt when the participants step checks it mid-swap', async () => { - // Given an upgrade swap caught between its two renames - const {finishSwap} = await holdSwapWithReceiptMissing(); - - // When the participants step checks the scanned file can be read - const onSuccess = jest.fn(); - const onFailure = jest.fn(); - const check = navigateToStartStepIfScanFileCannotBeRead(RECEIPT, RECEIPT_URI, onSuccess, CONST.IOU.REQUEST_TYPE.MANUAL, CONST.IOU.TYPE.SUBMIT, '1', '2', 'image/jpeg', onFailure); - await finishSwap(); - await check; - - // Then the receipt is kept. The failure path clears it from the transaction and sends the user back to scan. - expect(onFailure).not.toHaveBeenCalled(); - expect(onSuccess).toHaveBeenCalled(); - }); - - it('passes receipt validation on the confirmation page', async () => { - // Given an upgrade swap caught between its two renames - const {finishSwap} = await holdSwapWithReceiptMissing(); - - // When the confirmation page validates the receipt file before submit - const onSuccess = jest.fn(); - const onFailure = jest.fn(); - const check = validateReceiptFile(RECEIPT, RECEIPT_URI, 'image/jpeg', onSuccess, onFailure); - await finishSwap(); - await check; - - // Then validation passes rather than flagging a receipt that is only being swapped - expect(onFailure).not.toHaveBeenCalled(); - expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({uri: RECEIPT_URI})); - }); - - it('keeps the receipt when a validation that started before the swap lands in its gap', async () => { - // Given an upgrade swap still staging its new file, before it has touched the receipt - const holds: Record<'staging' | 'secondRename', () => void> = { - staging: () => {}, - secondRename: () => {}, - }; - let stagingStarted: () => void = () => {}; - let gapOpened: () => void = () => {}; - const isStaging = new Promise((resolve) => { - stagingStarted = resolve; - }); - const isInGap = new Promise((resolve) => { - gapOpened = resolve; - }); - mockMoveFile.mockImplementation((from: string, to: string) => { - const move = () => { - mockExisting.delete(from); - mockExisting.add(to); - }; - const hold = (name: keyof typeof holds, signal: () => void) => { - signal(); - return new Promise((settle) => { - holds[name] = () => { - move(); - settle(); - }; - }); - }; - if (to === `${RECEIPT_PATH}.receipt-swap-staged`) { - return hold('staging', stagingStarted); - } - if (from === `${RECEIPT_PATH}.receipt-swap-staged` && to === RECEIPT_PATH) { - return hold('secondRename', gapOpened); - } - move(); - return Promise.resolve(); - }); - const swap = ReceiptStorage.overwrite(RECEIPT, '/var/mobile/tmp/still.jpg'); - await isStaging; - - // And a validation that has started reading the file, slowly - let finishRead: () => void = () => {}; - mockHoldRead = new Promise((resolve) => { - finishRead = resolve; - }); - const onSuccess = jest.fn(); - const onFailure = jest.fn(); - const check = validateReceiptFile(RECEIPT, RECEIPT_URI, 'image/jpeg', onSuccess, onFailure); - await flushPendingWork(); - - // When the swap reaches its gap and the read lands there, finding nothing at the receipt's path - holds.staging(); - await isInGap; - expect(mockExisting.has(RECEIPT_PATH)).toBe(false); - mockHoldRead = undefined; - finishRead(); - await flushPendingWork(); - - // Then the missed read is not reported while the swap is still running - expect(onFailure).not.toHaveBeenCalled(); - expect(onSuccess).not.toHaveBeenCalled(); - - // And once the swap is done, the recheck finds the upgraded receipt and it passes - holds.secondRename(); - await swap; - await check; - expect(onFailure).not.toHaveBeenCalled(); - expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({uri: RECEIPT_URI})); - }); - - it('still reports a receipt that is really gone', async () => { - // Given a receipt that is missing with no swap and no backup - mockExisting.clear(); - - // When the confirmation page validates it - const onSuccess = jest.fn(); - const onFailure = jest.fn(); - await validateReceiptFile(RECEIPT, RECEIPT_URI, 'image/jpeg', onSuccess, onFailure); - - // Then the failure goes through, so a lost receipt is still sent back to scan - expect(onFailure).toHaveBeenCalled(); - expect(onSuccess).not.toHaveBeenCalled(); - }); - - it('still reports an unreadable receipt from the participants step', async () => { - // Given a receipt that is missing with no swap and no backup - mockExisting.clear(); - - // When the participants step checks it can be read - const onSuccess = jest.fn(); - const onFailure = jest.fn(); - await navigateToStartStepIfScanFileCannotBeRead(RECEIPT, RECEIPT_URI, onSuccess, CONST.IOU.REQUEST_TYPE.MANUAL, CONST.IOU.TYPE.SUBMIT, '1', '2', 'image/jpeg', onFailure); - - // Then the failure goes through once, after the recheck finds nothing - expect(onFailure).toHaveBeenCalledTimes(1); - expect(onSuccess).not.toHaveBeenCalled(); - }); - - it('rejects a directory at the receipt path even after the recheck', async () => { - // Given a directory where the receipt file should be, which exists but is not a file - mockExisting.clear(); - mockDirectories.add(RECEIPT_PATH); - - // When the confirmation page validates the receipt - const onSuccess = jest.fn(); - const onFailure = jest.fn(); - await validateReceiptFile(RECEIPT, RECEIPT_URI, 'image/jpeg', onSuccess, onFailure); - - // Then the recheck finding something at the path does not make it a valid receipt - expect(onSuccess).not.toHaveBeenCalled(); - expect(onFailure).toHaveBeenCalled(); - }); -}); diff --git a/tests/unit/libs/receiptStorageTest.ts b/tests/unit/libs/receiptStorageTest.ts index 9da2fa8fed71..17bd5b431300 100644 --- a/tests/unit/libs/receiptStorageTest.ts +++ b/tests/unit/libs/receiptStorageTest.ts @@ -1,32 +1,17 @@ -import fileURIToPath from '@libs/fileURIToPath'; import type ReceiptStorageType from '@libs/ReceiptStorage/types'; const mockExists = jest.fn, [string]>(); const mockMv = jest.fn, [string, string]>(); const mockMkdir = jest.fn, [string]>(); -const mockUnlink = jest.fn, [string]>(); -const mockReadDir = jest.fn>, [string]>(); -const mockWriteFile = jest.fn, [string, string, string]>(); jest.mock('react-native-fs', () => ({ exists: (path: string) => mockExists(path), moveFile: (from: string, to: string) => mockMv(from, to), mkdir: (path: string) => mockMkdir(path), - unlink: (path: string) => mockUnlink(path), - readDir: (path: string) => mockReadDir(path), - writeFile: (path: string, contents: string, encoding: string) => mockWriteFile(path, contents, encoding), })); jest.mock('@libs/NumberUtils', () => ({rand64: () => '1234'})); -const mockCheckFileExists = jest.fn, [string | undefined]>(); - -jest.mock('@libs/fileDownload/checkFileExists', () => ({ - __esModule: true, - default: (path: string | undefined) => mockCheckFileExists(path), - checkFileExistsWithReason: (path: string | undefined) => mockCheckFileExists(path).then((exists) => ({exists})), -})); - const FOLDER = '/var/mobile/Containers/Data/Application/AAAA-1111/Documents/Receipts-Upload'; jest.mock('@libs/getReceiptsUploadFolderPath', () => ({ __esModule: true, @@ -35,11 +20,6 @@ jest.mock('@libs/getReceiptsUploadFolderPath', () => ({ // Import the native implementation by path. Jest resolves the bare specifier to the web implementation. const {default: ReceiptStorage}: {default: ReceiptStorageType} = jest.requireActual('@libs/ReceiptStorage/index.native.ts'); -const { - start: startUpgrade, - finish: finishUpgrade, - isClaimedForRead, -}: {start: (name: string) => void; finish: (name: string) => void; isClaimedForRead: (name: string) => boolean} = jest.requireActual('@libs/ReceiptStorage/receiptUpgrades.ts'); const {default: WebReceiptStorage}: {default: ReceiptStorageType} = jest.requireActual('@libs/ReceiptStorage/index.ts'); describe('ReceiptStorage', () => { @@ -48,10 +28,6 @@ describe('ReceiptStorage', () => { mockExists.mockResolvedValue(true); mockMv.mockResolvedValue(undefined); mockMkdir.mockResolvedValue(undefined); - mockUnlink.mockResolvedValue(undefined); - mockCheckFileExists.mockResolvedValue(true); - mockReadDir.mockResolvedValue([]); - mockWriteFile.mockResolvedValue(undefined); }); describe('adopt', () => { @@ -98,814 +74,6 @@ describe('ReceiptStorage', () => { }); }); - describe('overwrite', () => { - const RECEIPT = 'CAM-1.jpg'; - const STILL = '/var/mobile/tmp/still.jpg'; - - // Only the receipt is on disk, not the staged or backup paths a previous swap would have used. - const onlyTheReceiptExists = (path: string) => Promise.resolve(path === `${FOLDER}/${RECEIPT}`); - - it('swaps the bytes and hands back the same durable name, so every consumer of the receipt follows along', async () => { - mockExists.mockImplementation(onlyTheReceiptExists); - - const name = await ReceiptStorage.overwrite(RECEIPT, `file://${STILL}`); - - expect(name).toBe(RECEIPT); - expect(mockMv.mock.calls).toEqual([ - [STILL, `${FOLDER}/${RECEIPT}.receipt-swap-staged`], - [`${FOLDER}/${RECEIPT}`, `${FOLDER}/${RECEIPT}.receipt-swap-backup`], - [`${FOLDER}/${RECEIPT}.receipt-swap-staged`, `${FOLDER}/${RECEIPT}`], - ]); - }); - - it('drops the file it moved aside once the swap went through, so a capture leaves one receipt behind', async () => { - mockExists.mockImplementation((path: string) => Promise.resolve(path === `${FOLDER}/${RECEIPT}` || path === `${FOLDER}/${RECEIPT}.receipt-swap-backup`)); - - await ReceiptStorage.overwrite(RECEIPT, STILL); - - expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.receipt-swap-backup`); - }); - - it('puts the original receipt back when the swap fails, rather than leaving the receipt missing', async () => { - const existing = new Set([`${FOLDER}/${RECEIPT}`]); - mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); - mockMv.mockImplementation((from: string, to: string) => { - if (from === `${FOLDER}/${RECEIPT}.receipt-swap-staged` && to === `${FOLDER}/${RECEIPT}`) { - return Promise.reject(new Error('no space left on device')); - } - existing.delete(from); - existing.add(to); - return Promise.resolve(); - }); - - await expect(ReceiptStorage.overwrite(RECEIPT, STILL)).rejects.toThrow('no space left on device'); - - expect(mockMv).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.receipt-swap-backup`, `${FOLDER}/${RECEIPT}`); - expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.receipt-swap-staged`); - }); - - it('reports where the receipt was left when the swap fails and the restore fails too', async () => { - const existing = new Set([`${FOLDER}/${RECEIPT}`]); - mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); - mockMv.mockImplementation((from: string, to: string) => { - if (to === `${FOLDER}/${RECEIPT}`) { - return Promise.reject(new Error('no space left on device')); - } - existing.delete(from); - existing.add(to); - return Promise.resolve(); - }); - - await expect(ReceiptStorage.overwrite(RECEIPT, STILL)).rejects.toThrow(`it is left at ${FOLDER}/${RECEIPT}.receipt-swap-backup`); - - // Freeing the staged copy gives a full disk room for the restore, so it happens even when the - // restore then fails. - expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.receipt-swap-staged`); - }); - - it('still reports success when only the housekeeping delete fails, because the receipt is already swapped', async () => { - mockExists.mockImplementation(onlyTheReceiptExists); - mockUnlink.mockRejectedValue(new Error('permission denied')); - - await expect(ReceiptStorage.overwrite(RECEIPT, STILL)).resolves.toBe(RECEIPT); - }); - - it('restores a receipt stranded under the backup name by a swap the app died in the middle of', async () => { - const existing = new Set([`${FOLDER}/${RECEIPT}.receipt-swap-backup`]); - mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); - mockMv.mockImplementation((from: string, to: string) => { - existing.delete(from); - existing.add(to); - return Promise.resolve(); - }); - - await expect(ReceiptStorage.overwrite(RECEIPT, STILL)).resolves.toBe(RECEIPT); - - // The stranded copy is the only one left, so it goes back before any cleanup can delete it. - expect(mockMv).toHaveBeenNthCalledWith(1, `${FOLDER}/${RECEIPT}.receipt-swap-backup`, `${FOLDER}/${RECEIPT}`); - expect(mockMv.mock.invocationCallOrder.at(0) ?? 0).toBeLessThan(mockUnlink.mock.invocationCallOrder.at(0) ?? Number.MAX_SAFE_INTEGER); - }); - - it('rejects without touching anything when the receipt is not in durable storage', async () => { - mockExists.mockResolvedValue(false); - - await expect(ReceiptStorage.overwrite(RECEIPT, STILL)).rejects.toThrow('not in durable storage'); - expect(mockMv).not.toHaveBeenCalled(); - }); - - it('backs out at the last moment when an upload claimed the receipt while the swap was getting ready', async () => { - const existing = new Set([`${FOLDER}/${RECEIPT}`, STILL]); - mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); - - // False when `overwrite` is called, true by the time the renames are about to start: the checks - // in between all read the filesystem, so this is the window the in-swap guard covers. - let hasReaderGivenUp = false; - mockMv.mockImplementation((from: string, to: string) => { - existing.delete(from); - existing.add(to); - if (from === STILL) { - hasReaderGivenUp = true; - } - return Promise.resolve(); - }); - mockUnlink.mockImplementation((path: string) => { - existing.delete(path); - return Promise.resolve(); - }); - - await expect(ReceiptStorage.overwrite(RECEIPT, STILL, () => hasReaderGivenUp)).rejects.toThrow('an upload claimed the receipt'); - - // Staging only put a file beside the receipt, so backing out means dropping that copy and - // leaving the receipt's own name untouched. - expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.receipt-swap-staged`); - expect(mockMv).not.toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}`, `${FOLDER}/${RECEIPT}.receipt-swap-backup`); - // The receipt itself is exactly where it was. - expect(existing.has(`${FOLDER}/${RECEIPT}`)).toBe(true); - }); - - it('swaps as normal when no upload has claimed the receipt', async () => { - mockExists.mockImplementation(onlyTheReceiptExists); - - await expect(ReceiptStorage.overwrite(RECEIPT, STILL, () => false)).resolves.toBe(RECEIPT); - expect(mockMv).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.receipt-swap-staged`, `${FOLDER}/${RECEIPT}`); - }); - - it('refuses a second swap over a receipt already being swapped, rather than clearing its staged copy', async () => { - const existing = new Set([`${FOLDER}/${RECEIPT}`]); - let finishFirstSwap: () => void = () => {}; - const firstSwapStarted = new Promise((resolve) => { - mockMv.mockImplementation((from: string, to: string) => { - existing.delete(from); - existing.add(to); - if (to === `${FOLDER}/${RECEIPT}.receipt-swap-staged`) { - resolve(); - return new Promise((settle) => { - finishFirstSwap = settle; - }); - } - return Promise.resolve(); - }); - }); - mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); - - const first = ReceiptStorage.overwrite(RECEIPT, STILL); - await firstSwapStarted; - - await expect(ReceiptStorage.overwrite(RECEIPT, '/var/mobile/tmp/other.jpg')).rejects.toThrow('a swap is already running'); - - finishFirstSwap(); - await expect(first).resolves.toBe(RECEIPT); - }); - - it('registers the swap before it reads the filesystem, so a reader cannot slip in front of it', async () => { - const existing = new Set([`${FOLDER}/${RECEIPT}`]); - mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); - mockMv.mockImplementation((from: string, to: string) => { - existing.delete(from); - existing.add(to); - return Promise.resolve(); - }); - - // No awaits yet: the checks inside `overwrite` all read the filesystem, and a reader arriving - // during them would otherwise see nothing in flight and read the file about to be renamed. - const swap = ReceiptStorage.overwrite(RECEIPT, STILL); - await expect(ReceiptStorage.overwrite(RECEIPT, STILL)).rejects.toThrow('a swap is already running'); - - await swap; - }); - }); - - describe('sweeping leftovers from an interrupted swap', () => { - const RECEIPT = 'CAM-1.jpg'; - const PENDING_SWAPS = `${FOLDER}/.pending-receipt-swaps`; - - /** The sweep runs once per launch, so a test that wants to watch it needs a module that has not swept yet. */ - function loadFreshStorage(): ReceiptStorageType { - jest.resetModules(); - const {default: storage}: {default: ReceiptStorageType} = jest.requireActual('@libs/ReceiptStorage/index.native.ts'); - return storage; - } - - async function runStartupSweep(storage: ReceiptStorageType) { - const originalRequestIdleCallback = global.requestIdleCallback; - global.requestIdleCallback = ((callback: IdleRequestCallback) => { - callback({didTimeout: false, timeRemaining: () => 50}); - return 0; - }) as typeof requestIdleCallback; - try { - await storage.sweepLeftovers(); - } finally { - global.requestIdleCallback = originalRequestIdleCallback; - } - } - - function setUpFakeDisk(paths: string[]): Set { - const existing = new Set(paths); - mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); - mockMv.mockImplementation((from: string, to: string) => { - existing.delete(from); - existing.add(to); - return Promise.resolve(); - }); - mockUnlink.mockImplementation((path: string) => { - existing.delete(path); - return Promise.resolve(); - }); - mockWriteFile.mockImplementation((path: string) => { - existing.add(path); - return Promise.resolve(); - }); - return existing; - } - - it('never waits on the swap that asked for it, since `overwrite` registers itself before awaiting the sweep', async () => { - // Given a receipt a swap the app died in left only under its backup name - const storage = loadFreshStorage(); - setUpFakeDisk([FOLDER, PENDING_SWAPS, `${PENDING_SWAPS}/${RECEIPT}`, `${FOLDER}/${RECEIPT}.receipt-swap-backup`]); - mockReadDir.mockResolvedValue([{name: RECEIPT}]); - - // When a new swap over that same receipt is the one that starts the sweep - const swap = storage.overwrite(RECEIPT, '/var/mobile/tmp/still.jpg'); - let stall: ReturnType | undefined; - const stalled = new Promise((resolve) => { - stall = setTimeout(() => resolve('deadlocked'), 1000); - }); - - // Then it settles: if the sweep ever waited for a running swap, it would wait on the caller - // waiting on it and neither would finish, so this asserts completion, not a result - await expect(Promise.race([swap.then(() => 'settled'), stalled])).resolves.toBe('settled'); - await expect(swap).resolves.toBe(RECEIPT); - expect(mockReadDir).toHaveBeenCalledWith(PENDING_SWAPS); - if (stall) { - clearTimeout(stall); - } - }); - - it('puts back a stranded backup and deletes the copies nothing owns before the swap renames anything', async () => { - // Given two pending swaps: one receipt only under its backup name, one in place with a staged copy beside it - const storage = loadFreshStorage(); - const existing = setUpFakeDisk([ - FOLDER, - PENDING_SWAPS, - `${PENDING_SWAPS}/${RECEIPT}`, - `${PENDING_SWAPS}/other.jpg`, - `${FOLDER}/${RECEIPT}.receipt-swap-backup`, - `${FOLDER}/other.jpg`, - `${FOLDER}/other.jpg.receipt-swap-staged`, - ]); - mockReadDir.mockResolvedValue([{name: RECEIPT}, {name: 'other.jpg'}]); - - // When a swap has to find the stranded receipt, so the sweep must finish before `verify` runs - await expect(storage.overwrite(RECEIPT, '/var/mobile/tmp/still.jpg')).resolves.toBe(RECEIPT); - - // Then the backup went back first, the ownerless staged copy is gone, and both entries are cleared - expect(mockMv).toHaveBeenNthCalledWith(1, `${FOLDER}/${RECEIPT}.receipt-swap-backup`, `${FOLDER}/${RECEIPT}`); - expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/other.jpg.receipt-swap-staged`); - expect(mockUnlink).not.toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}`); - expect(existing.has(`${PENDING_SWAPS}/other.jpg`)).toBe(false); - expect(existing.has(`${PENDING_SWAPS}/${RECEIPT}`)).toBe(false); - - mockReadDir.mockClear(); - await storage.overwrite(RECEIPT, '/var/mobile/tmp/another.jpg'); - expect(mockReadDir).not.toHaveBeenCalled(); - }); - - it('keeps the directory listing off the request path, so a read never waits on housekeeping', async () => { - // Given a launch whose first receipt work is an upload reading its file - const storage = loadFreshStorage(); - let hasListed = false; - mockReadDir.mockImplementation(() => { - hasListed = true; - return Promise.resolve([]); - }); - mockCheckFileExists.mockResolvedValue(true); - - // When `locate` runs inside the request the sequential queue is processing - await expect(storage.locate(`file://${FOLDER}/${RECEIPT}`)).resolves.toBe(`file://${FOLDER}/${RECEIPT}`); - - // Then it read straight through, but still started the sweep so a launch that only reads gets tidied - expect(mockMv).not.toHaveBeenCalled(); - expect(hasListed).toBe(true); - }); - - it('skips the sweep when no swap has ever been pending', async () => { - // Given a device that has never upgraded a receipt, so the pending swaps folder was never created - const storage = loadFreshStorage(); - mockExists.mockResolvedValue(false); - - // When the startup sweep runs - await runStartupSweep(storage); - - // Then it lists nothing, since `readDir` on a missing folder rejects and logs on every launch - expect(mockReadDir).not.toHaveBeenCalled(); - }); - - it('never lists the receipts folder itself, since that costs a stat per receipt ever taken', async () => { - // Given a device with pending swaps from earlier launches - const storage = loadFreshStorage(); - - // When the startup sweep runs - await runStartupSweep(storage); - - // Then only the pending swaps folder is listed, because on Android listing the receipts folder blocks every native call for seconds - expect(mockReadDir).toHaveBeenCalledWith(PENDING_SWAPS); - expect(mockReadDir).not.toHaveBeenCalledWith(FOLDER); - }); - - it('keeps a stranded backup and its entry when putting it back fails, since it is the only copy of that receipt', async () => { - // Given a receipt that exists only under its backup name, and a rename that fails - const storage = loadFreshStorage(); - const backupPath = `${FOLDER}/${RECEIPT}.receipt-swap-backup`; - const existing = setUpFakeDisk([FOLDER, PENDING_SWAPS, `${PENDING_SWAPS}/${RECEIPT}`, backupPath]); - mockReadDir.mockResolvedValue([{name: RECEIPT}]); - mockMv.mockRejectedValue(new Error('rename failed')); - - // When the startup sweep runs - await runStartupSweep(storage); - - // Then it tried the restore but left the backup and the entry for the next launch to retry - expect(mockMv).toHaveBeenCalledWith(backupPath, `${FOLDER}/${RECEIPT}`); - expect(existing.has(backupPath)).toBe(true); - expect(existing.has(`${PENDING_SWAPS}/${RECEIPT}`)).toBe(true); - }); - - it('keeps the entry when a leftover copy cannot be deleted, so a later sweep can still find it', async () => { - // Given a receipt in place with its old backup still beside it, and a delete that fails - const storage = loadFreshStorage(); - const backupPath = `${FOLDER}/${RECEIPT}.receipt-swap-backup`; - const existing = setUpFakeDisk([FOLDER, PENDING_SWAPS, `${PENDING_SWAPS}/${RECEIPT}`, `${FOLDER}/${RECEIPT}`, backupPath]); - mockReadDir.mockResolvedValue([{name: RECEIPT}]); - mockUnlink.mockImplementation((path: string) => { - if (path === backupPath) { - return Promise.reject(new Error('permission denied')); - } - existing.delete(path); - return Promise.resolve(); - }); - - // When the startup sweep runs - await runStartupSweep(storage); - - // Then the backup is still there and so is the entry that points at it - expect(existing.has(backupPath)).toBe(true); - expect(existing.has(`${PENDING_SWAPS}/${RECEIPT}`)).toBe(true); - }); - - it('keeps the entry when a finished swap could not delete the copy it moved aside', async () => { - // Given a receipt in durable storage, and a delete of the backup that fails - const storage = loadFreshStorage(); - const backupPath = `${FOLDER}/${RECEIPT}.receipt-swap-backup`; - const existing = setUpFakeDisk([FOLDER, `${FOLDER}/${RECEIPT}`]); - mockUnlink.mockImplementation((path: string) => { - if (path === backupPath) { - return Promise.reject(new Error('permission denied')); - } - existing.delete(path); - return Promise.resolve(); - }); - - // When the swap runs, which still succeeds because the receipt is already swapped - await expect(storage.overwrite(RECEIPT, '/var/mobile/tmp/still.jpg')).resolves.toBe(RECEIPT); - - // Then the entry stays, so the next launch's sweep deletes the backup instead of it leaking forever - expect(existing.has(backupPath)).toBe(true); - expect(existing.has(`${PENDING_SWAPS}/${RECEIPT}`)).toBe(true); - }); - - it('drops the entry of a receipt that is gone along with its backup, so later launches stop checking it', async () => { - // Given an entry whose receipt and backup were both deleted outside the app - const storage = loadFreshStorage(); - const existing = setUpFakeDisk([FOLDER, PENDING_SWAPS, `${PENDING_SWAPS}/${RECEIPT}`]); - mockReadDir.mockResolvedValue([{name: RECEIPT}]); - - // When the startup sweep runs - await runStartupSweep(storage); - - // Then there is nothing left to put back, so the entry is removed - expect(mockMv).not.toHaveBeenCalled(); - expect(existing.has(`${PENDING_SWAPS}/${RECEIPT}`)).toBe(false); - }); - - it('restores a stranded receipt during the deferred startup sweep, without needing an upload', async () => { - // Given the app died between the two renames of a swap - const storage = loadFreshStorage(); - const existing = setUpFakeDisk([FOLDER, PENDING_SWAPS, `${PENDING_SWAPS}/${RECEIPT}`, `${FOLDER}/${RECEIPT}.receipt-swap-backup`, `${FOLDER}/${RECEIPT}.receipt-swap-staged`]); - mockReadDir.mockResolvedValue([{name: RECEIPT}]); - - // When the startup sweep runs - await runStartupSweep(storage); - - // Then the receipt is back under its own name, and nothing of the swap is left - expect(mockMv).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.receipt-swap-backup`, `${FOLDER}/${RECEIPT}`); - expect(existing.has(`${FOLDER}/${RECEIPT}`)).toBe(true); - expect(existing.has(`${FOLDER}/${RECEIPT}.receipt-swap-backup`)).toBe(false); - expect(existing.has(`${FOLDER}/${RECEIPT}.receipt-swap-staged`)).toBe(false); - expect(existing.has(`${PENDING_SWAPS}/${RECEIPT}`)).toBe(false); - }); - - it('marks a swap pending before moving any file and clears the entry once the receipt is in place', async () => { - // Given a receipt in durable storage - const storage = loadFreshStorage(); - const existing = setUpFakeDisk([FOLDER, `${FOLDER}/${RECEIPT}`]); - - // When it is swapped for the full-resolution still - await expect(storage.overwrite(RECEIPT, '/var/mobile/tmp/still.jpg')).resolves.toBe(RECEIPT); - - // Then the entry was written before the first move, so dying mid-swap always leaves one, and is gone after - expect(mockWriteFile).toHaveBeenCalledWith(`${PENDING_SWAPS}/${RECEIPT}`, '', 'utf8'); - expect(mockWriteFile.mock.invocationCallOrder.at(0) ?? Number.MAX_SAFE_INTEGER).toBeLessThan(mockMv.mock.invocationCallOrder.at(0) ?? 0); - expect(existing.has(`${PENDING_SWAPS}/${RECEIPT}`)).toBe(false); - }); - - it('keeps the entry when a failed swap could not put the receipt back', async () => { - // Given a swap whose second rename fails and whose restore fails too - const storage = loadFreshStorage(); - const existing = setUpFakeDisk([FOLDER, `${FOLDER}/${RECEIPT}`]); - mockMv.mockImplementation((from: string, to: string) => { - if (to === `${FOLDER}/${RECEIPT}`) { - return Promise.reject(new Error('no space left on device')); - } - existing.delete(from); - existing.add(to); - return Promise.resolve(); - }); - - // When the swap runs - await expect(storage.overwrite(RECEIPT, '/var/mobile/tmp/still.jpg')).rejects.toThrow('it is left at'); - - // Then the entry stays, so the next launch's sweep finds the receipt under its backup name - expect(existing.has(`${PENDING_SWAPS}/${RECEIPT}`)).toBe(true); - }); - }); - - describe('locate', () => { - const RECEIPT_URI = `file://${FOLDER}/CAM-1.jpg`; - const RECEIPT_PATH = `${FOLDER}/CAM-1.jpg`; - - /** Drains the microtask queue, so an assertion sees where a promise chain actually stopped. */ - const flushPendingWork = () => - new Promise((resolve) => { - setImmediate(resolve); - }); - - it('hands back a receipt that is where it should be', async () => { - await expect(ReceiptStorage.locate(RECEIPT_URI)).resolves.toBe(RECEIPT_URI); - expect(mockMv).not.toHaveBeenCalled(); - }); - - it('puts back a receipt stranded under the backup name by an interrupted swap', async () => { - mockCheckFileExists.mockResolvedValue(false); - mockExists.mockImplementation((path: string) => Promise.resolve(path === `${RECEIPT_PATH}.receipt-swap-backup`)); - - await expect(ReceiptStorage.locate(RECEIPT_URI)).resolves.toBe(RECEIPT_URI); - expect(mockMv).toHaveBeenCalledWith(`${RECEIPT_PATH}.receipt-swap-backup`, RECEIPT_PATH); - }); - - it('finds a receipt the startup sweep put back between its two checks, instead of sending the upload without it', async () => { - // Given a receipt missing under its own name, whose backup the sweep moves back while the read is checking - mockCheckFileExists.mockResolvedValue(false); - let targetChecks = 0; - mockExists.mockImplementation((path: string) => { - if (path === RECEIPT_PATH) { - targetChecks += 1; - return Promise.resolve(targetChecks > 1); - } - return Promise.resolve(false); - }); - - // When the upload locates the receipt - const located = await ReceiptStorage.locate(RECEIPT_URI); - - // Then it sees the receipt the sweep restored rather than reporting it missing - expect(located).toBe(RECEIPT_URI); - }); - - it('reads straight through while a swap is still staging, since that half can be called off', async () => { - const existing = new Set([RECEIPT_PATH]); - let finishStaging: () => void = () => {}; - const stagingStarted = new Promise((resolve) => { - mockMv.mockImplementation((from: string, to: string) => { - if (to === `${RECEIPT_PATH}.receipt-swap-staged`) { - resolve(); - return new Promise((settle) => { - finishStaging = settle; - }); - } - existing.delete(from); - existing.add(to); - return Promise.resolve(); - }); - }); - mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); - mockCheckFileExists.mockImplementation((path?: string) => Promise.resolve(existing.has(fileURIToPath(path ?? '')))); - - // The upgrade has to be registered for a claim to land on it, as it is in the real flow. - startUpgrade('CAM-1.jpg'); - const swap = ReceiptStorage.overwrite('CAM-1.jpg', '/var/mobile/tmp/still.jpg', () => isClaimedForRead('CAM-1.jpg')); - await stagingStarted; - - // Staging is a copy into the receipts folder, which on a cross-filesystem move is the whole - // image. Waiting it out would put that copy in front of every other queued request. - await expect(ReceiptStorage.locate(RECEIPT_URI)).resolves.toBe(RECEIPT_URI); - expect(isClaimedForRead('CAM-1.jpg')).toBe(true); - - finishStaging(); - // The claim reaches the guard below the staging move, so the swap leaves the receipt alone. - await expect(swap).rejects.toThrow('an upload claimed the receipt'); - - finishUpgrade('CAM-1.jpg'); - }); - - it('waits for a swap that has started renaming, which is the one gap a claim cannot call off', async () => { - const existing = new Set([RECEIPT_PATH]); - let finishRename: () => void = () => {}; - const renameStarted = new Promise((resolve) => { - mockMv.mockImplementation((from: string, to: string) => { - if (to === `${RECEIPT_PATH}.receipt-swap-backup`) { - resolve(); - return new Promise((settle) => { - finishRename = settle; - }); - } - existing.delete(from); - existing.add(to); - return Promise.resolve(); - }); - }); - mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); - mockCheckFileExists.mockImplementation((path?: string) => Promise.resolve(existing.has(fileURIToPath(path ?? '')))); - - const swap = ReceiptStorage.overwrite('CAM-1.jpg', '/var/mobile/tmp/still.jpg'); - await renameStarted; - - let hasLocated = false; - const located = ReceiptStorage.locate(RECEIPT_URI).then((uri) => { - hasLocated = true; - return uri; - }); - await flushPendingWork(); - - // The receipt's own name is moving, so reading it now returns the old bytes, the new ones, or - // nothing at all. This is the only part of a swap a reader ever sits through. - expect(hasLocated).toBe(false); - - finishRename(); - await swap; - await expect(located).resolves.toBe(RECEIPT_URI); - }); - - it('does not wait on an upgrade that has no swap running, so a submit never queues behind a capture', async () => { - const existing = new Set([RECEIPT_PATH]); - mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); - mockCheckFileExists.mockImplementation((path?: string) => Promise.resolve(existing.has(fileURIToPath(path ?? '')))); - - // The upgrade is registered but still capturing or rotating: nothing has reached the renames. - startUpgrade('CAM-1.jpg'); - - const located = await ReceiptStorage.locate(RECEIPT_URI); - - // Read straight through, and the upgrade is told to leave the file alone rather than waited on. - expect(located).toBe(RECEIPT_URI); - expect(isClaimedForRead('CAM-1.jpg')).toBe(true); - - finishUpgrade('CAM-1.jpg'); - }); - - it('waits for a swap that is running rather than putting the backup back over it', async () => { - const existing = new Set([RECEIPT_PATH]); - let finishSwap: () => void = () => {}; - const swapReachedItsGap = new Promise((resolve) => { - mockMv.mockImplementation((from: string, to: string) => { - existing.delete(from); - existing.add(to); - if (to === `${RECEIPT_PATH}.receipt-swap-backup`) { - // The receipt is now missing from its own path, which is the gap a reader can land in. - resolve(); - return new Promise((settle) => { - finishSwap = () => { - existing.delete(`${RECEIPT_PATH}.receipt-swap-staged`); - existing.add(RECEIPT_PATH); - settle(); - }; - }); - } - return Promise.resolve(); - }); - }); - mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); - mockCheckFileExists.mockImplementation((path?: string) => Promise.resolve(existing.has(fileURIToPath(path ?? '')))); - - const swap = ReceiptStorage.overwrite('CAM-1.jpg', '/var/mobile/tmp/still.jpg'); - await swapReachedItsGap; - - const located = ReceiptStorage.locate(RECEIPT_URI); - finishSwap(); - await swap; - - // The swapped-in file is what the reader gets, and the backup was never moved back over it. - await expect(located).resolves.toBe(RECEIPT_URI); - expect(mockMv).not.toHaveBeenCalledWith(`${RECEIPT_PATH}.receipt-swap-backup`, RECEIPT_PATH); - }); - - it('treats a receipt that reappeared while the restore was failing as readable', async () => { - mockCheckFileExists.mockResolvedValue(false); - let hasMoved = false; - mockExists.mockImplementation((path: string) => Promise.resolve(path === `${RECEIPT_PATH}.receipt-swap-backup` || (path === RECEIPT_PATH && hasMoved))); - mockMv.mockImplementation(() => { - hasMoved = true; - return Promise.reject(new Error('file exists')); - }); - - await expect(ReceiptStorage.locate(RECEIPT_URI)).resolves.toBe(RECEIPT_URI); - }); - - it('reports a receipt that is gone and has no backup, so the caller can log it as dropped', async () => { - mockCheckFileExists.mockResolvedValue(false); - mockExists.mockResolvedValue(false); - - await expect(ReceiptStorage.locate(RECEIPT_URI)).resolves.toBeUndefined(); - }); - - it('reports a remote source that could not be read, rather than handing back a URL to upload', async () => { - mockCheckFileExists.mockResolvedValue(false); - - await expect(ReceiptStorage.locate('https://www.expensify.com/receipts/w_9.jpg')).resolves.toBeUndefined(); - expect(mockMv).not.toHaveBeenCalled(); - }); - }); - - describe('recheckAfterSwap', () => { - const RECEIPT_URI = `file://${FOLDER}/CAM-1.jpg`; - const RECEIPT_PATH = `${FOLDER}/CAM-1.jpg`; - - const flushPendingWork = () => - new Promise((resolve) => { - setImmediate(resolve); - }); - - function holdSwapWithReceiptMissing() { - const existing = new Set([RECEIPT_PATH]); - let release: () => void = () => {}; - const receiptMissing = new Promise((resolve) => { - mockMv.mockImplementation((from: string, to: string) => { - existing.delete(from); - if (from === `${RECEIPT_PATH}.receipt-swap-staged` && to === RECEIPT_PATH) { - resolve(); - return new Promise((settle) => { - release = () => { - existing.add(to); - settle(); - }; - }); - } - existing.add(to); - return Promise.resolve(); - }); - }); - mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); - return {existing, receiptMissing, release: () => release()}; - } - - it('confirms a receipt that is where it should be', async () => { - // Given a receipt on disk with nothing swapping it - mockExists.mockResolvedValue(true); - - // When a reader that failed rechecks it - const isPresent = await ReceiptStorage.recheckAfterSwap(RECEIPT_URI); - - // Then the receipt counts as present and nothing is moved - expect(isPresent).toBe(true); - expect(mockMv).not.toHaveBeenCalled(); - }); - - it('waits for a swap caught between its two renames, then confirms the receipt', async () => { - // Given a swap that has moved the receipt aside and not yet moved the upgraded file in - const {existing, receiptMissing, release} = holdSwapWithReceiptMissing(); - const swap = ReceiptStorage.overwrite('CAM-1.jpg', '/var/mobile/tmp/still.jpg'); - await receiptMissing; - - // When a reader that failed in that gap rechecks the receipt - let hasAnswered = false; - const recheck = ReceiptStorage.recheckAfterSwap(RECEIPT_URI).then((isPresent) => { - hasAnswered = true; - return isPresent; - }); - await flushPendingWork(); - - // Then it answers only once the swap is done, and the receipt is there - expect(hasAnswered).toBe(false); - release(); - await swap; - await expect(recheck).resolves.toBe(true); - expect(existing.has(RECEIPT_PATH)).toBe(true); - }); - - it('waits for a swap that is still staging, so the retried read cannot land in its gap', async () => { - // Given a swap still staging its new file, with the receipt still at its own path - const existing = new Set([RECEIPT_PATH]); - let finishStaging: () => void = () => {}; - const stagingStarted = new Promise((resolve) => { - mockMv.mockImplementation((from: string, to: string) => { - const move = () => { - existing.delete(from); - existing.add(to); - }; - if (to === `${RECEIPT_PATH}.receipt-swap-staged`) { - resolve(); - return new Promise((settle) => { - finishStaging = () => { - move(); - settle(); - }; - }); - } - move(); - return Promise.resolve(); - }); - }); - mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); - const swap = ReceiptStorage.overwrite('CAM-1.jpg', '/var/mobile/tmp/still.jpg'); - await stagingStarted; - - // When a reader whose read failed for another reason rechecks the receipt - let hasAnswered = false; - const recheck = ReceiptStorage.recheckAfterSwap(RECEIPT_URI).then((isPresent) => { - hasAnswered = true; - return isPresent; - }); - await flushPendingWork(); - - // Then it does not answer while the swap can still rename the receipt, and answers once the swap is done - expect(hasAnswered).toBe(false); - finishStaging(); - await swap; - await expect(recheck).resolves.toBe(true); - }); - - it('leaves a registered upgrade alone, since a reader rechecking the receipt is not an upload claiming it', async () => { - // Given an upgrade that is still capturing - startUpgrade('CAM-1.jpg'); - - // When a reader rechecks the receipt - await ReceiptStorage.recheckAfterSwap(RECEIPT_URI); - - // Then the upgrade is not claimed, so it can still swap the better photo in. `locate` would call it off. - expect(isClaimedForRead('CAM-1.jpg')).toBe(false); - - finishUpgrade('CAM-1.jpg'); - }); - - it('puts back a receipt stranded under the backup name by an interrupted swap', async () => { - // Given a swap the app died in, with the receipt left under its backup name - mockExists.mockImplementation((path: string) => Promise.resolve(path === `${RECEIPT_PATH}.receipt-swap-backup`)); - - // When a reader rechecks the receipt - await expect(ReceiptStorage.recheckAfterSwap(RECEIPT_URI)).resolves.toBe(true); - - // Then the backup is moved back, so the retried read finds the receipt - expect(mockMv).toHaveBeenCalledWith(`${RECEIPT_PATH}.receipt-swap-backup`, RECEIPT_PATH); - }); - - it('reports a receipt that is gone and has no backup, so the reader can fail for real', async () => { - // Given a receipt that is missing with no swap and no backup - mockExists.mockResolvedValue(false); - - // When a reader rechecks it - const isPresent = await ReceiptStorage.recheckAfterSwap(RECEIPT_URI); - - // Then it is reported gone - expect(isPresent).toBe(false); - }); - - it('reports a remote source as gone without touching the filesystem', async () => { - // Given a remote receipt whose read failed - const remoteSource = 'https://www.expensify.com/receipts/w_9.jpg'; - - // When the reader rechecks it - const isPresent = await ReceiptStorage.recheckAfterSwap(remoteSource); - - // Then there is no local swap to wait for, so the failure stands - expect(isPresent).toBe(false); - expect(mockExists).not.toHaveBeenCalled(); - }); - }); - - describe('discard', () => { - it('deletes a temporary file', async () => { - await ReceiptStorage.discard('file:///var/mobile/tmp/still.jpg'); - - expect(mockUnlink).toHaveBeenCalledWith('/var/mobile/tmp/still.jpg'); - }); - - it('resolves without deleting when the file is already gone', async () => { - mockExists.mockResolvedValue(false); - - await ReceiptStorage.discard('/var/mobile/tmp/still.jpg'); - - expect(mockUnlink).not.toHaveBeenCalled(); - }); - }); - describe('toLocalUri', () => { it('resolves against the folder as it is right now', () => { expect(ReceiptStorage.toLocalUri('receipt_1234.jpg')).toBe(`file://${FOLDER}/receipt_1234.jpg`); diff --git a/tests/unit/libs/receiptUpgradesTest.ts b/tests/unit/libs/receiptUpgradesTest.ts deleted file mode 100644 index 2321d2585c06..000000000000 --- a/tests/unit/libs/receiptUpgradesTest.ts +++ /dev/null @@ -1,110 +0,0 @@ -import {claimForRead, finish, getUpgradeCount, isClaimedForRead, recordUpgrade, start, subscribe} from '@libs/ReceiptStorage/receiptUpgrades'; - -const RECEIPT = 'receipt_1234.jpg'; - -describe('receiptUpgrades', () => { - afterEach(() => { - finish(RECEIPT); - jest.useRealTimers(); - }); - - it('reports nothing in flight for a receipt that is not being upgraded', () => { - expect(isClaimedForRead(RECEIPT)).toBe(false); - }); - - it('claims a receipt without waiting, so an upload never queues behind a capture', () => { - start(RECEIPT); - expect(isClaimedForRead(RECEIPT)).toBe(false); - - // Nothing is awaited here on purpose: this is the whole of what the upload path does, and it runs - // inside the request the sequential queue is processing. - claimForRead(RECEIPT); - - expect(isClaimedForRead(RECEIPT)).toBe(true); - }); - - it('ignores a claim on a receipt nothing is upgrading, so an ordinary upload is unaffected', () => { - claimForRead(RECEIPT); - expect(isClaimedForRead(RECEIPT)).toBe(false); - }); - - it('keeps a claim for the rest of the upgrade, so a second reader cannot hand the file back', () => { - start(RECEIPT); - claimForRead(RECEIPT); - claimForRead(RECEIPT); - - expect(isClaimedForRead(RECEIPT)).toBe(true); - }); - - it('forgets the claim once the upgrade is over, so the next one starts clean', () => { - start(RECEIPT); - claimForRead(RECEIPT); - finish(RECEIPT); - - start(RECEIPT); - expect(isClaimedForRead(RECEIPT)).toBe(false); - }); - - it('tells subscribers when a receipt starts and stops changing', () => { - const listener = jest.fn(); - const unsubscribe = subscribe(listener); - - start(RECEIPT); - expect(listener).toHaveBeenCalledTimes(1); - - finish(RECEIPT); - expect(listener).toHaveBeenCalledTimes(2); - - unsubscribe(); - start(RECEIPT); - expect(listener).toHaveBeenCalledTimes(2); - }); - - it('ignores a second start for the same receipt, so one upgrade cannot orphan another', () => { - start(RECEIPT); - claimForRead(RECEIPT); - // A second start must not reset the claim the first upgrade already handed out. - start(RECEIPT); - - expect(isClaimedForRead(RECEIPT)).toBe(true); - }); - - describe('version count', () => { - const KEPT_SNAPSHOT = 'receipt_kept_snapshot.jpg'; - const SWAPPED = 'receipt_swapped.jpg'; - - it('stays put for an upgrade that finished without replacing the file', () => { - start(KEPT_SNAPSHOT); - finish(KEPT_SNAPSHOT); - - // A view keys its image source on this, so bumping it here would re-decode a file that never - // moved — on the deadline-miss path, which is the common one. - expect(getUpgradeCount(KEPT_SNAPSHOT)).toBe(0); - }); - - it('moves only when the bytes behind the receipt actually changed', () => { - const listener = jest.fn(); - const unsubscribe = subscribe(listener); - - start(SWAPPED); - recordUpgrade(SWAPPED); - finish(SWAPPED); - - expect(getUpgradeCount(SWAPPED)).toBe(1); - // start, recordUpgrade and finish each tell the views something changed. - expect(listener).toHaveBeenCalledTimes(3); - unsubscribe(); - }); - - it('forgets the oldest receipts rather than growing for the whole launch', () => { - const first = 'receipt_first_of_many.jpg'; - recordUpgrade(first); - for (let index = 0; index < 60; index++) { - recordUpgrade(`receipt_bulk_${index}.jpg`); - } - - expect(getUpgradeCount(first)).toBe(0); - expect(getUpgradeCount('receipt_bulk_59.jpg')).toBe(1); - }); - }); -}); diff --git a/tests/unit/rotatePhotoToUprightTest.ts b/tests/unit/rotatePhotoToUprightTest.ts deleted file mode 100644 index 6b36f4beadce..000000000000 --- a/tests/unit/rotatePhotoToUprightTest.ts +++ /dev/null @@ -1,50 +0,0 @@ -import {getUprightRotation} from '@pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright'; - -describe('getUprightRotation', () => { - it('leaves a photo whose metadata already describes it upright as captured, rather than re-encoding it', () => { - expect(getUprightRotation({width: 1920, height: 1440, rotation: 90})).toBeUndefined(); - expect(getUprightRotation({width: 1920, height: 1440, rotation: 270})).toBeUndefined(); - }); - - it('turns a landscape sensor buffer that carries no rotation of its own', () => { - expect(getUprightRotation({width: 1920, height: 1440, rotation: 0})).toBe(90); - expect(getUprightRotation({width: 1920, height: 1440, rotation: 180})).toBe(90); - }); - - it('turns the opposite hold the other way, rather than leaving it 180 out', () => { - // `PhotoFile.orientation` is counter-clockwise, so `landscape-left` (270 CCW) is the 90 clockwise - // the Galaxy S20 FE needs, and `landscape-right` (90 CCW) has to go the other way. - expect(getUprightRotation({width: 1920, height: 1440, rotation: 0}, 'landscape-left')).toBe(90); - expect(getUprightRotation({width: 1920, height: 1440, rotation: 0}, 'landscape-right')).toBe(270); - }); - - it('keeps the verified quarter-turn for a hold that cannot make a landscape frame upright', () => { - // Only a quarter-turn makes a landscape frame portrait, so these fall back rather than turning 0 or - // 180 and leaving the receipt on its side. - expect(getUprightRotation({width: 1920, height: 1440, rotation: 0}, 'portrait')).toBe(90); - expect(getUprightRotation({width: 1920, height: 1440, rotation: 0}, 'portrait-upside-down')).toBe(90); - }); - - it('ignores the hold for a frame that already decodes upright, since only the decoded frame matters', () => { - expect(getUprightRotation({width: 1440, height: 1920, rotation: 0}, 'landscape-right')).toBeUndefined(); - }); - - it('leaves a photo that is already upright with no metadata alone, rather than re-encoding it', () => { - // iOS, where react-native-image-size reports the displayed size and no rotation. - expect(getUprightRotation({width: 2160, height: 2880})).toBeUndefined(); - expect(getUprightRotation({width: 1440, height: 1920, rotation: 0})).toBeUndefined(); - }); - - it('leaves an upright photo as captured even when it still carries metadata', () => { - expect(getUprightRotation({width: 1440, height: 1920, rotation: 180})).toBeUndefined(); - }); - - it('still turns a frame that decodes landscape, whatever its metadata says', () => { - // 1440x1920 asking for 90 decodes as 1920x1440, genuinely on its side. - expect(getUprightRotation({width: 1440, height: 1920, rotation: 90})).toBe(90); - }); - - it('leaves a square photo with no metadata alone', () => { - expect(getUprightRotation({width: 1440, height: 1440, rotation: 0})).toBeUndefined(); - }); -}); diff --git a/tests/unit/selectors/TableColumnWidthsTest.ts b/tests/unit/selectors/TableColumnWidthsTest.ts deleted file mode 100644 index f8e2dd1ffed5..000000000000 --- a/tests/unit/selectors/TableColumnWidthsTest.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {tableColumnWidthsSelector} from '@src/selectors/TableColumnWidths'; - -const TAGS_TABLE_ID = 'workspaceTags'; -const TAXES_TABLE_ID = 'workspaceTaxes'; - -describe('tableColumnWidthsSelector', () => { - const storedWidths = { - [TAGS_TABLE_ID]: {name: 240, enabled: 120}, - [TAXES_TABLE_ID]: {name: 300}, - }; - - // Given a table that has opted into resizing - // When its widths are selected out of the record holding every table's - // Then it gets only its own, so the table never lays out from another table's columns - it('picks out the widths stored for the table', () => { - expect(tableColumnWidthsSelector(TAGS_TABLE_ID)(storedWidths)).toEqual({name: 240, enabled: 120}); - }); - - // Given a resizable table the user has never dragged - // When its widths are selected - // Then there are none, so it lays out from its column defaults instead of another table's widths - it('returns nothing for a table with no stored widths', () => { - expect(tableColumnWidthsSelector('workspaceCategories')(storedWidths)).toBeUndefined(); - }); -}); diff --git a/tests/unit/usePolicyIndicatorChecksTest.ts b/tests/unit/usePolicyIndicatorChecksTest.ts index 6772c0642d41..7f939bd6a6dd 100644 --- a/tests/unit/usePolicyIndicatorChecksTest.ts +++ b/tests/unit/usePolicyIndicatorChecksTest.ts @@ -257,41 +257,6 @@ describe('usePolicyIndicatorChecks', () => { expect(result.current.policyErrorStatus).toBe(CONST.INDICATOR_STATUS.HAS_POLICY_ADMIN_CARD_FEED_ERRORS); expect(result.current.indicatorPolicyID).toBe(WORKSPACE.policyID); }); - - it('returns HAS_APPROVAL_WORKFLOW_NON_MEMBER_APPROVER when a member submits to someone no longer on the workspace', async () => { - // Given an advanced-approval workspace the admin can edit, where a member still submits to a removed approver - await act(async () => { - await Onyx.clear(); - await Onyx.multiSet({ - [ONYXKEYS.SESSION]: {email: userID}, - ...createPolicyCollectionDataSet( - createMock({ - id: WORKSPACE.policyID, - name: WORKSPACE.policyName, - type: CONST.POLICY.TYPE.CORPORATE, - owner: userID, - role: 'admin', - approver: userID, - approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, - policyAccountID: WORKSPACE.policyAccountID, - employeeList: { - [userID]: {email: userID, role: CONST.POLICY.ROLE.ADMIN, submitsTo: userID}, - [otherUserID]: {email: otherUserID, role: CONST.POLICY.ROLE.USER, submitsTo: 'removed@example.com'}, - }, - }), - ), - } satisfies OnyxMultiSetInput); - await waitForBatchedUpdatesWithAct(); - }); - - // When the policy indicator checks run - const {result} = renderHook(() => usePolicyIndicatorChecks()); - await waitForBatchedUpdatesWithAct(); - - // Then the Workspaces tab gets the red dot for that workspace, matching the flagged card on its Workflows page - expect(result.current.policyErrorStatus).toBe(CONST.INDICATOR_STATUS.HAS_APPROVAL_WORKFLOW_NON_MEMBER_APPROVER); - expect(result.current.indicatorPolicyID).toBe(WORKSPACE.policyID); - }); }); describe('policy info statuses', () => { diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index 77a1fba2b9d0..c3e78bccf919 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -136,7 +136,6 @@ jest.mock('@libs/ReportActionsUtils', () => ({ isTransactionThread: () => mockIsTransactionThread, isSentMoneyReportAction: () => mockIsSentMoneyReportAction, isReportPreviewAction: () => mockIsReportPreviewAction, - getVisibleReportActionErrors: (reportAction: ReportAction | undefined) => reportAction?.errors ?? {}, })); // --- ReportUtils --- From 3a17394939a719a9a4f46519198e1d33533ab735 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:09:04 +0530 Subject: [PATCH 22/56] Restore minimal disable comment for type assertion --- src/components/EditableCell/usePopoverEditState.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/EditableCell/usePopoverEditState.ts b/src/components/EditableCell/usePopoverEditState.ts index 3d5d5e433703..3c27c28ee07f 100644 --- a/src/components/EditableCell/usePopoverEditState.ts +++ b/src/components/EditableCell/usePopoverEditState.ts @@ -139,6 +139,7 @@ type UsePopoverEditStateOptionsGeneric = { * - Value comparison to prevent no-op saves */ function usePopoverEditState(options: UsePopoverEditStateOptionsGeneric) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the impl is non-generic for React Compiler hoisting return usePopoverEditStateImpl(options as UsePopoverEditStateOptions) as ReturnType & { handleSave: (newValue: T) => void; }; From c8f03e149f55bdbe63e541c29bacd8817ba8075a Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:15:21 +0530 Subject: [PATCH 23/56] Restore files that were reverted against latest main --- .claude/skills/app-coding-standards/SKILL.md | 1 + .../rules/consistency-20-function-names.md | 48 + .github/workflows/nativeDependencies.yml | 52 + Mobile-Expensify | 2 +- android/app/dependency-usage.json | 59 + config/eslint/eslint.seatbelt.tsv | 4 +- config/rsbuild/rsbuild.common.ts | 6 - cspell.json | 11 + .../netsuite/Connect-To-NetSuite.md | 32 +- .../certinia/Configure-Certinia.md | 6 +- .../Manage-Vendor-Matching-for-Certinia.md | 105 + .../netsuite/Connect-To-NetSuite.md | 247 +- .../Configure-Quickbooks-Online.md | 6 +- ...e-Vendor-Matching-for-QuickBooks-Online.md | 2 +- ...Set-Up-and-Manage-the-Expensify-Card-US.md | 3 +- .../reports-and-expenses/Distance-Expenses.md | 3 +- .../Using-Reports-in-New-Expensify.md | 2 +- .../Configure-Per-Diem-in-a-workspace.md | 1 + .../workspaces/Managing-Workspace-Members.md | 2 + .../workspaces/Set-distance-rates.md | 2 + ios/pod-usage.json | 28 + knip.json | 6 - package-lock.json | 576 +-- package.json | 3 - patches/link/details.md | 14 - patches/link/link+2.1.1+001+hot-load.patch | 4513 ----------------- scripts/checkUnusedGradleDependencies.ts | 179 + scripts/checkUnusedPods.ts | 175 + scripts/generateGradleDependencyUsage.ts | 108 + scripts/generatePodUsage.ts | 109 + scripts/gradleUsageShared.ts | 302 ++ scripts/nativeSourceComments.ts | 224 + scripts/nativeUsageManifest.ts | 62 + scripts/podUsageShared.ts | 158 + src/Expensify.tsx | 2 + src/ONYXKEYS.ts | 8 + src/ROUTES.ts | 16 +- src/components/ApprovalWorkflowSection.tsx | 20 +- src/components/InitialURLContextProvider.tsx | 31 +- .../sections/CategoryField.tsx | 104 +- .../sections/CategoryFieldDropdown.tsx | 5 +- .../sections/ExpenseFieldDropdown.tsx | 28 +- .../hooks/useReceiptThumbnailSource.ts | 13 +- .../ReportActionItem/MoneyRequestView.tsx | 8 +- src/components/ReportOrNotFoundGuard.tsx | 140 + .../SignInButtons/AppleSignIn/index.tsx | 16 +- .../SignInButtons/GoogleSignIn/index.tsx | 12 +- src/components/Table/Table.tsx | 320 +- src/components/Table/TableContext.tsx | 13 +- src/components/Table/TableHeader.tsx | 23 +- src/components/Table/TableRow.tsx | 7 + .../Table/TableSemanticContainer.tsx | 47 +- src/components/Table/buildTableListData.ts | 10 +- .../Table/calculateDynamicColumnWidths.ts | 15 + .../ColumnResizeHandle/index.native.tsx | 9 + .../columnResize/ColumnResizeHandle/index.tsx | 47 + .../ColumnResizeScope/index.native.tsx | 8 + .../columnResize/ColumnResizeScope/index.tsx | 26 + .../columnResize/applyColumnWidthOverrides.ts | 70 + .../columnResize/columnWidthExpressions.ts | 54 + .../columnResize/getDraggedColumnWidth.ts | 9 + .../getMeasureWidthRef/index.native.ts | 7 + .../columnResize/getMeasureWidthRef/index.ts | 16 + .../columnResize/getResizableColumnLayout.ts | 119 + src/components/Table/columnResize/types.ts | 13 + .../useColumnResize/index.native.ts | 9 + .../columnResize/useColumnResize/index.ts | 166 + .../columnResize/useColumnResize/types.ts | 30 + .../useColumnResize/useLiveColumnWidths.ts | 71 + .../useColumnResize/useResizeIndicator.ts | 58 + .../useStoredColumnWidths/index.native.ts | 9 + .../useStoredColumnWidths/index.ts | 13 + src/components/Table/types.ts | 6 + .../Table/useDynamicColumnWidths.ts | 91 +- .../WorkspaceCompanyCardsTable/index.tsx | 1 + .../WorkspaceExpensifyCardsTable/index.tsx | 2 + .../WorkspaceRowBrickRoadIndicator.tsx | 4 +- .../Tables/WorkspaceMembersTable/index.tsx | 1 + .../Tables/WorkspaceTaxesTable/index.tsx | 3 + src/components/Text.tsx | 6 +- src/hooks/useExpenseActions.ts | 2 +- ...asApprovalWorkflowWithNonMemberApprover.ts | 34 + src/hooks/useLocalReceiptThumbnail.ts | 7 +- src/hooks/usePolicyIndicatorChecks.ts | 20 +- src/hooks/useReceiptUpgradeCount.ts | 16 + src/hooks/useReportActionsScroll.ts | 6 +- src/hooks/useSaveMarketingAttribution.ts | 32 + src/hooks/useSearchBulkActions.ts | 9 +- src/libs/API/index.ts | 4 +- .../API/parameters/BeginAppleSignInParams.ts | 3 +- .../API/parameters/BeginGoogleSignInParams.ts | 3 +- .../SignInWithShortLivedAuthTokenParams.ts | 5 + src/libs/API/parameters/SignUpUserParams.ts | 3 +- src/libs/CardUtils.ts | 17 +- src/libs/ExportOnyxState/masking.ts | 2 + src/libs/Middleware/SaveResponseInOnyx.ts | 2 +- .../AppNavigator/AuthScreensInitHandler.tsx | 6 +- src/libs/Navigation/types.ts | 1 + src/libs/ReceiptStorage/index.native.ts | 287 +- src/libs/ReceiptStorage/index.ts | 6 + src/libs/ReceiptStorage/receiptUpgrades.ts | 83 + src/libs/ReceiptStorage/types.ts | 21 + src/libs/SessionUtils.ts | 57 +- src/libs/TransactionUtils/index.ts | 24 +- src/libs/WorkflowUtils.ts | 178 +- src/libs/actions/IOU/PayMoneyRequest.ts | 9 +- src/libs/actions/IOU/Receipt.ts | 20 +- src/libs/actions/MarketingAttribution.ts | 73 + src/libs/actions/Report/index.ts | 62 +- src/libs/actions/Session/index.ts | 69 +- src/libs/actions/TableColumnWidths.ts | 11 + src/libs/actions/Workflow.ts | 12 +- src/libs/actions/connections/merge/index.ts | 4 + .../validateReceiptFile/index.native.ts | 11 +- src/libs/getImageRecyclingKey.ts | 10 +- src/libs/merge/HRUtils.ts | 51 +- src/libs/merge/MergeUtils.ts | 6 +- .../prepareRequestPayload/index.native.ts | 24 +- src/libs/sanitizeLogParams.ts | 1 + .../ReportDetailsContent.tsx | 115 + .../ReportDetailsMenuItems.tsx | 193 +- .../ReportDetailsMenuTrackExpenseItems.tsx | 204 + src/pages/DynamicReportDetailsPage/index.tsx | 111 +- .../LogInWithShortLivedAuthTokenPage.tsx | 6 +- src/pages/LogOutPreviousUserPage.tsx | 142 +- .../BaseOnboardingPurpose.tsx | 5 +- .../ReportActionCompose/ComposerProvider.tsx | 6 +- .../ComposerWithSuggestions.tsx | 6 +- .../useEditComposerToggle.ts | 20 +- src/pages/inbox/report/ReportActionItem.tsx | 24 +- .../actionContents/ActionContentRouter.tsx | 4 +- .../actionContents/ChatMessageContent.tsx | 6 +- .../inbox/report/useReportDeepLinkOnOpen.ts | 49 + .../inbox/report/useShouldEditInComposer.ts | 14 + ...useShouldShowComposerForActiveEditDraft.ts | 7 +- .../inbox/report/withReportOrNotFound.tsx | 82 +- .../step/DynamicIOURequestStepUpgrade.tsx | 6 +- .../components/Camera/index.native.tsx | 36 +- .../components/Camera/types.ts | 3 + .../components/CameraViewport.tsx | 14 +- .../NavigationAwareCamera/Camera.tsx | 7 +- .../components/NavigationAwareCamera/types.ts | 1 + .../components/ScanEditReceipt.tsx | 1 + .../components/ScanSkipConfirmation.tsx | 1 + .../hooks/usePhotoUpgrade.ts | 316 ++ .../utils/rotatePhotoToUpright.ts | 71 + .../signin/SAMLSignInPage/index.native.tsx | 7 +- src/pages/signin/SignUpWelcomeForm.tsx | 3 +- src/pages/workspace/WorkspaceInitialPage.tsx | 3 + .../companyCards/addNew/CardTypeStep.tsx | 63 +- .../addNew/DynamicAddNewCardPage.tsx | 8 +- .../WorkspaceDuplicateSelectFeaturesForm.tsx | 59 +- src/pages/workspace/getWorkspaceMenuItems.ts | 5 +- .../workspace/hr/merge/MergeHRGroupsPage.tsx | 101 +- src/pages/workspace/hr/utils.ts | 9 +- .../members/WorkspaceMemberDetailsPage.tsx | 11 +- .../workspace/merge/MergeProviderCard.tsx | 23 +- src/pages/workspace/merge/types.ts | 6 + .../approvals/ApprovalWorkflowEditor.tsx | 10 +- ...orkspaceWorkflowsApprovalsApproverPage.tsx | 36 +- src/selectors/Session.ts | 5 +- src/selectors/TableColumnWidths.ts | 9 + src/setup/index.ts | 3 + src/styles/index.ts | 4 + src/styles/utils/cursor/index.native.ts | 1 + src/styles/utils/cursor/index.ts | 4 + src/styles/utils/cursor/types.ts | 1 + src/styles/utils/index.ts | 16 +- src/types/onyx/ApprovalWorkflow.ts | 12 + src/types/onyx/MarketingAttribution.ts | 46 + src/types/onyx/Policy.ts | 6 + src/types/onyx/TableColumnWidths.ts | 8 + src/types/onyx/index.ts | 6 + tests/actions/IOUTest/PayMoneyRequestTest.ts | 58 + tests/actions/MarketingAttributionTest.ts | 266 + tests/actions/PolicyTest.ts | 55 + tests/actions/ReportTest.ts | 109 + tests/actions/SessionTest.ts | 140 +- tests/actions/WorkflowTest.ts | 152 + .../DynamicReportDetailsPage.perf-test.tsx | 6 - tests/ui/AuthScreensInitHandlerTest.tsx | 18 +- tests/ui/DynamicIOURequestStepUpgradeTest.tsx | 161 + tests/ui/DynamicReportDetailsPageTest.tsx | 25 - .../ui/IOURequestRedirectToStartPageTest.tsx | 1 + .../LogInWithShortLivedAuthTokenPageTest.tsx | 12 +- tests/ui/MoneyRequestViewTest.tsx | 29 + tests/ui/OnboardingPurpose.tsx | 25 + .../ui/ReportActionMessageEditLayoutTest.tsx | 32 + tests/ui/SAMLSignInPageTest.tsx | 6 +- tests/ui/SessionTest.tsx | 115 +- tests/ui/WorkspaceMemberDetailsPageTest.tsx | 38 +- tests/ui/WorkspaceMembersTest.tsx | 42 +- ...paceWorkflowsApprovalsApproverPageTest.tsx | 231 + ...orkspaceWorkflowsApprovalsEditPageTest.tsx | 28 +- tests/unit/ApprovalWorkflowSectionTest.tsx | 58 + tests/unit/CardUtilsTest.ts | 24 + tests/unit/ClearReportActionErrorsTest.ts | 37 + tests/unit/ContextMenuActionsDeleteTest.ts | 105 + tests/unit/HrUtilsTest.ts | 285 +- tests/unit/MergeActionTest.ts | 75 +- tests/unit/MiddlewareTest.ts | 4 +- tests/unit/ReportActionsUtilsTest.ts | 151 + tests/unit/SearchEditMultipleTagPageTest.tsx | 3 + tests/unit/SessionUtilsTest.ts | 49 +- tests/unit/SignUpWelcomeFormTest.tsx | 26 +- tests/unit/TransactionUtilsTest.ts | 35 + tests/unit/WorkflowUtilsTest.ts | 476 +- ...rkspaceDuplicateSelectFeaturesFormTest.tsx | 209 + tests/unit/applyColumnWidthOverridesTest.ts | 66 + .../unit/calculateDynamicColumnWidthsTest.ts | 21 +- tests/unit/columnWidthExpressionsTest.ts | 17 + .../components/ExpenseFieldDropdown.test.tsx | 49 +- .../CategoryField.test.tsx | 283 +- .../components/NavigationAwareCameraTest.tsx | 61 + .../Table/TableSemanticContainerTest.tsx | 34 + .../Table/buildTableListDataTest.ts | 72 + .../components/Table/useColumnResizeTest.ts | 285 ++ .../DynamicReportDetailsPageTest.tsx | 21 - .../components/withReportOrNotFound.test.tsx | 257 + tests/unit/getImageRecyclingKeyTest.ts | 20 + tests/unit/getResizableColumnLayoutTest.ts | 104 + tests/unit/getWorkspaceMenuItemsTest.ts | 19 + .../unit/hooks/useEditComposerToggle.test.ts | 29 + .../hooks/useLocalReceiptThumbnail.test.ts | 83 + tests/unit/hooks/usePhotoUpgrade.test.ts | 625 +++ .../unit/hooks/useReceiptUpgradeCount.test.ts | 79 + tests/unit/hooks/useSearchBulkActionsTest.ts | 34 + .../libs/prepareRequestPayloadNativeTest.ts | 81 +- tests/unit/libs/receiptReadDuringSwapTest.ts | 275 + tests/unit/libs/receiptStorageTest.ts | 832 +++ tests/unit/libs/receiptUpgradesTest.ts | 110 + tests/unit/rotatePhotoToUprightTest.ts | 50 + tests/unit/selectors/TableColumnWidthsTest.ts | 25 + tests/unit/usePolicyIndicatorChecksTest.ts | 35 + tests/unit/useReportActionsScrollTest.tsx | 1 + 235 files changed, 12330 insertions(+), 6167 deletions(-) create mode 100644 .claude/skills/app-coding-standards/rules/consistency-20-function-names.md create mode 100644 .github/workflows/nativeDependencies.yml create mode 100644 android/app/dependency-usage.json create mode 100644 docs/articles/new-expensify/connections/certinia/Manage-Vendor-Matching-for-Certinia.md create mode 100644 ios/pod-usage.json delete mode 100644 patches/link/details.md delete mode 100644 patches/link/link+2.1.1+001+hot-load.patch create mode 100644 scripts/checkUnusedGradleDependencies.ts create mode 100644 scripts/checkUnusedPods.ts create mode 100644 scripts/generateGradleDependencyUsage.ts create mode 100644 scripts/generatePodUsage.ts create mode 100644 scripts/gradleUsageShared.ts create mode 100644 scripts/nativeSourceComments.ts create mode 100644 scripts/nativeUsageManifest.ts create mode 100644 scripts/podUsageShared.ts create mode 100644 src/components/ReportOrNotFoundGuard.tsx create mode 100644 src/components/Table/columnResize/ColumnResizeHandle/index.native.tsx create mode 100644 src/components/Table/columnResize/ColumnResizeHandle/index.tsx create mode 100644 src/components/Table/columnResize/ColumnResizeScope/index.native.tsx create mode 100644 src/components/Table/columnResize/ColumnResizeScope/index.tsx create mode 100644 src/components/Table/columnResize/applyColumnWidthOverrides.ts create mode 100644 src/components/Table/columnResize/columnWidthExpressions.ts create mode 100644 src/components/Table/columnResize/getDraggedColumnWidth.ts create mode 100644 src/components/Table/columnResize/getMeasureWidthRef/index.native.ts create mode 100644 src/components/Table/columnResize/getMeasureWidthRef/index.ts create mode 100644 src/components/Table/columnResize/getResizableColumnLayout.ts create mode 100644 src/components/Table/columnResize/types.ts create mode 100644 src/components/Table/columnResize/useColumnResize/index.native.ts create mode 100644 src/components/Table/columnResize/useColumnResize/index.ts create mode 100644 src/components/Table/columnResize/useColumnResize/types.ts create mode 100644 src/components/Table/columnResize/useColumnResize/useLiveColumnWidths.ts create mode 100644 src/components/Table/columnResize/useColumnResize/useResizeIndicator.ts create mode 100644 src/components/Table/columnResize/useStoredColumnWidths/index.native.ts create mode 100644 src/components/Table/columnResize/useStoredColumnWidths/index.ts create mode 100644 src/hooks/useHasApprovalWorkflowWithNonMemberApprover.ts create mode 100644 src/hooks/useReceiptUpgradeCount.ts create mode 100644 src/hooks/useSaveMarketingAttribution.ts create mode 100644 src/libs/ReceiptStorage/receiptUpgrades.ts create mode 100644 src/libs/actions/MarketingAttribution.ts create mode 100644 src/libs/actions/TableColumnWidths.ts create mode 100644 src/pages/DynamicReportDetailsPage/ReportDetailsContent.tsx create mode 100644 src/pages/DynamicReportDetailsPage/ReportDetailsMenuTrackExpenseItems.tsx create mode 100644 src/pages/inbox/report/useReportDeepLinkOnOpen.ts create mode 100644 src/pages/inbox/report/useShouldEditInComposer.ts create mode 100644 src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts create mode 100644 src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts create mode 100644 src/selectors/TableColumnWidths.ts create mode 100644 src/types/onyx/MarketingAttribution.ts create mode 100644 src/types/onyx/TableColumnWidths.ts create mode 100644 tests/actions/MarketingAttributionTest.ts create mode 100644 tests/ui/DynamicIOURequestStepUpgradeTest.tsx create mode 100644 tests/ui/WorkspaceWorkflowsApprovalsApproverPageTest.tsx create mode 100644 tests/unit/ContextMenuActionsDeleteTest.ts create mode 100644 tests/unit/WorkspaceDuplicateSelectFeaturesFormTest.tsx create mode 100644 tests/unit/applyColumnWidthOverridesTest.ts create mode 100644 tests/unit/columnWidthExpressionsTest.ts create mode 100644 tests/unit/components/NavigationAwareCameraTest.tsx create mode 100644 tests/unit/components/Table/buildTableListDataTest.ts create mode 100644 tests/unit/components/Table/useColumnResizeTest.ts create mode 100644 tests/unit/components/withReportOrNotFound.test.tsx create mode 100644 tests/unit/getResizableColumnLayoutTest.ts create mode 100644 tests/unit/hooks/useLocalReceiptThumbnail.test.ts create mode 100644 tests/unit/hooks/usePhotoUpgrade.test.ts create mode 100644 tests/unit/hooks/useReceiptUpgradeCount.test.ts create mode 100644 tests/unit/libs/receiptReadDuringSwapTest.ts create mode 100644 tests/unit/libs/receiptUpgradesTest.ts create mode 100644 tests/unit/rotatePhotoToUprightTest.ts create mode 100644 tests/unit/selectors/TableColumnWidthsTest.ts diff --git a/.claude/skills/app-coding-standards/SKILL.md b/.claude/skills/app-coding-standards/SKILL.md index 7f1f76639aba..17ab10ef3c30 100644 --- a/.claude/skills/app-coding-standards/SKILL.md +++ b/.claude/skills/app-coding-standards/SKILL.md @@ -59,6 +59,7 @@ Coding standards for the Expensify App. Each standard is a standalone file in `r - [CONSISTENCY-17](rules/consistency-17-plural-form.md) — Pluralize with PluralForm, not ternaries on a count - [CONSISTENCY-18](rules/consistency-18-comment-non-obvious-code.md) — Comment code that is not self-explanatory - [CONSISTENCY-19](rules/consistency-19-copy-grammar-and-case.md) — User-facing copy is grammatical and sentence case +- [CONSISTENCY-20](rules/consistency-20-function-names.md) — Name functions with a verb that says what they do ### Clean React Patterns - [CLEAN-REACT-PATTERNS-0](rules/clean-react-0-compiler.md) — React Compiler compliance diff --git a/.claude/skills/app-coding-standards/rules/consistency-20-function-names.md b/.claude/skills/app-coding-standards/rules/consistency-20-function-names.md new file mode 100644 index 000000000000..0abad36df62b --- /dev/null +++ b/.claude/skills/app-coding-standards/rules/consistency-20-function-names.md @@ -0,0 +1,48 @@ +--- +ruleId: CONSISTENCY-20 +title: Name functions with a verb that says what they do +--- + +## [CONSISTENCY-20] Name functions with a verb that says what they do + +### Reasoning + +A function name that starts with a verb tells the caller what the function does without a trip to its body. A name made only of nouns or adjectives, such as `reportTotal`, reads as a value, so the reader cannot tell that the function computes something or what it returns. + +### Incorrect + +```ts +function reportTotal(transactions: Transaction[]): number { + // ... +} +``` + +### Correct + +```ts +function getReportTotal(transactions: Transaction[]): number { + // ... +} +``` + +--- + +### Review Metadata + +Flag ONLY when ALL of these are true: + +- The changed code **declares** a new or renamed function (a function declaration, or an arrow function or function expression assigned to a `const`/`let`) +- The name does not start with a verb, so it reads as a value rather than an action (e.g. `reportTotal`, `policyMembers`) + +**DO NOT flag if:** + +- The function is a React component, a higher-order component (`withX`), or a hook (`useX`) +- The function is an Onyx selector, which ends in `Selector` by convention (e.g. `isQBORefreshTokenExpiringSoonSelector`) +- The function is a conversion that follows the `toX()` / `fromX()` idiom (e.g. `toSafeReturnPath`) +- The name starts with `on` or `handle`, which [CONSISTENCY-12](consistency-12-callback-named-for-action.md) covers +- A parent class, an interface, a library, or another external contract sets the name +- The function already exists and the PR does not rename it +- The file is a test or story + +**Search Patterns** (hints for reviewers): +- `function [a-z]` / `const [a-z][A-Za-z]* = (` / `const [a-z][A-Za-z]* = async (` diff --git a/.github/workflows/nativeDependencies.yml b/.github/workflows/nativeDependencies.yml new file mode 100644 index 000000000000..9f34711c3445 --- /dev/null +++ b/.github/workflows/nativeDependencies.yml @@ -0,0 +1,52 @@ +name: Native dependency check + +on: + workflow_dispatch: + pull_request: + types: [opened, synchronize] + branches-ignore: [staging, production] + paths: + - '.github/workflows/nativeDependencies.yml' + - 'ios/Podfile' + - 'ios/pod-usage.json' + - 'ios/**.swift' + - 'ios/**.m' + - 'ios/**.mm' + - 'ios/**.h' + - 'ios/**.hpp' + - 'ios/**.pch' + - 'ios/**.c' + - 'ios/**.cpp' + - 'ios/**.cc' + - 'android/app/build.gradle' + - 'android/app/dependency-usage.json' + - 'android/app/**.java' + - 'android/app/**.kt' + - 'android/app/**.xml' + - 'scripts/checkUnusedPods.ts' + - 'scripts/checkUnusedGradleDependencies.ts' + - 'scripts/podUsageShared.ts' + - 'scripts/gradleUsageShared.ts' + - 'scripts/nativeSourceComments.ts' + - 'scripts/nativeUsageManifest.ts' + +concurrency: + group: ${{ github.ref == 'refs/heads/main' && format('{0}-{1}', github.ref, github.sha) || github.ref }}-nativeDependencies + cancel-in-progress: true + +jobs: + nativeDependencies: + name: Check for unused native dependencies + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 + + - name: Setup Node + uses: ./.github/actions/composite/setupNode + + - name: Check for unused pods + run: npx bun scripts/checkUnusedPods.ts + + - name: Check for unused Gradle dependencies + run: npx bun scripts/checkUnusedGradleDependencies.ts diff --git a/Mobile-Expensify b/Mobile-Expensify index 0bfa0070a60c..49dee986556d 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 0bfa0070a60ccba87234c3285b7057465c5bd5a2 +Subproject commit 49dee986556dc4fcf428ff658d3b0d3f2df30d22 diff --git a/android/app/dependency-usage.json b/android/app/dependency-usage.json new file mode 100644 index 000000000000..ba00283e1a43 --- /dev/null +++ b/android/app/dependency-usage.json @@ -0,0 +1,59 @@ +{ + "androidx.legacy:legacy-support-core-utils": { + "usage": "exempt", + "reason": "Umbrella artifact under the \"AndroidX support library\" comment; it ships no classes the sources import directly.", + "reviewed": "2026-08-28" + }, + "androidx.lifecycle:lifecycle-viewmodel-ktx": { + "usage": "exempt", + "reason": "Version pin for a transitive androidx.lifecycle; no Java or Kotlin source imports it. Consumer not identified \u2014 recheck when the androidx versions move.", + "reviewed": "2026-08-28" + }, + "androidx.multidex:multidex": { + "usage": "packages", + "packages": [ + "androidx.multidex" + ] + }, + "androidx.work:work-runtime-ktx": { + "usage": "exempt", + "reason": "Version pin only. android/app/build.gradle:240-242 records it as the fix for a conflict between airship and react-native-plaid-link-sdk.", + "reviewed": "2026-08-28" + }, + "com.facebook.fresco:animated-gif": { + "usage": "exempt", + "reason": "Sole supplier of the animated-GIF decoder for React Native. Removing it breaks the animations rather than the build.", + "reviewed": "2026-08-28" + }, + "com.facebook.fresco:fresco": { + "usage": "exempt", + "reason": "Image pipeline React Native draws through; the app reaches it via React Native rather than importing com.facebook.drawee itself.", + "reviewed": "2026-08-28" + }, + "com.facebook.react:hermes-android": { + "usage": "exempt", + "reason": "Supplies the Hermes JS engine as a native library, loaded at runtime rather than imported; ReactAndroid declares hermes-engine compileOnly, so nothing else brings it in.", + "reviewed": "2026-08-28" + }, + "com.facebook.react:react-android": { + "usage": "packages", + "packages": [ + "com.facebook.react" + ] + }, + "com.google.firebase:firebase-bom": { + "usage": "exempt", + "reason": "Platform BOM that aligns the Firebase versions React Native Firebase resolves; it declares no classes.", + "reviewed": "2026-08-28" + }, + "com.squareup.okhttp3:okhttp-urlconnection": { + "usage": "exempt", + "reason": "Version pin only. android/app/build.gradle:244 records it as preventing a crash with react-native-plaid-link-sdk.", + "reviewed": "2026-08-28" + }, + "io.github.react-native-community:jsc-android": { + "usage": "exempt", + "reason": "JavaScriptCore, declared through the jscFlavor variable in the else branch of the hermesEnabled check at android/app/build.gradle:218-222. android/gradle.properties sets hermesEnabled=true, so this branch is not taken today; the artifact is a native JS engine loaded by React Native rather than imported, so no source reference can prove or disprove it.", + "reviewed": "2026-09-17" + } +} diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 0c37ef30f930..5fec781e7e52 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -964,7 +964,6 @@ "../../src/pages/DynamicNewReportWorkspaceSelectionPage.tsx" "no-restricted-imports" 1 "../../src/pages/DynamicReportChangeApproverPage.tsx" "no-restricted-imports" 1 "../../src/pages/DynamicReportChangeWorkspacePage.tsx" "no-restricted-imports" 1 -"../../src/pages/DynamicReportDetailsPage/index.tsx" "no-restricted-imports" 1 "../../src/pages/DynamicReportParticipantDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/DynamicReportParticipantDetailsPage.tsx" "no-restricted-imports" 1 "../../src/pages/DynamicReportParticipantRoleSelectionPage.tsx" "no-restricted-imports" 1 @@ -1633,7 +1632,7 @@ "../../src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/members/WorkspaceInviteMessageComponent.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/workspace/members/WorkspaceMemberCustomFieldPage.tsx" "no-restricted-imports" 1 -"../../src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/members/WorkspaceOwnerChangeCheck.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/workspace/members/WorkspaceOwnerPaymentCardForm.tsx" "react-hooks/set-state-in-effect" 2 @@ -1710,7 +1709,6 @@ "../../src/pages/workspace/WorkspaceConfirmationOwnerSelectorPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/WorkspaceConfirmationSuccessPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/WorkspaceInitialPage.tsx" "no-restricted-imports" 1 -"../../src/pages/workspace/WorkspaceMembersPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/workspace/WorkspaceMembersPage.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx" "no-restricted-imports" 1 "../../src/pages/workspace/WorkspaceNamePage.tsx" "no-restricted-imports" 1 diff --git a/config/rsbuild/rsbuild.common.ts b/config/rsbuild/rsbuild.common.ts index f610e45b943e..caf4082088e8 100644 --- a/config/rsbuild/rsbuild.common.ts +++ b/config/rsbuild/rsbuild.common.ts @@ -442,7 +442,6 @@ const getCommonConfiguration = async ({file = '.env', platform = 'web', isDevSer // Rsbuild's default exclusion, plus the `.br` twins BrotliCompressionPlugin emits below: listing them would // double the report with a meaningless "gzipped size" of already-Brotli-compressed bytes. printFileSize: {exclude: (asset) => /\.(?:map|LICENSE\.txt|d\.(?:ts|mts|cts)|br)$/.test(asset.name)}, - // We have to load the whole lottie player to get the player to work in offline mode // heic-to library is used sparsely so we load it as a separate chunk to reduce initial bundle size // ExpensifyIcons/illustrations chunks are loaded eagerly for offline support // Vendor: extract all 3rd party deps (~75% of App) to a separate js file for better caching @@ -450,11 +449,6 @@ const getCommonConfiguration = async ({file = '.env', platform = 'web', isDevSer strategy: 'custom', splitChunks: { cacheGroups: { - lottiePlayer: { - test: /[\\/]node_modules[\\/](@dotlottie\/react-player)[\\/]/, - name: 'lottiePlayer', - chunks: 'all', - }, heicTo: { test: /[\\/]node_modules[\\/](heic-to)[\\/]/, name: 'heicTo', diff --git a/cspell.json b/cspell.json index f5f9e034144b..ab05306535c1 100644 --- a/cspell.json +++ b/cspell.json @@ -49,6 +49,7 @@ "BNDL", "BOFMCAM2", "BROWSABLE", + "bumptech", "bunfig", "BYOC", "BambooHr", @@ -125,6 +126,7 @@ "EUVAT", "elif", "evals", + "EXIF", "EXPENSIDEV", "EXPENSIFYAPI", "EXPENSIFYPDBUSINESS", @@ -138,6 +140,7 @@ "Ephermeral", "Erste", "Español", + "detekt", "Fabrikam", "fillyjonk", "hemulen", @@ -146,11 +149,16 @@ "IFSC", "imgbot", "issuecomment", + "kapt", + "modulemap", "mymble", "snorkmaiden", "ticky", "unparseable", + "urlconnection", + "viewmodel", "whomper", + "xcframework", "Ελληνικά", "Expatriot", "Expensable", @@ -176,6 +184,7 @@ "GDSO", "GEOLOCATION", "Gaber", + "Gbraid", "Gclid", "Geral", "gitlink", @@ -451,6 +460,7 @@ "WDYR", "Wallester", "Warchoł", + "Wbraid", "Webp", "Wintrust", "Woohoo", @@ -1125,6 +1135,7 @@ "utilise", "vcfanzfav", "vcfcitibank", + "vcfmock", "vcpu", "verticalanchor", "victoryaxis", diff --git a/docs/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite.md b/docs/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite.md index d7f677d402a0..9e4170dcd76b 100644 --- a/docs/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite.md +++ b/docs/articles/expensify-classic/connections/netsuite/Connect-To-NetSuite.md @@ -1,16 +1,18 @@ --- title: Connect to NetSuite description: Connect NetSuite to Expensify for streamlined expense reporting and accounting integration. -keywords: [NetSuite, integration, Expensify Classic, expense reporting, accounting, automated reporting] +keywords: [NetSuite, integration, Expensify Classic, expense reporting, accounting, automated reporting, NetSuite OAuth 2.0] order: 1 --- Expensify integrates directly with NetSuite to automate report exports, customize your coding preferences, and leverage NetSuite's advanced features. Follow the steps below to set up the integration. +**Note:** New NetSuite connections use OAuth 2.0, which you can only set up in New Expensify. To connect NetSuite, switch to New Expensify and follow the steps in [Connect to NetSuite in New Expensify](/articles/new-expensify/connections/netsuite/Connect-To-NetSuite). After you connect, the connection works in both Expensify Classic and New Expensify. + # Before You Connect NetSuite to Expensify -- **Token-based authentication** ensures each request to NetSuite is signed and verified for authenticity. +- **OAuth 2.0** is how new connections are set up. You set it up in New Expensify, and you need two-factor authentication enabled on your Expensify account. - You must have **administrator access** in NetSuite to initiate the connection. - **Control Plan** in Expensify is required for this integration. - Employees do not need NetSuite access or a license to submit expense reports; the integration is managed by the Workspace Admin. @@ -28,10 +30,14 @@ Expensify integrates directly with NetSuite to automate report exports, customiz 4. If you already have the Expensify Connect bundle installed, go to **Customization > SuiteBundler > Search & Install Bundles > List** and update to the latest version. 5. Ensure **Show on Existing Custom Forms** is selected for all available fields. +The latest bundle version includes the permissions Expensify needs for OAuth 2.0 and REST web services, so update it before you connect. + --- # Step 2: Enable Token-Based Authentication +**Note:** Steps 2–4 and Step 12 describe how existing token-based connections were set up. You can no longer create a new token-based connection. Existing token-based connections keep working until they migrate to OAuth 2.0, and we'll let you know well before token-based authentication is removed. To set up a new connection, follow the steps in [Connect to NetSuite in New Expensify](/articles/new-expensify/connections/netsuite/Connect-To-NetSuite). + 1. In NetSuite, navigate to **Setup > Company > Enable Features > SuiteCloud > Manage Authentication**. 2. Enable **Token-Based Authentication**. 3. Click **Save**. @@ -132,6 +138,8 @@ Ensure **Tax Groups** can be applied to expenses by setting the **Tax Code Lists # Step 12: Connect Expensify and NetSuite +**Note:** You can no longer create a new token-based connection. New connections use OAuth 2.0, and Expensify Classic directs you to New Expensify to complete the connection. The steps below show how existing token-based connections were set up. + 1. Log into Expensify as a **Workspace Admin** and go to **Settings > Workspaces > [Workspace Name] > Accounting > NetSuite**. 2. Click **Connect to NetSuite**. 3. Enter your **Account ID** (found in NetSuite under **Setup > Integration > Web Services Preferences**). @@ -163,5 +171,25 @@ To transfer the connection ownership: 4. Enter the new admin's email address and NetSuite account ID. 5. Follow the steps to reconfigure the connection. +Reconnecting creates a new connection, so you'll complete it in New Expensify using OAuth 2.0. + +--- + +## Why do I need New Expensify to connect NetSuite? + +The OAuth 2.0 connection and re-authorization flows are only available in New Expensify. After you connect, you can keep using Expensify Classic to manage your NetSuite settings and export reports. + +--- + +## Do I need to do anything about my existing NetSuite connection? + +No. Workspaces that already connect to NetSuite using token-based authentication keep working exactly as they do today. When it's time to re-authorize using OAuth 2.0, we'll let you know, and you'll complete it in New Expensify. + +--- + +## When is token-based authentication going away? + +We'll remove token-based authentication once every customer with an existing connection has migrated to OAuth 2.0. We'll give you notice and migration steps before that happens, so you don't need to do anything yet. + --- diff --git a/docs/articles/new-expensify/connections/certinia/Configure-Certinia.md b/docs/articles/new-expensify/connections/certinia/Configure-Certinia.md index 53e2208557d9..1f165a9df00d 100644 --- a/docs/articles/new-expensify/connections/certinia/Configure-Certinia.md +++ b/docs/articles/new-expensify/connections/certinia/Configure-Certinia.md @@ -84,7 +84,9 @@ If a report contains both reimbursable and non-reimbursable expenses, Expensify ## How to set a default vendor for Certinia (FFA) -Select a vendor from your Certinia FFA account. This vendor is assigned to non-reimbursable payable invoices. +Select **Default vendor**, then choose a vendor from your Certinia FFA account. This vendor is assigned to non-reimbursable expenses that don't already have a vendor assigned. + +If vendor matching is enabled for your Workspace, [learn how to manage vendor matching for Certinia](/articles/new-expensify/connections/certinia/Manage-Vendor-Matching-for-Certinia). --- @@ -135,7 +137,7 @@ No. Enabling Auto-sync does not affect previously approved or reimbursed reports ## How do reports map to records in Certinia? -- **FFA (Payable Invoices):** Account Name = the account linked to the submitter's email, Reference 1 = the report URL, Invoice Description = the report title. +- **FFA (Payable Invoices):** Account Name = the account linked to the submitter's email (for non-reimbursable expenses, the assigned vendor account is used when one is available), Reference 1 = the report URL, Invoice Description = the report title. - **PSA/SRP (Expense Reports):** Expense Report Name = the report title, Resource = the submitter's email, Description = the report URL, Approver = the Expensify report approver. ## How do I export tax? diff --git a/docs/articles/new-expensify/connections/certinia/Manage-Vendor-Matching-for-Certinia.md b/docs/articles/new-expensify/connections/certinia/Manage-Vendor-Matching-for-Certinia.md new file mode 100644 index 000000000000..169b42c742e4 --- /dev/null +++ b/docs/articles/new-expensify/connections/certinia/Manage-Vendor-Matching-for-Certinia.md @@ -0,0 +1,105 @@ +--- +title: Manage Vendor Matching for Certinia +description: Learn how Certinia FFA vendor matching assigns vendors to non-reimbursable expenses before they export as Payable Invoices, including automatic matching, manual selection, and default vendor behavior. +keywords: [Certinia, FinancialForce, FFA, vendor matching, vendor, company card expenses, Payable Invoices, default vendor] +internalScope: Audience is workspace admins using the Certinia FFA connection. Covers imported Certinia vendors, automatic and manual vendor assignment, default vendor behavior, how vendors affect Payable Invoice export, and vendors that are no longer valid. Does not cover Certinia connection setup, other Certinia configuration settings, Certinia PSA/SRP, or vendor matching for other accounting connections. +noindex: true +sitemap: false +--- + +# Manage Vendor Matching for Certinia + +Certinia vendor matching lets workspace admins review and update the vendor assigned to non-reimbursable expenses before they export to Certinia FFA as Payable Invoices. Expensify imports your Certinia vendor accounts, automatically matches vendors where possible, and lets admins set or update the **Vendor** field before export. This helps ensure expenses export to the correct vendor account instead of requiring manual corrections in Certinia. + +## Who can use Certinia vendor matching + +This feature is available to workspace admins whose workspace: + + - Is connected to Certinia using the FFA module. + - Has finished configuring the Certinia connection. + +Certinia vendor matching isn't available for Certinia PSA or SRP connections. + +Certinia vendor matching is rolling out gradually. If the **Vendors** feature and the **Vendor** field don't appear on a configured Certinia FFA workspace, they aren't enabled for your workspace yet. + +If your workspace isn't connected to Certinia yet, learn how to [connect to Certinia](/articles/new-expensify/connections/certinia/Connect-To-Certinia). + +## How vendors are matched to expenses + +Expensify assigns vendors automatically in the following order: + + - If a workspace merchant rule specifies a vendor, that vendor is assigned. + - Otherwise, Expensify automatically matches the merchant name against your imported Certinia vendor list. For example, **STARBUCKS #456 DOWNTOWN** matches **Starbucks**. + - If no match is found, the **Vendor** field remains empty until a workspace admin selects one. + +Whenever a vendor is assigned automatically, Concierge posts a system message on the expense indicating whether the vendor was set by a merchant rule or by vendor matching. + +## How to select a Certinia vendor on an expense + +1. Open the non-reimbursable expense. +2. Select **Vendor**. +3. Search for the vendor by name. +4. Select the vendor you want to assign. + +Once a vendor is selected manually, Expensify preserves that selection and won't overwrite it with automatic matching. + +![The expense details view for a non-reimbursable company card expense on a Certinia-connected workspace, with the Vendor row visible directly below Category and a matched vendor name shown]({{site.url}}/assets/images/Expense_select-vendor.png){:width="100%"} + +## Where to find your imported Certinia vendors + +1. In the navigation tabs (on the left on web, on the bottom on mobile), select **Workspaces > [workspace name]**. +2. Select **Vendors**. +3. Use **Find vendor** to search the list by name. + +Vendors are managed in Certinia, so the list is read-only in Expensify and refreshes when the connection syncs. Expensify imports Salesforce accounts of type Vendor or Supplier from your Certinia FFA account. + +## How to set a default vendor for Certinia + +1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Workspaces > [workspace name]**. +2. Select **Accounting**. +3. On the Certinia connection, select **Export**. +4. Select **Default vendor**. +5. Select a vendor from your imported Certinia vendor list. + +The default vendor is used only when an expense doesn't already have a vendor assigned. + +## How vendors export to Certinia + +When non-reimbursable expenses export to Certinia FFA, Expensify assigns each expense to a vendor account in the following order: + +1. The vendor selected on the expense, if it's still in your imported Certinia vendor list. +2. The vendor account set for the company card, if one is set. +3. The **Default vendor** configured in the Certinia export settings. +4. The Certinia account linked to the report submitter's email. + +Expensify creates one Payable Invoice for each combination of company card and vendor account on the report. For example, a report with expenses from two vendors on the same card exports as two Payable Invoices. + +Because a matched vendor or default vendor can be used, non-reimbursable expenses can export even if the report submitter isn't set up as a contact in Certinia. + +Reimbursable expenses aren't affected by vendor matching. They continue to export as one Payable Invoice made out to the employee. + +## How vendors that are no longer valid affect expenses + +If a vendor assigned to an expense is removed or archived in Certinia, the expense displays a **Vendor no longer valid** error after the connection syncs, similar to category, tag, and tax violations. + +Select an active vendor on the expense to clear the error. + +# FAQ + +## Do I have to set a vendor? + +No. The **Vendor** field is optional. + +Expensify automatically attempts to match a vendor using your imported Certinia vendor list. If no match is found, the field can remain blank, and the expense exports using the next available vendor account described in the export order above. + +## Does manually assigning a vendor stop automatic matching? + +Yes. Once a workspace admin manually assigns a vendor to an expense, Expensify preserves that selection and won't replace it with automatic matching. + +## Why don't I see any vendors to choose from? + +The vendor selector shows **No vendors found** when your Certinia vendor list is empty. Add the vendors in Certinia, then sync the connection again. + +## Why is vendor matching missing on my Certinia PSA workspace? + +Vendor matching is only available for Certinia FFA connections. Certinia PSA and SRP connections don't support vendor matching. diff --git a/docs/articles/new-expensify/connections/netsuite/Connect-To-NetSuite.md b/docs/articles/new-expensify/connections/netsuite/Connect-To-NetSuite.md index 6b8647d7a60c..36afe0f0a109 100644 --- a/docs/articles/new-expensify/connections/netsuite/Connect-To-NetSuite.md +++ b/docs/articles/new-expensify/connections/netsuite/Connect-To-NetSuite.md @@ -1,137 +1,143 @@ --- title: Connect to NetSuite -description: Connect NetSuite to New Expensify for streamlined expense reporting and accounting integration. -keywords: [New Expensify, NetSuite integration, connect NetSuite, NetSuite bundle, accounting sync] +description: Connect NetSuite to New Expensify for streamlined expense reporting and accounting sync. +keywords: [New Expensify, NetSuite integration, connect NetSuite, NetSuite OAuth 2.0, NetSuite REST web services, NetSuite token-based authentication, NetSuite bundle, accounting sync] +internalScope: Audience is Workspace admins on the Control plan who are also NetSuite administrators, covers setting up a new NetSuite connection using OAuth 2.0 and REST web services plus the legacy token-based authentication steps that are still supported, does not cover configuring import/export settings after connecting or troubleshooting a broken connection order: 1 --- +# Connect to NetSuite -Connect your Expensify Workspace to NetSuite to streamline expense syncing, reporting, and accounting. This guide walks you through installing the bundle, enabling features, and finalizing the connection. +Connect your Expensify Workspace to NetSuite to streamline expense syncing, reporting, and accounting. New connections use OAuth 2.0 and NetSuite's REST web services, so you approve access on a NetSuite consent screen instead of creating and copying access tokens. -**Note:** You must have a NetSuite account and be on the **Control** plan in Expensify. +This guide walks you through preparing NetSuite, connecting Expensify, and confirming the connection. Token-based authentication is still supported and documented below, but OAuth 2.0 is the recommended way to set up a new connection. -**Before you begin, make sure:** +--- + +## What you need before you connect to NetSuite + +- A NetSuite account you can log into as an administrator +- The **Control** plan in Expensify +- Two-factor authentication enabled on your Expensify account, which NetSuite requires to authorize an OAuth 2.0 connection +- One Expensify **Workspace** per NetSuite **subsidiary** +- A Workspace currency that matches the NetSuite subsidiary's default currency + +Employees submitting reports don't need NetSuite access or licenses. + +--- + +## How to connect to NetSuite + +Complete these steps in order. Each step has its own section below with detailed instructions. -- You can log into NetSuite as an administrator -- Each NetSuite **subsidiary** maps to a separate Expensify **Workspace** -- Your Workspace currency matches the NetSuite subsidiary's default currency -- Employees submitting reports don’t need NetSuite access or licenses +1. Install the Expensify bundle in NetSuite +2. Enable OAuth 2.0 in NetSuite +3. Enable REST web services in NetSuite +4. Enable expense reports in NetSuite +5. Set up expense categories in NetSuite +6. Check your journal entry transaction forms +7. Check your expense report transaction forms +8. Check your vendor bill transaction forms +9. Check your vendor credit transaction forms +10. Set up tax groups in NetSuite +11. Connect Expensify to NetSuite using OAuth 2.0 + +If you're connecting with token-based authentication instead, follow the steps in **How to connect to NetSuite using token-based authentication** below. --- -# Step 1: Install the Expensify bundle in NetSuite +## How to install the Expensify bundle in NetSuite 1. In NetSuite, go to **Customization > SuiteBundler > Search & Install Bundles** 2. Search for **Expensify** and select the **Expensify Connect** bundle (Bundle ID `283395`) 3. Click **Install** -4. If already installed, go to **SuiteBundler > List** and update to the latest version +4. If the bundle is already installed, go to **SuiteBundler > List** and update to the latest version 5. Select **Show on Existing Custom Forms** for all available fields ---- - -# Step 2: Enable Token-Based Authentication - -1. Go to **Setup > Company > Enable Features > SuiteCloud > Manage Authentication** -2. Enable **Token Based Authentication** -3. Click **Save** +The latest bundle version grants the permissions Expensify needs to log in using OAuth 2.0 access tokens and to use REST web services, so update it before you connect. --- -# Step 3: Assign the Expensify Integration Role +## How to enable OAuth 2.0 in NetSuite -1. Go to **Lists > Employees** -2. Find and edit the employee who will connect the integration -3. Under the **Access** tab, add the **Expensify Integration** role -4. Click **Save** +1. In NetSuite, go to **Setup > Company > Enable Features > SuiteCloud** +2. Under **Manage Authentication**, enable **OAuth 2.0** +3. Click **Save** -**Reminder:** Tokens link to a **user-role combination**, not just a user. You must use the same user and role for all syncs after setup. +Skip this section if you're connecting with token-based authentication instead. --- -# Step 4: Create access tokens +## How to enable REST web services in NetSuite -1. In NetSuite, use the Global Search to search for `page: tokens` -2. Click **New Access Token** -3. Select **Expensify** as the application and the **Expensify Integration** role -4. Click **Save** -5. Copy and securely save the **Token ID** and **Token Secret** (you’ll only see them once) +1. In NetSuite, go to **Setup > Company > Enable Features > SuiteCloud** +2. Under **SuiteTalk (Web Services)**, enable **REST Web Services** +3. Click **Save** + +Skip this section if you're connecting with token-based authentication instead. --- -# Step 5: Enable Expense Reports in NetSuite +## How to enable expense reports in NetSuite 1. Go to **Setup > Company > Enable Features > Employees** -2. Ensure **Expense Reports** is enabled -3. If not, check the box and click **Save** +2. Make sure **Expense Reports** is enabled +3. If it isn't, check the box and click **Save** --- -# Step 6: Set up Expense Categories in NetSuite +## How to set up expense categories in NetSuite 1. Go to **Setup > Accounting > Expense Categories** 2. If none are listed, click **New** to create them -**Note:** Expense Categories map to Expensify Categories and are linked to GL accounts for easy employee selection. +Expense categories map to Expensify categories and are linked to GL accounts so employees can select them easily. --- -# Step 7: Check Journal Entry transaction forms +## How to check your journal entry transaction forms 1. Go to **Customization > Forms > Transaction Forms** 2. Edit the **Standard Journal Entry** form: - - Under **Main** tab, make sure: - - **Created From** is shown - - Display type is **Normal** - - Under **Lines**, ensure **Receipt URL** is shown + - Under the **Main** tab, make sure **Created From** is shown and its display type is **Normal** + - Under **Lines**, make sure **Receipt URL** is shown 3. Repeat for all journal-type forms --- -# Step 8: Check Expense Report transaction forms +## How to check your expense report transaction forms 1. Go to **Customization > Forms > Transaction Forms** 2. Edit the **Standard Expense Report** form: - - Under **Main** tab: - - **Created From** should be shown - - Under **Expenses** subtab: - - **Receipt URL** should be shown + - Under the **Main** tab, make sure **Created From** is shown + - Under the **Expenses** subtab, make sure **Receipt URL** is shown 3. Repeat for all expense report-type forms --- -# Step 9: Check Vendor Bill transaction forms +## How to check your vendor bill transaction forms 1. Go to **Customization > Forms > Transaction Forms** 2. Edit your Vendor Bill form: - - Under **Main** tab: - - Show “Created From” - - Hide **Department**, **Class**, and **Location** - - Under **Expenses** subtab: - - Show **Receipt URL**, **Department**, **Location**, **Class** + - Under the **Main** tab, show **Created From** and hide **Department**, **Class**, and **Location** + - Under the **Expenses** subtab, show **Receipt URL**, **Department**, **Location**, and **Class** 3. Repeat for all vendor bill-type forms --- -# Step 10: Check Vendor Credit transaction forms +## How to check your vendor credit transaction forms 1. Go to **Customization > Forms > Transaction Forms** 2. Edit your Vendor Credit form: - - Under **Main** tab: - - Show “Created From” - - Hide **Department**, **Class**, and **Location** - - Under **Expenses** subtab: - - Show **Receipt URL**, **Department**, **Location**, **Class** + - Under the **Main** tab, show **Created From** and hide **Department**, **Class**, and **Location** + - Under the **Expenses** subtab, show **Receipt URL**, **Department**, **Location**, and **Class** 3. Repeat for all vendor credit-type forms --- -# Step 11: Set up Tax Groups (if applicable) - -**Things to note about tax:** +## How to set up tax groups in NetSuite -- Expensify supports Tax Groups, not individual Tax Codes (except when using SuiteTax) -- Set **Tax Code Lists Include** to “Tax Groups And Tax Codes” or “Tax Groups Only” under **Setup > Accounting > Set Up Taxes** -- UK and Ireland: ensure each Tax Group contains only one Tax Code +Expensify supports Tax Groups, not individual Tax Codes, except when you use SuiteTax. Under **Setup > Accounting > Set Up Taxes**, set **Tax Code Lists Include** to **Tax Groups And Tax Codes** or **Tax Groups Only**. In the UK and Ireland, make sure each Tax Group contains only one Tax Code. To create a Tax Group: @@ -141,21 +147,27 @@ To create a Tax Group: 4. Choose a Tax Code and click **Add** 5. Click **Save** 6. Repeat for each tax rate you want to show in Expensify + --- -# Step 12: Connect Expensify to NetSuite +## How to connect Expensify to NetSuite using OAuth 2.0 -1. From the navigation tabs (on the left on web, and at the bottom on mobile), go to **Workspaces > [Workspace Name] > Accounting** +1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Workspaces > [Workspace Name] > Accounting** 2. Click **Connect** next to **NetSuite** -3. Click **Next** through setup steps 1–4 -4. On **Step 5**, enter: - - **NetSuite Account ID** - - Note: If connecting to a Sandbox environment, in the Account ID, ensure the "s" and "b" are always capitalized and the hyphen "-" is replaced by an underscore "_". So "123456-sb1" would be "123456_SB1". - - **Token ID** - - **Token Secret** -5. Click **Confirm** +3. Click **Next** through the NetSuite setup steps +4. Enter your **NetSuite Account ID** +5. Click **Connect** +6. On the NetSuite consent screen, sign in if prompted and approve access for Expensify + +If you're connecting to a Sandbox environment, capitalize the "s" and "b" in the Account ID and replace the hyphen with an underscore. For example, `123456-sb1` becomes `123456_SB1`. -You can find your **Account ID** in NetSuite under **Setup > Integration > Web Services Preferences.** +You can find your Account ID in NetSuite under **Setup > Integration > Web Services Preferences**. + + ![The New Expensify workspace setting is open and the More Features tab is selected and visible. The toggle to enable Accounting is highlighted with an orange call out and is currently in the grey disabled position.]({{site.url}}/assets/images/ExpensifyHelp-Xero-1.png) @@ -163,20 +175,97 @@ You can find your **Account ID** in NetSuite under **Setup > Integration > Web S --- -# After Setup +## What happens after you connect to NetSuite - The NetSuite connection syncs within 1–2 minutes -- Newly approved and paid reports will export to NetSuite via SOAP Web Services -- Continue with configuring settings by visiting: - [Configure NetSuite in Expensify](https://help.expensify.com/articles/new-expensify/connections/netsuite/Configure-Netsuite) +- Newly approved and paid reports export to NetSuite +- Next, choose your import, export, and advanced settings. [Learn how to configure NetSuite in Expensify](/articles/new-expensify/connections/netsuite/Configure-Netsuite). + +--- + +## How to connect to NetSuite using token-based authentication + +**Note:** Token-based authentication still works, but we're deprecating it. We'll remove it as soon as every customer with an existing connection has migrated to the new OAuth 2.0 connection, and we'll let you know well before that happens. If you're setting up a new connection, use OAuth 2.0 instead. + +Complete these steps in order. Steps 1 and 5–11 are the same as for an OAuth 2.0 connection. Steps 2–4 and 12 have their own sections below. + +1. Install the Expensify bundle in NetSuite +2. Enable token-based authentication in NetSuite +3. Assign the Expensify Integration role in NetSuite +4. Create access tokens in NetSuite +5. Enable expense reports in NetSuite +6. Set up expense categories in NetSuite +7. Check your journal entry transaction forms +8. Check your expense report transaction forms +9. Check your vendor bill transaction forms +10. Check your vendor credit transaction forms +11. Set up tax groups in NetSuite +12. Connect Expensify to NetSuite using access tokens + +--- + +## How to enable token-based authentication in NetSuite + +1. Go to **Setup > Company > Enable Features > SuiteCloud > Manage Authentication** +2. Enable **Token Based Authentication** +3. Click **Save** + +--- + +## How to assign the Expensify Integration role in NetSuite + +1. Go to **Lists > Employees** +2. Find and edit the employee who will connect the integration +3. Under the **Access** tab, add the **Expensify Integration** role +4. Click **Save** + +Tokens link to a user-role combination, not just a user. Use the same user and role for all syncs after setup. + +--- + +## How to create access tokens in NetSuite + +1. In NetSuite, use the Global Search to search for `page: tokens` +2. Click **New Access Token** +3. Select **Expensify** as the application and the **Expensify Integration** role +4. Click **Save** +5. Copy and securely save the **Token ID** and **Token Secret**, which you'll only see once + +--- + +## How to connect Expensify to NetSuite using access tokens + +1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Workspaces > [Workspace Name] > Accounting** +2. Click **Connect** next to **NetSuite** +3. Click **Next** through the NetSuite setup steps +4. Enter your **NetSuite Account ID**, **Token ID**, and **Token Secret** +5. Click **Confirm** + +If you're connecting to a Sandbox environment, capitalize the "s" and "b" in the Account ID and replace the hyphen with an underscore. For example, `123456-sb1` becomes `123456_SB1`. --- # FAQ +## Why do I need two-factor authentication to connect NetSuite? + +NetSuite requires two-factor authentication to authorize an OAuth 2.0 connection. If two-factor authentication isn't enabled on your Expensify account, Expensify prompts you to enable it before you can continue. Token-based connections don't require it. + +## Do I still need to create access tokens in NetSuite? + +Not if you connect with OAuth 2.0. You approve access on a NetSuite consent screen instead, so there are no token IDs or token secrets to create, copy, or store. You only create access tokens if you connect with token-based authentication, which we're deprecating. + +## How are existing NetSuite connections affected by the new OAuth connection option? + +Workspaces that already connect to NetSuite using token-based authentication keep working exactly as they do today. You don't need to reinstall the bundle, create new tokens, or reconnect. Expensify will let you know before anything changes for existing connections. + +## When is token-based authentication going away? + +We'll remove token-based authentication once every customer with an existing connection has migrated to OAuth 2.0. We'll give you notice and migration steps before that happens, so you don't need to do anything yet. + ## How do I ensure smooth import of customer and vendor data from NetSuite? -Set the **Page Size** to 1000 in NetSuite. To do this: +Set the **Page Size** to 1000 in NetSuite: + 1. Go to **Setup > Integration > Web Services Preferences** 2. Search for and update the **Page Size** setting - diff --git a/docs/articles/new-expensify/connections/quickbooks-online/Configure-Quickbooks-Online.md b/docs/articles/new-expensify/connections/quickbooks-online/Configure-Quickbooks-Online.md index 95703706d2d1..95c5d2fc0cce 100644 --- a/docs/articles/new-expensify/connections/quickbooks-online/Configure-Quickbooks-Online.md +++ b/docs/articles/new-expensify/connections/quickbooks-online/Configure-Quickbooks-Online.md @@ -56,9 +56,9 @@ To choose how data is exported from Expensify to QuickBooks Online: - **Export company card expenses as**: - Credit card (default) - Debit card - - Optionally set a **Default vendor** as a fallback for expenses that don't have a vendor set. Learn how vendors are imported, matched, and exported in [QuickBooks Online Vendor Matching](/articles/new-expensify/connections/quickbooks-online/Manage-Vendor-Matching-for-QuickBooks-Online.md). + - For both Credit card and Debit card, optionally select a **Default vendor** to apply to expenses that don't auto-match a vendor. If you leave it blank, those expenses export as **Credit Card Misc** or **Debit Card Misc**. Learn how vendors are imported, matched, and exported in [QuickBooks Online Vendor Matching](/articles/new-expensify/connections/quickbooks-online/Manage-Vendor-Matching-for-QuickBooks-Online). - Vendor bill - - If exporting as vendor bills, select the AP account and optionally a default vendor + - If exporting as vendor bills, select the AP account and optionally select a **Default vendor**. QuickBooks Online doesn't substitute a **Credit Card Misc** or **Debit Card Misc** vendor on vendor bill exports. - Vendor bill is unavailable when locations are imported as tags. To keep it available, import locations as report fields (only available on the **Control** plan) - **Export Consolidated Travel Billing expenses as**: This option appears only when [Consolidated Travel Billing](/travel/hubs/consolidated-travel-billing/) is enabled. Travel expenses export as credit card charges. Click it, then select the **Credit card account** where travel expenses will post in QuickBooks Online. @@ -74,7 +74,7 @@ To manage automation and other connection preferences: - **Auto-sync**: Automatically updates data between QuickBooks Online and Expensify - **Invite employees**: Imports and invites employees from QuickBooks Online -- **Automatically create entities**: If a vendor/customer doesn’t exist, Expensify will create one +- **Auto-create entities**: Expensify automatically creates vendors in QuickBooks Online if they don’t exist already, and auto-creates customers when exporting invoices. Turning this setting on or off doesn’t change the **Default vendor** you selected under **Export** - **Sync reimbursed reports**: - If marked as paid in QuickBooks Online, the report will show as reimbursed in Expensify - If reimbursed via ACH in Expensify, the status will sync to paid in QuickBooks diff --git a/docs/articles/new-expensify/connections/quickbooks-online/Manage-Vendor-Matching-for-QuickBooks-Online.md b/docs/articles/new-expensify/connections/quickbooks-online/Manage-Vendor-Matching-for-QuickBooks-Online.md index 5faca8c6b61e..d7383b266189 100644 --- a/docs/articles/new-expensify/connections/quickbooks-online/Manage-Vendor-Matching-for-QuickBooks-Online.md +++ b/docs/articles/new-expensify/connections/quickbooks-online/Manage-Vendor-Matching-for-QuickBooks-Online.md @@ -47,7 +47,7 @@ When company card expenses are exported, Expensify assigns vendors in the follow 1. The vendor selected on the expense. 2. The **Default vendor** configured in workspace settings. -3. **Credit Card Misc** or **Debit Card Misc**., if neither of the above is available. +3. **Credit Card Misc** or **Debit Card Misc**, if neither of the above is available. ## How inactive QuickBooks Online vendors affect expenses diff --git a/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-US.md b/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-US.md index 103f7634afb3..9a473a4ee10f 100644 --- a/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-US.md +++ b/docs/articles/new-expensify/expensify-card/Set-Up-and-Manage-the-Expensify-Card-US.md @@ -92,7 +92,8 @@ When issuing a card, you can also apply an existing Spend Rule or create a new o - Limit type - Card name - Deactivation -4. To change the linked bank account, update settlement frequency, or manage cash back settings, click **Settings**. +4. On the web, you can also hover over a card's **Name**, **Limit type**, or **Limit** in the list and click the pencil icon that appears to edit it inline. +5. To change the linked bank account, update settlement frequency, or manage cash back settings, click **Settings**. Your workspace also has built-in default protections that automatically block certain transaction categories on every Expensify Card, and you can set Spend Rules to approve or decline transactions in real time. To learn more, [learn how to set up Expensify Card Spend Rules](/articles/new-expensify/expensify-card/Expensify-Card-Spend-Rules). diff --git a/docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md b/docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md index ec6190b6f080..72bd6e6349cb 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md +++ b/docs/articles/new-expensify/reports-and-expenses/Distance-Expenses.md @@ -1,7 +1,7 @@ --- title: Distance Expenses description: Learn how to create a Distance expense using GPS tracking, map-based routes, manual entry, or odometer readings, and how the reimbursement rate is determined in New Expensify. -keywords: [New Expensify, distance expense, mileage, mileage reimbursement, create expense, distance rate, workspace rate, map route, reimbursement rate, manual mileage, manual distance, global create, track distance, GPS, GPS tracking, start GPS, track route, track mileage, mileage tracking, calculate mileage reimbursement, mileage rate, odometer, odometer reading, odometer image, odometer mileage, odometer distance, start reading, end reading, save for later, in-progress odometer, incomplete odometer, edit distance, edit waypoints, manual edit distance, edit stop, trim trip, edit GPS trip, shorten GPS route, GPS slider, total distance, stop GPS trip, stop without moving, resume GPS trip, discard GPS trip, zero distance trip, same start and stop location, no edit icon, can't create expense, change distance rate, edit distance rate, move distance expense, move expense to another workspace, rate not valid for this workspace, distance rate violation] +keywords: [New Expensify, distance expense, mileage, mileage reimbursement, create expense, distance rate, workspace rate, map route, reuse route, reuse prior route, reimbursement rate, manual mileage, manual distance, global create, track distance, GPS, GPS tracking, start GPS, track route, track mileage, mileage tracking, calculate mileage reimbursement, mileage rate, odometer, odometer reading, odometer image, odometer mileage, odometer distance, start reading, end reading, save for later, in-progress odometer, incomplete odometer, edit distance, edit waypoints, manual edit distance, edit stop, trim trip, edit GPS trip, shorten GPS route, GPS slider, total distance, stop GPS trip, stop without moving, resume GPS trip, discard GPS trip, zero distance trip, same start and stop location, no edit icon, can't create expense, change distance rate, edit distance rate, move distance expense, move expense to another workspace, rate not valid for this workspace, distance rate violation] internalScope: Audience is all members. Covers creating Distance expenses using GPS tracking, map-based routes, manual entry, and odometer readings, plus how reimbursement rates are applied. Does not cover configuring Workspace distance rates in detail or broader report submission workflows. --- @@ -39,6 +39,7 @@ To create an expense using distance between the starting and ending locations of 2. Select **Map** from the top row. 3. Enter the **Start** and **Stop** locations. - To include additional stops, select **Add stop**. + - To use a route from a map-based Distance expense you created in the last 90 days, select **Reuse route** and choose the route. The stops and distance fill in automatically. 4. If more than one driving route is available, the map draws both routes. The selected route is highlighted in darker green with its distance shown in a dark label, and the alternate route appears in lighter green with a lighter distance label. To choose the alternate route, tap it on the map — it becomes the selected route, and the distance and amount update to match. 5. Select **Next**. 6. On the confirmation screen, review and confirm: diff --git a/docs/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify.md b/docs/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify.md index b08dfee40b07..7e54625df02b 100644 --- a/docs/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify.md +++ b/docs/articles/new-expensify/reports-and-expenses/Using-Reports-in-New-Expensify.md @@ -145,7 +145,7 @@ To view saved searches: Saved searches are listed in alphabetical order by name. -To rename a saved search, click **Save** while viewing it and enter a new name. You can also rename, share, or delete a saved search by selecting the three dots **(⋮)** next to it. Selecting **Share** copies the search URL to your clipboard. +To update a saved search, change its filters while viewing it, click **Save**, then select **Update existing**. To keep the original and save your changes as a new search, select **Create new** instead. You can rename, share, or delete a saved search by selecting the three dots **(⋮)** next to it. Selecting **Share** copies the search URL to your clipboard. ## Why you have a My expenses saved search diff --git a/docs/articles/new-expensify/workspaces/Configure-Per-Diem-in-a-workspace.md b/docs/articles/new-expensify/workspaces/Configure-Per-Diem-in-a-workspace.md index 3f0105690157..7c7fc9610458 100644 --- a/docs/articles/new-expensify/workspaces/Configure-Per-Diem-in-a-workspace.md +++ b/docs/articles/new-expensify/workspaces/Configure-Per-Diem-in-a-workspace.md @@ -54,6 +54,7 @@ Each rate is listed as a line item. You can: - Select one or more rates to edit or delete. - Click a rate to adjust its details. +- On the web, hover over a rate's **Destination**, **Subrate**, or **Amount** and click the pencil icon that appears to edit it inline. - Use the **X selected** dropdown to apply bulk actions like delete. ![Edit rates using the right-hand panel]({{site.url}}/assets/images/perdiem_03.png){:width="100%"} diff --git a/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md b/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md index 5a1eaa4943dc..aa9a38957be4 100644 --- a/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md +++ b/docs/articles/new-expensify/workspaces/Managing-Workspace-Members.md @@ -92,6 +92,8 @@ If someone no longer needs access to your workspace, you can remove them individ 3. In the right-hand panel, click **Roles**. 4. Select the new role and confirm. +On the web, you can also change a member’s role directly from the members list. Hover over the member’s **Role** and click the pencil icon that appears to select a new role. + **Note:** People admins can change a member’s role between **Member** and **Auditor** only. Granting or removing the admin role or any scoped admin role (such as Card admin, People admin, or Payments admin) requires a Workspace admin. **Note:** You can’t change the role of the member set as the workspace’s authorized payer. Their **Role** can’t be edited and role-change options are hidden until you assign a different payer. To change their role, first go to **Workspaces > Workflows**, open **Payments**, and set a different admin as the **Payer**. diff --git a/docs/articles/new-expensify/workspaces/Set-distance-rates.md b/docs/articles/new-expensify/workspaces/Set-distance-rates.md index bcead478f396..c58d7b8a7ede 100644 --- a/docs/articles/new-expensify/workspaces/Set-distance-rates.md +++ b/docs/articles/new-expensify/workspaces/Set-distance-rates.md @@ -71,6 +71,8 @@ Once **Track tax** is enabled, you can assign a tax rate and tax reclaimable amo - **Tax reclaimable** (if enabled) 5. Select **Save**. +On the web, you can also edit a rate's **Name** or **Rate** directly from the table. Hover over the value and click the pencil icon that appears to edit it inline. + --- ## How to disable or delete distance rates diff --git a/ios/pod-usage.json b/ios/pod-usage.json new file mode 100644 index 000000000000..e49c2d884c52 --- /dev/null +++ b/ios/pod-usage.json @@ -0,0 +1,28 @@ +{ + "AirshipServiceExtension": { + "usage": "module", + "module": "AirshipServiceExtension" + }, + "AppLogs": { + "usage": "module", + "module": "AppLogs" + }, + "FullStory": { + "usage": "exempt", + "reason": "Pinned here to the 1.70.1 xcframework tarball; fullstory_react-native depends on FullStory (~> 1.14) and would otherwise resolve it itself.", + "reviewed": "2026-08-28" + }, + "GzipSwift": { + "usage": "module", + "module": "Gzip" + }, + "NWWebSocket": { + "usage": "exempt", + "reason": "Never imported. PusherSwift depends on NWWebSocket (~> 0.5.7); this declaration only holds the floor at ~> 0.5.10.", + "reviewed": "2026-08-28" + }, + "TrustKit": { + "usage": "module", + "module": "TrustKit" + } +} diff --git a/knip.json b/knip.json index 32c6af6ddd9d..8fba9bd02c97 100644 --- a/knip.json +++ b/knip.json @@ -68,14 +68,9 @@ } }, "ignoreDependencies": [ - "@expensify/react-native-hybrid-app", "focus-trap", - "group-ib-fp", - "react-native-image-size", - "react-native-picker-select", "react-native-quick-crypto", "react-native-quick-base64", - "lodash", "@babel/plugin-proposal-private-methods", "@babel/plugin-proposal-private-property-in-object", "@babel/plugin-transform-export-namespace-from", @@ -85,7 +80,6 @@ "html-entities", "@fullstory/babel-plugin-react-native", "@fullstory/babel-plugin-annotate-react", - "eslint-config-airbnb-typescript", "eslint-config-prettier", "eslint-plugin-storybook", "@dword-design/eslint-plugin-import-alias", diff --git a/package-lock.json b/package-lock.json index 73782a1c2f57..f656c125faa8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,14 +17,12 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", - "@dotlottie/react-player": "^1.6.3", "@expensify/nitro-utils": "file:./modules/ExpensifyNitroUtils", "@expensify/react-native-background-task": "file:./modules/background-task", "@expensify/react-native-hybrid-app": "file:./modules/hybrid-app", "@expensify/react-native-live-markdown": "0.1.342", "@expensify/react-native-wallet": "0.1.22", "@expo/metro-config": "57.0.7", - "@expo/metro-runtime": "57.0.7", "@formatjs/intl-listformat": "^7.5.7", "@formatjs/intl-locale": "^4.0.0", "@formatjs/intl-numberformat": "^8.10.3", @@ -268,7 +266,6 @@ "jest-transformer-svg": "^2.0.1", "knip": "^6.34.0", "lefthook": "2.1.9", - "link": "^2.1.1", "memfs": "^4.6.0", "nitrogen": "0.37.1", "onchange": "^7.1.0", @@ -5112,44 +5109,6 @@ "react": ">=16.8.0" } }, - "node_modules/@dotlottie/common": { - "version": "0.7.11", - "license": "MIT", - "dependencies": { - "@dotlottie/dotlottie-js": "^0.7.0", - "@preact/signals-core": "^1.2.3", - "howler": "^2.2.3", - "lottie-web": "^5.12.2", - "xstate": "^4.38.1" - }, - "engines": { - "node": ">18.0.0" - } - }, - "node_modules/@dotlottie/dotlottie-js": { - "version": "0.7.2", - "license": "MIT", - "dependencies": { - "browser-image-hash": "^0.0.5", - "fflate": "^0.8.1", - "sharp": "^0.33.2", - "sharp-phash": "^2.1.0", - "valibot": "^0.13.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@dotlottie/react-player": { - "version": "1.6.19", - "license": "MIT", - "dependencies": { - "@dotlottie/common": "0.7.11" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, "node_modules/@dword-design/dedent": { "version": "0.7.0", "dev": true, @@ -5230,6 +5189,7 @@ "version": "1.11.2", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -7760,31 +7720,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/@expo/metro-runtime": { - "version": "57.0.7", - "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-57.0.7.tgz", - "integrity": "sha512-95UeoN/YsLellvskKsFGN9vKBwNc5k70ysO3skqfL3VusWlYIiYmPY+MpEWNz8A8W2CjLg9AKwZKAGrFj6znQg==", - "license": "MIT", - "dependencies": { - "@expo/log-box": "^57.0.1", - "anser": "^1.4.9", - "pretty-format": "^29.7.0", - "stacktrace-parser": "^0.1.10", - "whatwg-fetch": "^3.0.0" - }, - "peerDependencies": { - "@expo/log-box": "^57.0.1", - "expo": "*", - "react": "*", - "react-dom": "*", - "react-native": "*" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, "node_modules/@expo/osascript": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.7.1.tgz", @@ -10270,363 +10205,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.2.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@invertase/react-native-apple-authentication": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/@invertase/react-native-apple-authentication/-/react-native-apple-authentication-2.5.0.tgz", @@ -14347,14 +13925,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@preact/signals-core": { - "version": "1.8.0", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "license": "BSD-3-Clause" @@ -15305,33 +14875,6 @@ "@types/react": "^18.0.0" } }, - "node_modules/@rgba-image/common": { - "version": "0.1.13", - "license": "MIT" - }, - "node_modules/@rgba-image/copy": { - "version": "0.1.3", - "license": "MIT", - "dependencies": { - "@rgba-image/common": "^0.1.13" - } - }, - "node_modules/@rgba-image/create-image": { - "version": "0.1.1", - "license": "MIT", - "dependencies": { - "@rgba-image/common": "^0.1.0" - } - }, - "node_modules/@rgba-image/lanczos": { - "version": "0.1.1", - "license": "MIT", - "dependencies": { - "@rgba-image/common": "^0.1.13", - "@rgba-image/copy": "^0.1.2", - "@rgba-image/create-image": "^0.1.1" - } - }, "node_modules/@rnmapbox/maps": { "version": "10.3.2", "resolved": "https://registry.npmjs.org/@rnmapbox/maps/-/maps-10.3.2.tgz", @@ -22929,15 +22472,6 @@ "integrity": "sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ==", "dev": true }, - "node_modules/browser-image-hash": { - "version": "0.0.5", - "license": "MIT", - "dependencies": { - "@rgba-image/lanczos": "^0.1.0", - "decimal.js": "^10.2.0", - "wasm-imagemagick": "^1.2.3" - } - }, "node_modules/browserslist": { "version": "4.28.7", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", @@ -24970,6 +24504,7 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, "license": "MIT" }, "node_modules/decode-uri-component": { @@ -27907,10 +27442,6 @@ "integrity": "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==", "license": "MIT" }, - "node_modules/fflate": { - "version": "0.8.2", - "license": "MIT" - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -33820,17 +33351,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/link": { - "version": "2.1.1", - "dev": true, - "license": "MIT", - "bin": { - "link": "dist/cli.js" - }, - "funding": { - "url": "https://github.com/privatenumber/link?sponsor=1" - } - }, "node_modules/loader-runner": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", @@ -34128,10 +33648,6 @@ } } }, - "node_modules/lottie-web": { - "version": "5.12.2", - "license": "MIT" - }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -39625,63 +39141,6 @@ "dev": true, "license": "MIT" }, - "node_modules/sharp": { - "version": "0.33.5", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5" - } - }, - "node_modules/sharp-phash": { - "version": "2.2.0", - "license": "MIT", - "engines": { - "node": ">= 10" - }, - "peerDependencies": { - "sharp": ">= 0.32.0" - } - }, - "node_modules/sharp/node_modules/semver": { - "version": "7.7.1", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "license": "MIT", @@ -40233,6 +39692,7 @@ }, "node_modules/stack-generator": { "version": "2.0.10", + "dev": true, "license": "MIT", "dependencies": { "stackframe": "^1.3.4" @@ -40263,6 +39723,7 @@ }, "node_modules/stacktrace-gps": { "version": "3.1.2", + "dev": true, "license": "MIT", "dependencies": { "source-map": "0.5.6", @@ -40271,6 +39732,7 @@ }, "node_modules/stacktrace-gps/node_modules/source-map": { "version": "0.5.6", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -40278,6 +39740,7 @@ }, "node_modules/stacktrace-js": { "version": "2.0.2", + "dev": true, "license": "MIT", "dependencies": { "error-stack-parser": "^2.0.6", @@ -42690,10 +42153,6 @@ "node": ">=10.12.0" } }, - "node_modules/valibot": { - "version": "0.13.1", - "license": "MIT" - }, "node_modules/validate-npm-package-name": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", @@ -42801,21 +42260,6 @@ "loose-envify": "^1.0.0" } }, - "node_modules/wasm-imagemagick": { - "version": "1.2.8", - "license": "Apache-2.0", - "dependencies": { - "p-map": "^2.0.0", - "stacktrace-js": "^2.0.0" - } - }, - "node_modules/wasm-imagemagick/node_modules/p-map": { - "version": "2.1.0", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/watchpack": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", @@ -43758,14 +43202,6 @@ "dev": true, "license": "MIT" }, - "node_modules/xstate": { - "version": "4.38.3", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/xstate" - } - }, "node_modules/xtend": { "version": "4.0.2", "license": "MIT", diff --git a/package.json b/package.json index 43fcb7cf541a..9a8893604467 100644 --- a/package.json +++ b/package.json @@ -93,14 +93,12 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", - "@dotlottie/react-player": "^1.6.3", "@expensify/nitro-utils": "file:./modules/ExpensifyNitroUtils", "@expensify/react-native-background-task": "file:./modules/background-task", "@expensify/react-native-hybrid-app": "file:./modules/hybrid-app", "@expensify/react-native-live-markdown": "0.1.342", "@expensify/react-native-wallet": "0.1.22", "@expo/metro-config": "57.0.7", - "@expo/metro-runtime": "57.0.7", "@formatjs/intl-listformat": "^7.5.7", "@formatjs/intl-locale": "^4.0.0", "@formatjs/intl-numberformat": "^8.10.3", @@ -344,7 +342,6 @@ "jest-transformer-svg": "^2.0.1", "knip": "^6.34.0", "lefthook": "2.1.9", - "link": "^2.1.1", "memfs": "^4.6.0", "nitrogen": "0.37.1", "onchange": "^7.1.0", diff --git a/patches/link/details.md b/patches/link/details.md deleted file mode 100644 index 158c430737ed..000000000000 --- a/patches/link/details.md +++ /dev/null @@ -1,14 +0,0 @@ -# `link` patches - -### [link+2.1.1+001+hot-load.patch](link+2.1.1+001+hot-load.patch) - -- Reason: - - ``` - This patch enables us to develop react-native-live-markdown from the TS source and have it hot-reload in E/App. - - ``` - -- Upstream PR/issue: 🛑, this is related to Expensify/react-native-live-markdown and it seems like a customization, so it makes sense there's no upstream issue. -- E/App issue: Same as the PR. -- PR introducing patch: https://github.com/Expensify/App/pull/47794 \ No newline at end of file diff --git a/patches/link/link+2.1.1+001+hot-load.patch b/patches/link/link+2.1.1+001+hot-load.patch deleted file mode 100644 index ae5abc87400a..000000000000 --- a/patches/link/link+2.1.1+001+hot-load.patch +++ /dev/null @@ -1,4513 +0,0 @@ -diff --git a/node_modules/link/README.md b/node_modules/link/README.md -index d7ff505..bc868c4 100644 ---- a/node_modules/link/README.md -+++ b/node_modules/link/README.md -@@ -1,4 +1,11 @@ --# npx link -+

-+ -+

-+

-+ npx link -+
-+ -+

- - A safer and enhanced version of [`npm link`](https://docs.npmjs.com/cli/v8/commands/npm-link). - -@@ -13,14 +20,10 @@ Why is `npm link` unsafe? Read the [blog post](https://hirok.io/posts/avoid-npm- -
- -

-- -- -- -- -- Premium sponsor banner -- -- -+ -+ -

-+

Already a sponsor? Join the discussion in the Development repo!

- - ## Terminology - -@@ -120,6 +123,37 @@ _Publish mode_ helps replicate the production environment in your development se - - > **Note:** If the _Dependency package_ emits new files, you'll need to re-run `npx link publish ` to create new hard links. - -+#### Watch mode -+ -+To automatically re-link a package in publish mode whenever a change is made, use the `--watch` flag: -+ -+```sh -+npx link publish --watch -+``` -+ -+Hard links do not support directories, so files must be individually linked. _Watch mode_ addresses this limitation by automatically linking new files added to the _Dependency package_ so they appear in the _Consuming package_. -+ -+##### Watch mode caveats -+ -+A fundamental limitation of _watch mode_ is that it doesn't have a reliable way to know when the rebuild of your local _Dependency package_ is complete. -+ -+The **recommended** way to address this limitation is using the `--litmus` flag. This flag specifies a filepath relative to the _Dependency package_ that `link` can check to see if a build is completed. -+For example, you might create an empty file in your local dependency called `.build_complete`, and augment it's build system to delete that file at the start of builds, and re-create it at the end of builds. Then pass `npx link --watch --litmus '.build_complete'` -+ -+``` -+"build:watch": "nodemon --watch src --ext .ts,.tsx,.css --exec \"rm -f .build_complete && yarn prepare && yarn pack && touch .build_complete\"", -+``` -+ -+If for some reason you can't do that, you'll have to fall back on some heuristics: -+ -+| Flag | Description | Default | -+|------|------------------------------------------------------------------------------------------------------------------------------------------|---------| -+| `--delay` or `-d` | The amount of time (in ms) after a change is observed to refresh the packlist for your _Dependency package_ | 2000 | -+| `--interval` or `-i` | The amount of time (in ms) to poll the _Dependency package_ to see if all expected files are present (i.e: build is complete) | 500 | -+| `--maxBuildTime` or `-m` | The maximum amount of time (in ms) to poll the _Dependency package_ to see if all expected files are present (i.e: build is complete) | 30000 | -+ -+Note that there is an edge case with _watch mode_ that may not work as expected if you don't use the `--litmus` flag; if you intentionally delete a file in the _Dependency package_, you may have to wait `--maxBuildTime` before the links are refreshed. -+ - ### Configuration file - - Create a `link.config.json` (or `link.config.js`) configuration file at the root of the _Consuming package_ to automatically setup links to multiple _Dependency packages_. -@@ -166,18 +200,6 @@ Enable with the `--deep` flag or `deepLink` property in `link.config.json`. - npx link --deep - ``` - --
-- --

-- -- -- -- -- Premium sponsor banner -- -- --

-- - ## FAQ - - ### Why should I use `npx link` over `npm link`? -@@ -213,23 +235,6 @@ $ npx link - - ## Sponsors - --

-- -- -- -- -- Premium sponsor banner -- -- -- -- -- -- -- Premium sponsor banner -- -- --

-- -

- - -diff --git a/node_modules/link/dist/cli.js b/node_modules/link/dist/cli.js -index ae69549..5687188 100755 ---- a/node_modules/link/dist/cli.js -+++ b/node_modules/link/dist/cli.js -@@ -1,111 +1,4284 @@ - #!/usr/bin/env node --"use strict";var O=require("fs/promises"),Ot=require("tty"),A=require("path"),fi=require("module"),ru=require("fs"),kt=require("events"),Fi=typeof document<"u"?document.currentScript:null;const jt="known-flag",xt="unknown-flag",Tt="argument",{stringify:ne}=JSON,Rt=/\B([A-Z])/g,Pt=u=>u.replace(Rt,"-$1").toLowerCase(),{hasOwnProperty:Lt}=Object.prototype,ie=(u,e)=>Lt.call(u,e),Mt=u=>Array.isArray(u),ou=u=>typeof u=="function"?[u,!1]:Mt(u)?[u[0],!0]:ou(u.type),Nt=(u,e)=>u===Boolean?e!=="false":e,It=(u,e)=>typeof e=="boolean"?e:u===Number&&e===""?Number.NaN:u(e),_t=/[\s.:=]/,Wt=u=>{const e=`Flag name ${ne(u)}`;if(u.length===0)throw new Error(`${e} cannot be empty`);if(u.length===1)throw new Error(`${e} must be longer than a character`);const t=u.match(_t);if(t)throw new Error(`${e} cannot contain ${ne(t?.[0])}`)},Gt=u=>{const e={},t=(D,n)=>{if(ie(e,D))throw new Error(`Duplicate flags named ${ne(D)}`);e[D]=n};for(const D in u){if(!ie(u,D))continue;Wt(D);const n=u[D],i=[[],...ou(n),n];t(D,i);const s=Pt(D);if(D!==s&&t(s,i),"alias"in n&&typeof n.alias=="string"){const{alias:o}=n,a=`Flag alias ${ne(o)} for flag ${ne(D)}`;if(o.length===0)throw new Error(`${a} cannot be empty`);if(o.length>1)throw new Error(`${a} must be a single character`);t(o,i)}}return e},qt=(u,e)=>{const t={};for(const D in u){if(!ie(u,D))continue;const[n,,i,s]=e[D];if(n.length===0&&"default"in s){let{default:o}=s;typeof o=="function"&&(o=o()),t[D]=o}else t[D]=i?n:n.pop()}return t},ge="--",zt=/[.:=]/,Jt=/^-{1,2}\w/,Vt=u=>{if(!Jt.test(u))return;const e=!u.startsWith(ge);let t=u.slice(e?1:2),D;const n=t.match(zt);if(n){const{index:i}=n;D=t.slice(i+1),t=t.slice(0,i)}return[t,D,e]},Ut=(u,{onFlag:e,onArgument:t})=>{let D;const n=(i,s)=>{if(typeof D!="function")return!0;D(i,s),D=void 0};for(let i=0;i{for(const[t,D,n]of e.reverse()){if(D){const i=u[t];let s=i.slice(0,D);if(n||(s+=i.slice(D+1)),s!=="-"){u[t]=s;continue}}u.splice(t,1)}},Ht=(u,e=process.argv.slice(2),{ignore:t}={})=>{const D=[],n=Gt(u),i={},s=[];return s[ge]=[],Ut(e,{onFlag(o,a,h){const f=ie(n,o);if(!t?.(f?jt:xt,o,a)){if(f){const[g,F]=n[o],d=Nt(F,a),m=(w,S)=>{D.push(h),S&&D.push(S),g.push(It(F,w||""))};return d===void 0?m:m(d)}ie(i,o)||(i[o]=[]),i[o].push(a===void 0?!0:a),D.push(h)}},onArgument(o,a,h){t?.(Tt,e[a[0]])||(s.push(...o),h?(s[ge]=o,e.splice(a[0])):D.push(a))}}),Zt(e,D),{flags:qt(u,n),unknownFlags:i,_:s}};var Xt=Object.create,me=Object.defineProperty,Yt=Object.defineProperties,Kt=Object.getOwnPropertyDescriptor,Qt=Object.getOwnPropertyDescriptors,eD=Object.getOwnPropertyNames,au=Object.getOwnPropertySymbols,uD=Object.getPrototypeOf,lu=Object.prototype.hasOwnProperty,tD=Object.prototype.propertyIsEnumerable,cu=(u,e,t)=>e in u?me(u,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):u[e]=t,Be=(u,e)=>{for(var t in e||(e={}))lu.call(e,t)&&cu(u,t,e[t]);if(au)for(var t of au(e))tD.call(e,t)&&cu(u,t,e[t]);return u},Re=(u,e)=>Yt(u,Qt(e)),DD=u=>me(u,"__esModule",{value:!0}),nD=(u,e)=>()=>(u&&(e=u(u=0)),e),iD=(u,e)=>()=>(e||u((e={exports:{}}).exports,e),e.exports),sD=(u,e,t,D)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of eD(e))!lu.call(u,n)&&n!=="default"&&me(u,n,{get:()=>e[n],enumerable:!(D=Kt(e,n))||D.enumerable});return u},rD=(u,e)=>sD(DD(me(u!=null?Xt(uD(u)):{},"default",{value:u,enumerable:!0})),u),x=nD(()=>{}),oD=iD((u,e)=>{x(),e.exports=function(){return/\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62(?:\uDB40\uDC77\uDB40\uDC6C\uDB40\uDC73|\uDB40\uDC73\uDB40\uDC63\uDB40\uDC74|\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67)\uDB40\uDC7F|(?:\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDD1D\u200D(?:\uD83D[\uDC68\uDC69]))(?:\uD83C[\uDFFB-\uDFFE])|(?:\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDD1D\u200D(?:\uD83D[\uDC68\uDC69]))(?:\uD83C[\uDFFB-\uDFFD\uDFFF])|(?:\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDD1D\u200D(?:\uD83D[\uDC68\uDC69]))(?:\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])|(?:\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDD1D\u200D(?:\uD83D[\uDC68\uDC69]))(?:\uD83C[\uDFFB\uDFFD-\uDFFF])|(?:\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDD1D\u200D(?:\uD83D[\uDC68\uDC69]))(?:\uD83C[\uDFFC-\uDFFF])|\uD83D\uDC68(?:\uD83C\uDFFB(?:\u200D(?:\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFF])|\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFF]))|\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFC-\uDFFF])|[\u2695\u2696\u2708]\uFE0F|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD]))?|(?:\uD83C[\uDFFC-\uDFFF])\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFF])|\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFF]))|\u200D(?:\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83D\uDC68|(?:\uD83D[\uDC68\uDC69])\u200D(?:\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67]))|\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67])|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFF\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFE])|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFE\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFD\uDFFF])|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFD\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFC\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB\uDFFD-\uDFFF])|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|(?:\uD83C\uDFFF\u200D[\u2695\u2696\u2708]|\uD83C\uDFFE\u200D[\u2695\u2696\u2708]|\uD83C\uDFFD\u200D[\u2695\u2696\u2708]|\uD83C\uDFFC\u200D[\u2695\u2696\u2708]|\u200D[\u2695\u2696\u2708])\uFE0F|\u200D(?:(?:\uD83D[\uDC68\uDC69])\u200D(?:\uD83D[\uDC66\uDC67])|\uD83D[\uDC66\uDC67])|\uD83C\uDFFF|\uD83C\uDFFE|\uD83C\uDFFD|\uD83C\uDFFC)?|(?:\uD83D\uDC69(?:\uD83C\uDFFB\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D(?:\uD83D[\uDC68\uDC69])|\uD83D[\uDC68\uDC69])|(?:\uD83C[\uDFFC-\uDFFF])\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D(?:\uD83D[\uDC68\uDC69])|\uD83D[\uDC68\uDC69]))|\uD83E\uDDD1(?:\uD83C[\uDFFB-\uDFFF])\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1)(?:\uD83C[\uDFFB-\uDFFF])|\uD83D\uDC69\u200D\uD83D\uDC69\u200D(?:\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67]))|\uD83D\uDC69(?:\u200D(?:\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D(?:\uD83D[\uDC68\uDC69])|\uD83D[\uDC68\uDC69])|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFF\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFE\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFD\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFC\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFB\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD]))|\uD83E\uDDD1(?:\u200D(?:\uD83E\uDD1D\u200D\uD83E\uDDD1|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFF\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFE\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFD\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFC\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFB\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD]))|\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC69\u200D\uD83D\uDC69\u200D(?:\uD83D[\uDC66\uDC67])|\uD83D\uDC69\u200D\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67])|(?:\uD83D\uDC41\uFE0F\u200D\uD83D\uDDE8|\uD83E\uDDD1(?:\uD83C\uDFFF\u200D[\u2695\u2696\u2708]|\uD83C\uDFFE\u200D[\u2695\u2696\u2708]|\uD83C\uDFFD\u200D[\u2695\u2696\u2708]|\uD83C\uDFFC\u200D[\u2695\u2696\u2708]|\uD83C\uDFFB\u200D[\u2695\u2696\u2708]|\u200D[\u2695\u2696\u2708])|\uD83D\uDC69(?:\uD83C\uDFFF\u200D[\u2695\u2696\u2708]|\uD83C\uDFFE\u200D[\u2695\u2696\u2708]|\uD83C\uDFFD\u200D[\u2695\u2696\u2708]|\uD83C\uDFFC\u200D[\u2695\u2696\u2708]|\uD83C\uDFFB\u200D[\u2695\u2696\u2708]|\u200D[\u2695\u2696\u2708])|\uD83D\uDE36\u200D\uD83C\uDF2B|\uD83C\uDFF3\uFE0F\u200D\u26A7|\uD83D\uDC3B\u200D\u2744|(?:(?:\uD83C[\uDFC3\uDFC4\uDFCA]|\uD83D[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6]|\uD83E[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD])(?:\uD83C[\uDFFB-\uDFFF])|\uD83D\uDC6F|\uD83E[\uDD3C\uDDDE\uDDDF])\u200D[\u2640\u2642]|(?:\u26F9|\uD83C[\uDFCB\uDFCC]|\uD83D\uDD75)(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])\u200D[\u2640\u2642]|\uD83C\uDFF4\u200D\u2620|(?:\uD83C[\uDFC3\uDFC4\uDFCA]|\uD83D[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6]|\uD83E[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD])\u200D[\u2640\u2642]|[\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u2328\u23CF\u23ED-\u23EF\u23F1\u23F2\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB\u25FC\u2600-\u2604\u260E\u2611\u2618\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u2692\u2694-\u2697\u2699\u269B\u269C\u26A0\u26A7\u26B0\u26B1\u26C8\u26CF\u26D1\u26D3\u26E9\u26F0\u26F1\u26F4\u26F7\u26F8\u2702\u2708\u2709\u270F\u2712\u2714\u2716\u271D\u2721\u2733\u2734\u2744\u2747\u2763\u27A1\u2934\u2935\u2B05-\u2B07\u3030\u303D\u3297\u3299]|\uD83C[\uDD70\uDD71\uDD7E\uDD7F\uDE02\uDE37\uDF21\uDF24-\uDF2C\uDF36\uDF7D\uDF96\uDF97\uDF99-\uDF9B\uDF9E\uDF9F\uDFCD\uDFCE\uDFD4-\uDFDF\uDFF5\uDFF7]|\uD83D[\uDC3F\uDCFD\uDD49\uDD4A\uDD6F\uDD70\uDD73\uDD76-\uDD79\uDD87\uDD8A-\uDD8D\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA\uDECB\uDECD-\uDECF\uDEE0-\uDEE5\uDEE9\uDEF0\uDEF3])\uFE0F|\uD83C\uDFF3\uFE0F\u200D\uD83C\uDF08|\uD83D\uDC69\u200D\uD83D\uDC67|\uD83D\uDC69\u200D\uD83D\uDC66|\uD83D\uDE35\u200D\uD83D\uDCAB|\uD83D\uDE2E\u200D\uD83D\uDCA8|\uD83D\uDC15\u200D\uD83E\uDDBA|\uD83E\uDDD1(?:\uD83C\uDFFF|\uD83C\uDFFE|\uD83C\uDFFD|\uD83C\uDFFC|\uD83C\uDFFB)?|\uD83D\uDC69(?:\uD83C\uDFFF|\uD83C\uDFFE|\uD83C\uDFFD|\uD83C\uDFFC|\uD83C\uDFFB)?|\uD83C\uDDFD\uD83C\uDDF0|\uD83C\uDDF6\uD83C\uDDE6|\uD83C\uDDF4\uD83C\uDDF2|\uD83D\uDC08\u200D\u2B1B|\u2764\uFE0F\u200D(?:\uD83D\uDD25|\uD83E\uDE79)|\uD83D\uDC41\uFE0F|\uD83C\uDFF3\uFE0F|\uD83C\uDDFF(?:\uD83C[\uDDE6\uDDF2\uDDFC])|\uD83C\uDDFE(?:\uD83C[\uDDEA\uDDF9])|\uD83C\uDDFC(?:\uD83C[\uDDEB\uDDF8])|\uD83C\uDDFB(?:\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDEE\uDDF3\uDDFA])|\uD83C\uDDFA(?:\uD83C[\uDDE6\uDDEC\uDDF2\uDDF3\uDDF8\uDDFE\uDDFF])|\uD83C\uDDF9(?:\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDED\uDDEF-\uDDF4\uDDF7\uDDF9\uDDFB\uDDFC\uDDFF])|\uD83C\uDDF8(?:\uD83C[\uDDE6-\uDDEA\uDDEC-\uDDF4\uDDF7-\uDDF9\uDDFB\uDDFD-\uDDFF])|\uD83C\uDDF7(?:\uD83C[\uDDEA\uDDF4\uDDF8\uDDFA\uDDFC])|\uD83C\uDDF5(?:\uD83C[\uDDE6\uDDEA-\uDDED\uDDF0-\uDDF3\uDDF7-\uDDF9\uDDFC\uDDFE])|\uD83C\uDDF3(?:\uD83C[\uDDE6\uDDE8\uDDEA-\uDDEC\uDDEE\uDDF1\uDDF4\uDDF5\uDDF7\uDDFA\uDDFF])|\uD83C\uDDF2(?:\uD83C[\uDDE6\uDDE8-\uDDED\uDDF0-\uDDFF])|\uD83C\uDDF1(?:\uD83C[\uDDE6-\uDDE8\uDDEE\uDDF0\uDDF7-\uDDFB\uDDFE])|\uD83C\uDDF0(?:\uD83C[\uDDEA\uDDEC-\uDDEE\uDDF2\uDDF3\uDDF5\uDDF7\uDDFC\uDDFE\uDDFF])|\uD83C\uDDEF(?:\uD83C[\uDDEA\uDDF2\uDDF4\uDDF5])|\uD83C\uDDEE(?:\uD83C[\uDDE8-\uDDEA\uDDF1-\uDDF4\uDDF6-\uDDF9])|\uD83C\uDDED(?:\uD83C[\uDDF0\uDDF2\uDDF3\uDDF7\uDDF9\uDDFA])|\uD83C\uDDEC(?:\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEE\uDDF1-\uDDF3\uDDF5-\uDDFA\uDDFC\uDDFE])|\uD83C\uDDEB(?:\uD83C[\uDDEE-\uDDF0\uDDF2\uDDF4\uDDF7])|\uD83C\uDDEA(?:\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDED\uDDF7-\uDDFA])|\uD83C\uDDE9(?:\uD83C[\uDDEA\uDDEC\uDDEF\uDDF0\uDDF2\uDDF4\uDDFF])|\uD83C\uDDE8(?:\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDEE\uDDF0-\uDDF5\uDDF7\uDDFA-\uDDFF])|\uD83C\uDDE7(?:\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEF\uDDF1-\uDDF4\uDDF6-\uDDF9\uDDFB\uDDFC\uDDFE\uDDFF])|\uD83C\uDDE6(?:\uD83C[\uDDE8-\uDDEC\uDDEE\uDDF1\uDDF2\uDDF4\uDDF6-\uDDFA\uDDFC\uDDFD\uDDFF])|[#\*0-9]\uFE0F\u20E3|\u2764\uFE0F|(?:\uD83C[\uDFC3\uDFC4\uDFCA]|\uD83D[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6]|\uD83E[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD])(?:\uD83C[\uDFFB-\uDFFF])|(?:\u26F9|\uD83C[\uDFCB\uDFCC]|\uD83D\uDD75)(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])|\uD83C\uDFF4|(?:[\u270A\u270B]|\uD83C[\uDF85\uDFC2\uDFC7]|\uD83D[\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDC8F\uDC91\uDCAA\uDD7A\uDD95\uDD96\uDE4C\uDE4F\uDEC0\uDECC]|\uD83E[\uDD0C\uDD0F\uDD18-\uDD1C\uDD1E\uDD1F\uDD30-\uDD34\uDD36\uDD77\uDDB5\uDDB6\uDDBB\uDDD2\uDDD3\uDDD5])(?:\uD83C[\uDFFB-\uDFFF])|(?:[\u261D\u270C\u270D]|\uD83D[\uDD74\uDD90])(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])|[\u270A\u270B]|\uD83C[\uDF85\uDFC2\uDFC7]|\uD83D[\uDC08\uDC15\uDC3B\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDC8F\uDC91\uDCAA\uDD7A\uDD95\uDD96\uDE2E\uDE35\uDE36\uDE4C\uDE4F\uDEC0\uDECC]|\uD83E[\uDD0C\uDD0F\uDD18-\uDD1C\uDD1E\uDD1F\uDD30-\uDD34\uDD36\uDD77\uDDB5\uDDB6\uDDBB\uDDD2\uDDD3\uDDD5]|\uD83C[\uDFC3\uDFC4\uDFCA]|\uD83D[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6]|\uD83E[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD]|\uD83D\uDC6F|\uD83E[\uDD3C\uDDDE\uDDDF]|[\u231A\u231B\u23E9-\u23EC\u23F0\u23F3\u25FD\u25FE\u2614\u2615\u2648-\u2653\u267F\u2693\u26A1\u26AA\u26AB\u26BD\u26BE\u26C4\u26C5\u26CE\u26D4\u26EA\u26F2\u26F3\u26F5\u26FA\u26FD\u2705\u2728\u274C\u274E\u2753-\u2755\u2757\u2795-\u2797\u27B0\u27BF\u2B1B\u2B1C\u2B50\u2B55]|\uD83C[\uDC04\uDCCF\uDD8E\uDD91-\uDD9A\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF84\uDF86-\uDF93\uDFA0-\uDFC1\uDFC5\uDFC6\uDFC8\uDFC9\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF8-\uDFFF]|\uD83D[\uDC00-\uDC07\uDC09-\uDC14\uDC16-\uDC3A\uDC3C-\uDC3E\uDC40\uDC44\uDC45\uDC51-\uDC65\uDC6A\uDC79-\uDC7B\uDC7D-\uDC80\uDC84\uDC88-\uDC8E\uDC90\uDC92-\uDCA9\uDCAB-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDDA4\uDDFB-\uDE2D\uDE2F-\uDE34\uDE37-\uDE44\uDE48-\uDE4A\uDE80-\uDEA2\uDEA4-\uDEB3\uDEB7-\uDEBF\uDEC1-\uDEC5\uDED0-\uDED2\uDED5-\uDED7\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB]|\uD83E[\uDD0D\uDD0E\uDD10-\uDD17\uDD1D\uDD20-\uDD25\uDD27-\uDD2F\uDD3A\uDD3F-\uDD45\uDD47-\uDD76\uDD78\uDD7A-\uDDB4\uDDB7\uDDBA\uDDBC-\uDDCB\uDDD0\uDDE0-\uDDFF\uDE70-\uDE74\uDE78-\uDE7A\uDE80-\uDE86\uDE90-\uDEA8\uDEB0-\uDEB6\uDEC0-\uDEC2\uDED0-\uDED6]|(?:[\u231A\u231B\u23E9-\u23EC\u23F0\u23F3\u25FD\u25FE\u2614\u2615\u2648-\u2653\u267F\u2693\u26A1\u26AA\u26AB\u26BD\u26BE\u26C4\u26C5\u26CE\u26D4\u26EA\u26F2\u26F3\u26F5\u26FA\u26FD\u2705\u270A\u270B\u2728\u274C\u274E\u2753-\u2755\u2757\u2795-\u2797\u27B0\u27BF\u2B1B\u2B1C\u2B50\u2B55]|\uD83C[\uDC04\uDCCF\uDD8E\uDD91-\uDD9A\uDDE6-\uDDFF\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF93\uDFA0-\uDFCA\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF4\uDFF8-\uDFFF]|\uD83D[\uDC00-\uDC3E\uDC40\uDC42-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDD7A\uDD95\uDD96\uDDA4\uDDFB-\uDE4F\uDE80-\uDEC5\uDECC\uDED0-\uDED2\uDED5-\uDED7\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB]|\uD83E[\uDD0C-\uDD3A\uDD3C-\uDD45\uDD47-\uDD78\uDD7A-\uDDCB\uDDCD-\uDDFF\uDE70-\uDE74\uDE78-\uDE7A\uDE80-\uDE86\uDE90-\uDEA8\uDEB0-\uDEB6\uDEC0-\uDEC2\uDED0-\uDED6])|(?:[#\*0-9\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u231A\u231B\u2328\u23CF\u23E9-\u23F3\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB-\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u261D\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692-\u2697\u2699\u269B\u269C\u26A0\u26A1\u26A7\u26AA\u26AB\u26B0\u26B1\u26BD\u26BE\u26C4\u26C5\u26C8\u26CE\u26CF\u26D1\u26D3\u26D4\u26E9\u26EA\u26F0-\u26F5\u26F7-\u26FA\u26FD\u2702\u2705\u2708-\u270D\u270F\u2712\u2714\u2716\u271D\u2721\u2728\u2733\u2734\u2744\u2747\u274C\u274E\u2753-\u2755\u2757\u2763\u2764\u2795-\u2797\u27A1\u27B0\u27BF\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B50\u2B55\u3030\u303D\u3297\u3299]|\uD83C[\uDC04\uDCCF\uDD70\uDD71\uDD7E\uDD7F\uDD8E\uDD91-\uDD9A\uDDE6-\uDDFF\uDE01\uDE02\uDE1A\uDE2F\uDE32-\uDE3A\uDE50\uDE51\uDF00-\uDF21\uDF24-\uDF93\uDF96\uDF97\uDF99-\uDF9B\uDF9E-\uDFF0\uDFF3-\uDFF5\uDFF7-\uDFFF]|\uD83D[\uDC00-\uDCFD\uDCFF-\uDD3D\uDD49-\uDD4E\uDD50-\uDD67\uDD6F\uDD70\uDD73-\uDD7A\uDD87\uDD8A-\uDD8D\uDD90\uDD95\uDD96\uDDA4\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA-\uDE4F\uDE80-\uDEC5\uDECB-\uDED2\uDED5-\uDED7\uDEE0-\uDEE5\uDEE9\uDEEB\uDEEC\uDEF0\uDEF3-\uDEFC\uDFE0-\uDFEB]|\uD83E[\uDD0C-\uDD3A\uDD3C-\uDD45\uDD47-\uDD78\uDD7A-\uDDCB\uDDCD-\uDDFF\uDE70-\uDE74\uDE78-\uDE7A\uDE80-\uDE86\uDE90-\uDEA8\uDEB0-\uDEB6\uDEC0-\uDEC2\uDED0-\uDED6])\uFE0F|(?:[\u261D\u26F9\u270A-\u270D]|\uD83C[\uDF85\uDFC2-\uDFC4\uDFC7\uDFCA-\uDFCC]|\uD83D[\uDC42\uDC43\uDC46-\uDC50\uDC66-\uDC78\uDC7C\uDC81-\uDC83\uDC85-\uDC87\uDC8F\uDC91\uDCAA\uDD74\uDD75\uDD7A\uDD90\uDD95\uDD96\uDE45-\uDE47\uDE4B-\uDE4F\uDEA3\uDEB4-\uDEB6\uDEC0\uDECC]|\uD83E[\uDD0C\uDD0F\uDD18-\uDD1F\uDD26\uDD30-\uDD39\uDD3C-\uDD3E\uDD77\uDDB5\uDDB6\uDDB8\uDDB9\uDDBB\uDDCD-\uDDCF\uDDD1-\uDDDD])/g}});x(),x(),x();var aD=u=>{var e,t,D;let n=(e=process.stdout.columns)!=null?e:Number.POSITIVE_INFINITY;return typeof u=="function"&&(u=u(n)),u||(u={}),Array.isArray(u)?{columns:u,stdoutColumns:n}:{columns:(t=u.columns)!=null?t:[],stdoutColumns:(D=u.stdoutColumns)!=null?D:n}};x(),x(),x(),x(),x();function lD({onlyFirst:u=!1}={}){let e=["[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)","(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))"].join("|");return new RegExp(e,u?void 0:"g")}function hu(u){if(typeof u!="string")throw new TypeError(`Expected a \`string\`, got \`${typeof u}\``);return u.replace(lD(),"")}x();function cD(u){return Number.isInteger(u)?u>=4352&&(u<=4447||u===9001||u===9002||11904<=u&&u<=12871&&u!==12351||12880<=u&&u<=19903||19968<=u&&u<=42182||43360<=u&&u<=43388||44032<=u&&u<=55203||63744<=u&&u<=64255||65040<=u&&u<=65049||65072<=u&&u<=65131||65281<=u&&u<=65376||65504<=u&&u<=65510||110592<=u&&u<=110593||127488<=u&&u<=127569||131072<=u&&u<=262141):!1}var hD=rD(oD());function V(u){if(typeof u!="string"||u.length===0||(u=hu(u),u.length===0))return 0;u=u.replace((0,hD.default)()," ");let e=0;for(let t=0;t=127&&D<=159||D>=768&&D<=879||(D>65535&&t++,e+=cD(D)?2:1)}return e}var pu=u=>Math.max(...u.split(` --`).map(V)),pD=u=>{let e=[];for(let t of u){let{length:D}=t,n=D-e.length;for(let i=0;ie[i]&&(e[i]=s)}}return e};x();var fu=/^\d+%$/,Fu={width:"auto",align:"left",contentWidth:0,paddingLeft:0,paddingRight:0,paddingTop:0,paddingBottom:0,horizontalPadding:0,paddingLeftString:"",paddingRightString:""},fD=(u,e)=>{var t;let D=[];for(let n=0;n=e){let a=s-e,h=Math.ceil(t.paddingLeft/n*a),f=a-h;t.paddingLeft-=h,t.paddingRight-=f,t.horizontalPadding=t.paddingLeft+t.paddingRight}t.paddingLeftString=t.paddingLeft?" ".repeat(t.paddingLeft):"",t.paddingRightString=t.paddingRight?" ".repeat(t.paddingRight):"";let o=e-t.horizontalPadding;t.width=Math.max(Math.min(t.width,o),i)}}var du=()=>Object.assign([],{columns:0});function dD(u,e){let t=[du()],[D]=t;for(let n of u){let i=n.width+n.horizontalPadding;D.columns+i>e&&(D=du(),t.push(D)),D.push(n),D.columns+=i}for(let n of t){let i=n.reduce((F,d)=>F+d.width+d.horizontalPadding,0),s=e-i;if(s===0)continue;let o=n.filter(F=>"autoOverflow"in F),a=o.filter(F=>F.autoOverflow>0),h=a.reduce((F,d)=>F+d.autoOverflow,0),f=Math.min(h,s);for(let F of a){let d=Math.floor(F.autoOverflow/h*f);F.width+=d,s-=d}let g=Math.floor(s/o.length);for(let F=0;Fe=>`\x1B[${e+u}m`,Eu=(u=0)=>e=>`\x1B[${38+u};5;${e}m`,gu=(u=0)=>(e,t,D)=>`\x1B[${38+u};2;${e};${t};${D}m`;function ED(){let u=new Map,e={modifier:{reset:[0,0],bold:[1,22],dim:[2,22],italic:[3,23],underline:[4,24],overline:[53,55],inverse:[7,27],hidden:[8,28],strikethrough:[9,29]},color:{black:[30,39],red:[31,39],green:[32,39],yellow:[33,39],blue:[34,39],magenta:[35,39],cyan:[36,39],white:[37,39],blackBright:[90,39],redBright:[91,39],greenBright:[92,39],yellowBright:[93,39],blueBright:[94,39],magentaBright:[95,39],cyanBright:[96,39],whiteBright:[97,39]},bgColor:{bgBlack:[40,49],bgRed:[41,49],bgGreen:[42,49],bgYellow:[43,49],bgBlue:[44,49],bgMagenta:[45,49],bgCyan:[46,49],bgWhite:[47,49],bgBlackBright:[100,49],bgRedBright:[101,49],bgGreenBright:[102,49],bgYellowBright:[103,49],bgBlueBright:[104,49],bgMagentaBright:[105,49],bgCyanBright:[106,49],bgWhiteBright:[107,49]}};e.color.gray=e.color.blackBright,e.bgColor.bgGray=e.bgColor.bgBlackBright,e.color.grey=e.color.blackBright,e.bgColor.bgGrey=e.bgColor.bgBlackBright;for(let[t,D]of Object.entries(e)){for(let[n,i]of Object.entries(D))e[n]={open:`\x1B[${i[0]}m`,close:`\x1B[${i[1]}m`},D[n]=e[n],u.set(i[0],i[1]);Object.defineProperty(e,t,{value:D,enumerable:!1})}return Object.defineProperty(e,"codes",{value:u,enumerable:!1}),e.color.close="\x1B[39m",e.bgColor.close="\x1B[49m",e.color.ansi=Cu(),e.color.ansi256=Eu(),e.color.ansi16m=gu(),e.bgColor.ansi=Cu(Pe),e.bgColor.ansi256=Eu(Pe),e.bgColor.ansi16m=gu(Pe),Object.defineProperties(e,{rgbToAnsi256:{value:(t,D,n)=>t===D&&D===n?t<8?16:t>248?231:Math.round((t-8)/247*24)+232:16+36*Math.round(t/255*5)+6*Math.round(D/255*5)+Math.round(n/255*5),enumerable:!1},hexToRgb:{value:t=>{let D=/(?[a-f\d]{6}|[a-f\d]{3})/i.exec(t.toString(16));if(!D)return[0,0,0];let{colorString:n}=D.groups;n.length===3&&(n=n.split("").map(s=>s+s).join(""));let i=Number.parseInt(n,16);return[i>>16&255,i>>8&255,i&255]},enumerable:!1},hexToAnsi256:{value:t=>e.rgbToAnsi256(...e.hexToRgb(t)),enumerable:!1},ansi256ToAnsi:{value:t=>{if(t<8)return 30+t;if(t<16)return 90+(t-8);let D,n,i;if(t>=232)D=((t-232)*10+8)/255,n=D,i=D;else{t-=16;let a=t%36;D=Math.floor(t/36)/5,n=Math.floor(a/6)/5,i=a%6/5}let s=Math.max(D,n,i)*2;if(s===0)return 30;let o=30+(Math.round(i)<<2|Math.round(n)<<1|Math.round(D));return s===2&&(o+=60),o},enumerable:!1},rgbToAnsi:{value:(t,D,n)=>e.ansi256ToAnsi(e.rgbToAnsi256(t,D,n)),enumerable:!1},hexToAnsi:{value:t=>e.ansi256ToAnsi(e.hexToAnsi256(t)),enumerable:!1}}),e}var gD=ED(),mD=gD,we=new Set(["\x1B","\x9B"]),BD=39,Le="\x07",mu="[",wD="]",Bu="m",Me=`${wD}8;;`,wu=u=>`${we.values().next().value}${mu}${u}${Bu}`,yu=u=>`${we.values().next().value}${Me}${u}${Le}`,yD=u=>u.split(" ").map(e=>V(e)),Ne=(u,e,t)=>{let D=[...e],n=!1,i=!1,s=V(hu(u[u.length-1]));for(let[o,a]of D.entries()){let h=V(a);if(s+h<=t?u[u.length-1]+=a:(u.push(a),s=0),we.has(a)&&(n=!0,i=D.slice(o+1).join("").startsWith(Me)),n){i?a===Le&&(n=!1,i=!1):a===Bu&&(n=!1);continue}s+=h,s===t&&o0&&u.length>1&&(u[u.length-2]+=u.pop())},$D=u=>{let e=u.split(" "),t=e.length;for(;t>0&&!(V(e[t-1])>0);)t--;return t===e.length?u:e.slice(0,t).join(" ")+e.slice(t).join("")},AD=(u,e,t={})=>{if(t.trim!==!1&&u.trim()==="")return"";let D="",n,i,s=yD(u),o=[""];for(let[h,f]of u.split(" ").entries()){t.trim!==!1&&(o[o.length-1]=o[o.length-1].trimStart());let g=V(o[o.length-1]);if(h!==0&&(g>=e&&(t.wordWrap===!1||t.trim===!1)&&(o.push(""),g=0),(g>0||t.trim===!1)&&(o[o.length-1]+=" ",g++)),t.hard&&s[h]>e){let F=e-g,d=1+Math.floor((s[h]-F-1)/e);Math.floor((s[h]-1)/e)e&&g>0&&s[h]>0){if(t.wordWrap===!1&&ge&&t.wordWrap===!1){Ne(o,f,e);continue}o[o.length-1]+=f}t.trim!==!1&&(o=o.map(h=>$D(h)));let a=[...o.join(` --`)];for(let[h,f]of a.entries()){if(D+=f,we.has(f)){let{groups:F}=new RegExp(`(?:\\${mu}(?\\d+)m|\\${Me}(?.*)${Le})`).exec(a.slice(h).join(""))||{groups:{}};if(F.code!==void 0){let d=Number.parseFloat(F.code);n=d===BD?void 0:d}else F.uri!==void 0&&(i=F.uri.length===0?void 0:F.uri)}let g=mD.codes.get(Number(n));a[h+1]===` --`?(i&&(D+=yu("")),n&&g&&(D+=wu(g))):f===` --`&&(n&&g&&(D+=wu(n)),i&&(D+=yu(i)))}return D};function bD(u,e,t){return String(u).normalize().replace(/\r\n/g,` -+'use strict'; -+ -+var fs$1 = require('fs/promises'); -+var N$1 = require('tty'); -+var path$1 = require('path'); -+var module$1 = require('module'); -+var path$2 = require('node:path'); -+var fs$2 = require('node:fs/promises'); -+var require$$0 = require('fs'); -+var require$$2 = require('events'); -+ -+var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null; -+const V$2="known-flag",k$2="unknown-flag",C$1="argument",{stringify:h}=JSON,O$1=/\B([A-Z])/g,v$1=t=>t.replace(O$1,"-$1").toLowerCase(),{hasOwnProperty:D$1}=Object.prototype,w$2=(t,n)=>D$1.call(t,n),L$2=t=>Array.isArray(t),b$2=t=>typeof t=="function"?[t,!1]:L$2(t)?[t[0],!0]:b$2(t.type),d$2=(t,n)=>t===Boolean?n!=="false":n,m$1=(t,n)=>typeof n=="boolean"?n:t===Number&&n===""?Number.NaN:t(n),R$2=/[\s.:=]/,B=t=>{const n=`Flag name ${h(t)}`;if(t.length===0)throw new Error(`${n} cannot be empty`);if(t.length===1)throw new Error(`${n} must be longer than a character`);const r=t.match(R$2);if(r)throw new Error(`${n} cannot contain ${h(r?.[0])}`)},K$1=t=>{const n={},r=(e,o)=>{if(w$2(n,e))throw new Error(`Duplicate flags named ${h(e)}`);n[e]=o;};for(const e in t){if(!w$2(t,e))continue;B(e);const o=t[e],s=[[],...b$2(o),o];r(e,s);const i=v$1(e);if(e!==i&&r(i,s),"alias"in o&&typeof o.alias=="string"){const{alias:a}=o,l=`Flag alias ${h(a)} for flag ${h(e)}`;if(a.length===0)throw new Error(`${l} cannot be empty`);if(a.length>1)throw new Error(`${l} must be a single character`);r(a,s);}}return n},_$2=(t,n)=>{const r={};for(const e in t){if(!w$2(t,e))continue;const[o,,s,i]=n[e];if(o.length===0&&"default"in i){let{default:a}=i;typeof a=="function"&&(a=a()),r[e]=a;}else r[e]=s?o:o.pop();}return r},F$1="--",G$2=/[.:=]/,T$2=/^-{1,2}\w/,N=t=>{if(!T$2.test(t))return;const n=!t.startsWith(F$1);let r=t.slice(n?1:2),e;const o=r.match(G$2);if(o){const{index:s}=o;e=r.slice(s+1),r=r.slice(0,s);}return [r,e,n]},$$1=(t,{onFlag:n,onArgument:r})=>{let e;const o=(s,i)=>{if(typeof e!="function")return !0;e(s,i),e=void 0;};for(let s=0;s{for(const[r,e,o]of n.reverse()){if(e){const s=t[r];let i=s.slice(0,e);if(o||(i+=s.slice(e+1)),i!=="-"){t[r]=i;continue}}t.splice(r,1);}},U$2=(t,n=process.argv.slice(2),{ignore:r}={})=>{const e=[],o=K$1(t),s={},i=[];return i[F$1]=[],$$1(n,{onFlag(a,l,f){const g=w$2(o,a);if(!r?.(g?V$2:k$2,a,l)){if(g){const[c,u]=o[a],y=d$2(u,l),p=(P,A)=>{e.push(f),A&&e.push(A),c.push(m$1(u,P||""));};return y===void 0?p:p(y)}w$2(s,a)||(s[a]=[]),s[a].push(l===void 0?!0:l),e.push(f);}},onArgument(a,l,f){r?.(C$1,n[l[0]])||(i.push(...a),f?(i[F$1]=a,n.splice(l[0])):e.push(l));}}),E(n,e),{flags:_$2(t,o),unknownFlags:s,_:i}}; -+ -+var DD=Object.create;var m=Object.defineProperty,uD=Object.defineProperties,FD=Object.getOwnPropertyDescriptor,CD=Object.getOwnPropertyDescriptors,tD=Object.getOwnPropertyNames,I$1=Object.getOwnPropertySymbols,ED=Object.getPrototypeOf,L$1=Object.prototype.hasOwnProperty,eD=Object.prototype.propertyIsEnumerable;var W$1=(D,F,u)=>F in D?m(D,F,{enumerable:!0,configurable:!0,writable:!0,value:u}):D[F]=u,p=(D,F)=>{for(var u in F||(F={}))L$1.call(F,u)&&W$1(D,u,F[u]);if(I$1)for(var u of I$1(F))eD.call(F,u)&&W$1(D,u,F[u]);return D},c=(D,F)=>uD(D,CD(F)),nD=D=>m(D,"__esModule",{value:!0});var rD=(D,F)=>()=>(D&&(F=D(D=0)),F);var iD=(D,F)=>()=>(F||D((F={exports:{}}).exports,F),F.exports);var oD=(D,F,u,C)=>{if(F&&typeof F=="object"||typeof F=="function")for(let t of tD(F))!L$1.call(D,t)&&(t!=="default")&&m(D,t,{get:()=>F[t],enumerable:!(C=FD(F,t))||C.enumerable});return D},BD=(D,F)=>oD(nD(m(D!=null?DD(ED(D)):{},"default",{value:D,enumerable:!0})),D);var i=rD(()=>{});var $=iD((LD,N)=>{i();N.exports=function(){return /\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62(?:\uDB40\uDC77\uDB40\uDC6C\uDB40\uDC73|\uDB40\uDC73\uDB40\uDC63\uDB40\uDC74|\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67)\uDB40\uDC7F|(?:\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDD1D\u200D(?:\uD83D[\uDC68\uDC69]))(?:\uD83C[\uDFFB-\uDFFE])|(?:\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDD1D\u200D(?:\uD83D[\uDC68\uDC69]))(?:\uD83C[\uDFFB-\uDFFD\uDFFF])|(?:\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDD1D\u200D(?:\uD83D[\uDC68\uDC69]))(?:\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])|(?:\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDD1D\u200D(?:\uD83D[\uDC68\uDC69]))(?:\uD83C[\uDFFB\uDFFD-\uDFFF])|(?:\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDD1D\u200D(?:\uD83D[\uDC68\uDC69]))(?:\uD83C[\uDFFC-\uDFFF])|\uD83D\uDC68(?:\uD83C\uDFFB(?:\u200D(?:\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFF])|\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFF]))|\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFC-\uDFFF])|[\u2695\u2696\u2708]\uFE0F|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD]))?|(?:\uD83C[\uDFFC-\uDFFF])\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFF])|\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFF]))|\u200D(?:\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83D\uDC68|(?:\uD83D[\uDC68\uDC69])\u200D(?:\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67]))|\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67])|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFF\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFE])|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFE\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFD\uDFFF])|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFD\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFC\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB\uDFFD-\uDFFF])|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|(?:\uD83C\uDFFF\u200D[\u2695\u2696\u2708]|\uD83C\uDFFE\u200D[\u2695\u2696\u2708]|\uD83C\uDFFD\u200D[\u2695\u2696\u2708]|\uD83C\uDFFC\u200D[\u2695\u2696\u2708]|\u200D[\u2695\u2696\u2708])\uFE0F|\u200D(?:(?:\uD83D[\uDC68\uDC69])\u200D(?:\uD83D[\uDC66\uDC67])|\uD83D[\uDC66\uDC67])|\uD83C\uDFFF|\uD83C\uDFFE|\uD83C\uDFFD|\uD83C\uDFFC)?|(?:\uD83D\uDC69(?:\uD83C\uDFFB\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D(?:\uD83D[\uDC68\uDC69])|\uD83D[\uDC68\uDC69])|(?:\uD83C[\uDFFC-\uDFFF])\u200D\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D(?:\uD83D[\uDC68\uDC69])|\uD83D[\uDC68\uDC69]))|\uD83E\uDDD1(?:\uD83C[\uDFFB-\uDFFF])\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1)(?:\uD83C[\uDFFB-\uDFFF])|\uD83D\uDC69\u200D\uD83D\uDC69\u200D(?:\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67]))|\uD83D\uDC69(?:\u200D(?:\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D(?:\uD83D[\uDC68\uDC69])|\uD83D[\uDC68\uDC69])|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFF\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFE\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFD\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFC\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFB\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD]))|\uD83E\uDDD1(?:\u200D(?:\uD83E\uDD1D\u200D\uD83E\uDDD1|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFF\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFE\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFD\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFC\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFB\u200D(?:\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD]))|\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC69\u200D\uD83D\uDC69\u200D(?:\uD83D[\uDC66\uDC67])|\uD83D\uDC69\u200D\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67])|(?:\uD83D\uDC41\uFE0F\u200D\uD83D\uDDE8|\uD83E\uDDD1(?:\uD83C\uDFFF\u200D[\u2695\u2696\u2708]|\uD83C\uDFFE\u200D[\u2695\u2696\u2708]|\uD83C\uDFFD\u200D[\u2695\u2696\u2708]|\uD83C\uDFFC\u200D[\u2695\u2696\u2708]|\uD83C\uDFFB\u200D[\u2695\u2696\u2708]|\u200D[\u2695\u2696\u2708])|\uD83D\uDC69(?:\uD83C\uDFFF\u200D[\u2695\u2696\u2708]|\uD83C\uDFFE\u200D[\u2695\u2696\u2708]|\uD83C\uDFFD\u200D[\u2695\u2696\u2708]|\uD83C\uDFFC\u200D[\u2695\u2696\u2708]|\uD83C\uDFFB\u200D[\u2695\u2696\u2708]|\u200D[\u2695\u2696\u2708])|\uD83D\uDE36\u200D\uD83C\uDF2B|\uD83C\uDFF3\uFE0F\u200D\u26A7|\uD83D\uDC3B\u200D\u2744|(?:(?:\uD83C[\uDFC3\uDFC4\uDFCA]|\uD83D[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6]|\uD83E[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD])(?:\uD83C[\uDFFB-\uDFFF])|\uD83D\uDC6F|\uD83E[\uDD3C\uDDDE\uDDDF])\u200D[\u2640\u2642]|(?:\u26F9|\uD83C[\uDFCB\uDFCC]|\uD83D\uDD75)(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])\u200D[\u2640\u2642]|\uD83C\uDFF4\u200D\u2620|(?:\uD83C[\uDFC3\uDFC4\uDFCA]|\uD83D[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6]|\uD83E[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD])\u200D[\u2640\u2642]|[\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u2328\u23CF\u23ED-\u23EF\u23F1\u23F2\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB\u25FC\u2600-\u2604\u260E\u2611\u2618\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u2692\u2694-\u2697\u2699\u269B\u269C\u26A0\u26A7\u26B0\u26B1\u26C8\u26CF\u26D1\u26D3\u26E9\u26F0\u26F1\u26F4\u26F7\u26F8\u2702\u2708\u2709\u270F\u2712\u2714\u2716\u271D\u2721\u2733\u2734\u2744\u2747\u2763\u27A1\u2934\u2935\u2B05-\u2B07\u3030\u303D\u3297\u3299]|\uD83C[\uDD70\uDD71\uDD7E\uDD7F\uDE02\uDE37\uDF21\uDF24-\uDF2C\uDF36\uDF7D\uDF96\uDF97\uDF99-\uDF9B\uDF9E\uDF9F\uDFCD\uDFCE\uDFD4-\uDFDF\uDFF5\uDFF7]|\uD83D[\uDC3F\uDCFD\uDD49\uDD4A\uDD6F\uDD70\uDD73\uDD76-\uDD79\uDD87\uDD8A-\uDD8D\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA\uDECB\uDECD-\uDECF\uDEE0-\uDEE5\uDEE9\uDEF0\uDEF3])\uFE0F|\uD83C\uDFF3\uFE0F\u200D\uD83C\uDF08|\uD83D\uDC69\u200D\uD83D\uDC67|\uD83D\uDC69\u200D\uD83D\uDC66|\uD83D\uDE35\u200D\uD83D\uDCAB|\uD83D\uDE2E\u200D\uD83D\uDCA8|\uD83D\uDC15\u200D\uD83E\uDDBA|\uD83E\uDDD1(?:\uD83C\uDFFF|\uD83C\uDFFE|\uD83C\uDFFD|\uD83C\uDFFC|\uD83C\uDFFB)?|\uD83D\uDC69(?:\uD83C\uDFFF|\uD83C\uDFFE|\uD83C\uDFFD|\uD83C\uDFFC|\uD83C\uDFFB)?|\uD83C\uDDFD\uD83C\uDDF0|\uD83C\uDDF6\uD83C\uDDE6|\uD83C\uDDF4\uD83C\uDDF2|\uD83D\uDC08\u200D\u2B1B|\u2764\uFE0F\u200D(?:\uD83D\uDD25|\uD83E\uDE79)|\uD83D\uDC41\uFE0F|\uD83C\uDFF3\uFE0F|\uD83C\uDDFF(?:\uD83C[\uDDE6\uDDF2\uDDFC])|\uD83C\uDDFE(?:\uD83C[\uDDEA\uDDF9])|\uD83C\uDDFC(?:\uD83C[\uDDEB\uDDF8])|\uD83C\uDDFB(?:\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDEE\uDDF3\uDDFA])|\uD83C\uDDFA(?:\uD83C[\uDDE6\uDDEC\uDDF2\uDDF3\uDDF8\uDDFE\uDDFF])|\uD83C\uDDF9(?:\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDED\uDDEF-\uDDF4\uDDF7\uDDF9\uDDFB\uDDFC\uDDFF])|\uD83C\uDDF8(?:\uD83C[\uDDE6-\uDDEA\uDDEC-\uDDF4\uDDF7-\uDDF9\uDDFB\uDDFD-\uDDFF])|\uD83C\uDDF7(?:\uD83C[\uDDEA\uDDF4\uDDF8\uDDFA\uDDFC])|\uD83C\uDDF5(?:\uD83C[\uDDE6\uDDEA-\uDDED\uDDF0-\uDDF3\uDDF7-\uDDF9\uDDFC\uDDFE])|\uD83C\uDDF3(?:\uD83C[\uDDE6\uDDE8\uDDEA-\uDDEC\uDDEE\uDDF1\uDDF4\uDDF5\uDDF7\uDDFA\uDDFF])|\uD83C\uDDF2(?:\uD83C[\uDDE6\uDDE8-\uDDED\uDDF0-\uDDFF])|\uD83C\uDDF1(?:\uD83C[\uDDE6-\uDDE8\uDDEE\uDDF0\uDDF7-\uDDFB\uDDFE])|\uD83C\uDDF0(?:\uD83C[\uDDEA\uDDEC-\uDDEE\uDDF2\uDDF3\uDDF5\uDDF7\uDDFC\uDDFE\uDDFF])|\uD83C\uDDEF(?:\uD83C[\uDDEA\uDDF2\uDDF4\uDDF5])|\uD83C\uDDEE(?:\uD83C[\uDDE8-\uDDEA\uDDF1-\uDDF4\uDDF6-\uDDF9])|\uD83C\uDDED(?:\uD83C[\uDDF0\uDDF2\uDDF3\uDDF7\uDDF9\uDDFA])|\uD83C\uDDEC(?:\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEE\uDDF1-\uDDF3\uDDF5-\uDDFA\uDDFC\uDDFE])|\uD83C\uDDEB(?:\uD83C[\uDDEE-\uDDF0\uDDF2\uDDF4\uDDF7])|\uD83C\uDDEA(?:\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDED\uDDF7-\uDDFA])|\uD83C\uDDE9(?:\uD83C[\uDDEA\uDDEC\uDDEF\uDDF0\uDDF2\uDDF4\uDDFF])|\uD83C\uDDE8(?:\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDEE\uDDF0-\uDDF5\uDDF7\uDDFA-\uDDFF])|\uD83C\uDDE7(?:\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEF\uDDF1-\uDDF4\uDDF6-\uDDF9\uDDFB\uDDFC\uDDFE\uDDFF])|\uD83C\uDDE6(?:\uD83C[\uDDE8-\uDDEC\uDDEE\uDDF1\uDDF2\uDDF4\uDDF6-\uDDFA\uDDFC\uDDFD\uDDFF])|[#\*0-9]\uFE0F\u20E3|\u2764\uFE0F|(?:\uD83C[\uDFC3\uDFC4\uDFCA]|\uD83D[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6]|\uD83E[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD])(?:\uD83C[\uDFFB-\uDFFF])|(?:\u26F9|\uD83C[\uDFCB\uDFCC]|\uD83D\uDD75)(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])|\uD83C\uDFF4|(?:[\u270A\u270B]|\uD83C[\uDF85\uDFC2\uDFC7]|\uD83D[\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDC8F\uDC91\uDCAA\uDD7A\uDD95\uDD96\uDE4C\uDE4F\uDEC0\uDECC]|\uD83E[\uDD0C\uDD0F\uDD18-\uDD1C\uDD1E\uDD1F\uDD30-\uDD34\uDD36\uDD77\uDDB5\uDDB6\uDDBB\uDDD2\uDDD3\uDDD5])(?:\uD83C[\uDFFB-\uDFFF])|(?:[\u261D\u270C\u270D]|\uD83D[\uDD74\uDD90])(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])|[\u270A\u270B]|\uD83C[\uDF85\uDFC2\uDFC7]|\uD83D[\uDC08\uDC15\uDC3B\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDC8F\uDC91\uDCAA\uDD7A\uDD95\uDD96\uDE2E\uDE35\uDE36\uDE4C\uDE4F\uDEC0\uDECC]|\uD83E[\uDD0C\uDD0F\uDD18-\uDD1C\uDD1E\uDD1F\uDD30-\uDD34\uDD36\uDD77\uDDB5\uDDB6\uDDBB\uDDD2\uDDD3\uDDD5]|\uD83C[\uDFC3\uDFC4\uDFCA]|\uD83D[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6]|\uD83E[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD]|\uD83D\uDC6F|\uD83E[\uDD3C\uDDDE\uDDDF]|[\u231A\u231B\u23E9-\u23EC\u23F0\u23F3\u25FD\u25FE\u2614\u2615\u2648-\u2653\u267F\u2693\u26A1\u26AA\u26AB\u26BD\u26BE\u26C4\u26C5\u26CE\u26D4\u26EA\u26F2\u26F3\u26F5\u26FA\u26FD\u2705\u2728\u274C\u274E\u2753-\u2755\u2757\u2795-\u2797\u27B0\u27BF\u2B1B\u2B1C\u2B50\u2B55]|\uD83C[\uDC04\uDCCF\uDD8E\uDD91-\uDD9A\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF84\uDF86-\uDF93\uDFA0-\uDFC1\uDFC5\uDFC6\uDFC8\uDFC9\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF8-\uDFFF]|\uD83D[\uDC00-\uDC07\uDC09-\uDC14\uDC16-\uDC3A\uDC3C-\uDC3E\uDC40\uDC44\uDC45\uDC51-\uDC65\uDC6A\uDC79-\uDC7B\uDC7D-\uDC80\uDC84\uDC88-\uDC8E\uDC90\uDC92-\uDCA9\uDCAB-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDDA4\uDDFB-\uDE2D\uDE2F-\uDE34\uDE37-\uDE44\uDE48-\uDE4A\uDE80-\uDEA2\uDEA4-\uDEB3\uDEB7-\uDEBF\uDEC1-\uDEC5\uDED0-\uDED2\uDED5-\uDED7\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB]|\uD83E[\uDD0D\uDD0E\uDD10-\uDD17\uDD1D\uDD20-\uDD25\uDD27-\uDD2F\uDD3A\uDD3F-\uDD45\uDD47-\uDD76\uDD78\uDD7A-\uDDB4\uDDB7\uDDBA\uDDBC-\uDDCB\uDDD0\uDDE0-\uDDFF\uDE70-\uDE74\uDE78-\uDE7A\uDE80-\uDE86\uDE90-\uDEA8\uDEB0-\uDEB6\uDEC0-\uDEC2\uDED0-\uDED6]|(?:[\u231A\u231B\u23E9-\u23EC\u23F0\u23F3\u25FD\u25FE\u2614\u2615\u2648-\u2653\u267F\u2693\u26A1\u26AA\u26AB\u26BD\u26BE\u26C4\u26C5\u26CE\u26D4\u26EA\u26F2\u26F3\u26F5\u26FA\u26FD\u2705\u270A\u270B\u2728\u274C\u274E\u2753-\u2755\u2757\u2795-\u2797\u27B0\u27BF\u2B1B\u2B1C\u2B50\u2B55]|\uD83C[\uDC04\uDCCF\uDD8E\uDD91-\uDD9A\uDDE6-\uDDFF\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF93\uDFA0-\uDFCA\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF4\uDFF8-\uDFFF]|\uD83D[\uDC00-\uDC3E\uDC40\uDC42-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDD7A\uDD95\uDD96\uDDA4\uDDFB-\uDE4F\uDE80-\uDEC5\uDECC\uDED0-\uDED2\uDED5-\uDED7\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB]|\uD83E[\uDD0C-\uDD3A\uDD3C-\uDD45\uDD47-\uDD78\uDD7A-\uDDCB\uDDCD-\uDDFF\uDE70-\uDE74\uDE78-\uDE7A\uDE80-\uDE86\uDE90-\uDEA8\uDEB0-\uDEB6\uDEC0-\uDEC2\uDED0-\uDED6])|(?:[#\*0-9\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u231A\u231B\u2328\u23CF\u23E9-\u23F3\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB-\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u261D\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692-\u2697\u2699\u269B\u269C\u26A0\u26A1\u26A7\u26AA\u26AB\u26B0\u26B1\u26BD\u26BE\u26C4\u26C5\u26C8\u26CE\u26CF\u26D1\u26D3\u26D4\u26E9\u26EA\u26F0-\u26F5\u26F7-\u26FA\u26FD\u2702\u2705\u2708-\u270D\u270F\u2712\u2714\u2716\u271D\u2721\u2728\u2733\u2734\u2744\u2747\u274C\u274E\u2753-\u2755\u2757\u2763\u2764\u2795-\u2797\u27A1\u27B0\u27BF\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B50\u2B55\u3030\u303D\u3297\u3299]|\uD83C[\uDC04\uDCCF\uDD70\uDD71\uDD7E\uDD7F\uDD8E\uDD91-\uDD9A\uDDE6-\uDDFF\uDE01\uDE02\uDE1A\uDE2F\uDE32-\uDE3A\uDE50\uDE51\uDF00-\uDF21\uDF24-\uDF93\uDF96\uDF97\uDF99-\uDF9B\uDF9E-\uDFF0\uDFF3-\uDFF5\uDFF7-\uDFFF]|\uD83D[\uDC00-\uDCFD\uDCFF-\uDD3D\uDD49-\uDD4E\uDD50-\uDD67\uDD6F\uDD70\uDD73-\uDD7A\uDD87\uDD8A-\uDD8D\uDD90\uDD95\uDD96\uDDA4\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA-\uDE4F\uDE80-\uDEC5\uDECB-\uDED2\uDED5-\uDED7\uDEE0-\uDEE5\uDEE9\uDEEB\uDEEC\uDEF0\uDEF3-\uDEFC\uDFE0-\uDFEB]|\uD83E[\uDD0C-\uDD3A\uDD3C-\uDD45\uDD47-\uDD78\uDD7A-\uDDCB\uDDCD-\uDDFF\uDE70-\uDE74\uDE78-\uDE7A\uDE80-\uDE86\uDE90-\uDEA8\uDEB0-\uDEB6\uDEC0-\uDEC2\uDED0-\uDED6])\uFE0F|(?:[\u261D\u26F9\u270A-\u270D]|\uD83C[\uDF85\uDFC2-\uDFC4\uDFC7\uDFCA-\uDFCC]|\uD83D[\uDC42\uDC43\uDC46-\uDC50\uDC66-\uDC78\uDC7C\uDC81-\uDC83\uDC85-\uDC87\uDC8F\uDC91\uDCAA\uDD74\uDD75\uDD7A\uDD90\uDD95\uDD96\uDE45-\uDE47\uDE4B-\uDE4F\uDEA3\uDEB4-\uDEB6\uDEC0\uDECC]|\uD83E[\uDD0C\uDD0F\uDD18-\uDD1F\uDD26\uDD30-\uDD39\uDD3C-\uDD3E\uDD77\uDDB5\uDDB6\uDDB8\uDDB9\uDDBB\uDDCD-\uDDCF\uDDD1-\uDDDD])/g};});i();i();i();var v=D=>{var u,C,t;let F=(u=process.stdout.columns)!=null?u:Number.POSITIVE_INFINITY;return typeof D=="function"&&(D=D(F)),D||(D={}),Array.isArray(D)?{columns:D,stdoutColumns:F}:{columns:(C=D.columns)!=null?C:[],stdoutColumns:(t=D.stdoutColumns)!=null?t:F}};i();i();i();i();i();function w$1({onlyFirst:D=!1}={}){let F=["[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)","(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))"].join("|");return new RegExp(F,D?void 0:"g")}function d$1(D){if(typeof D!="string")throw new TypeError(`Expected a \`string\`, got \`${typeof D}\``);return D.replace(w$1(),"")}i();function y$1(D){return Number.isInteger(D)?D>=4352&&(D<=4447||D===9001||D===9002||11904<=D&&D<=12871&&D!==12351||12880<=D&&D<=19903||19968<=D&&D<=42182||43360<=D&&D<=43388||44032<=D&&D<=55203||63744<=D&&D<=64255||65040<=D&&D<=65049||65072<=D&&D<=65131||65281<=D&&D<=65376||65504<=D&&D<=65510||110592<=D&&D<=110593||127488<=D&&D<=127569||131072<=D&&D<=262141):!1}var j=BD($());function g(D){if(typeof D!="string"||D.length===0||(D=d$1(D),D.length===0))return 0;D=D.replace((0, j.default)()," ");let F=0;for(let u=0;u=127&&C<=159||C>=768&&C<=879||(C>65535&&u++,F+=y$1(C)?2:1);}return F}var b$1=D=>Math.max(...D.split(` -+`).map(g));var k$1=D=>{let F=[];for(let u of D){let{length:C}=u,t=C-F.length;for(let E=0;EF[E]&&(F[E]=e);}}return F};i();var _$1=/^\d+%$/,z$1={width:"auto",align:"left",contentWidth:0,paddingLeft:0,paddingRight:0,paddingTop:0,paddingBottom:0,horizontalPadding:0,paddingLeftString:"",paddingRightString:""},sD=(D,F)=>{var C;let u=[];for(let t=0;t=F){let n=e-F,o=Math.ceil(u.paddingLeft/t*n),B=n-o;u.paddingLeft-=o,u.paddingRight-=B,u.horizontalPadding=u.paddingLeft+u.paddingRight;}u.paddingLeftString=u.paddingLeft?" ".repeat(u.paddingLeft):"",u.paddingRightString=u.paddingRight?" ".repeat(u.paddingRight):"";let r=F-u.horizontalPadding;u.width=Math.max(Math.min(u.width,r),E);}}var G$1=()=>Object.assign([],{columns:0});function lD(D,F){let u=[G$1()],[C]=u;for(let t of D){let E=t.width+t.horizontalPadding;C.columns+E>F&&(C=G$1(),u.push(C)),C.push(t),C.columns+=E;}for(let t of u){let E=t.reduce((s,l)=>s+l.width+l.horizontalPadding,0),e=F-E;if(e===0)continue;let r=t.filter(s=>"autoOverflow"in s),n=r.filter(s=>s.autoOverflow>0),o=n.reduce((s,l)=>s+l.autoOverflow,0),B=Math.min(o,e);for(let s of n){let l=Math.floor(s.autoOverflow/o*B);s.width+=l,e-=l;}let a=Math.floor(e/r.length);for(let s=0;sF=>`[${F+D}m`,V$1=(D=0)=>F=>`[${38+D};5;${F}m`,Y=(D=0)=>(F,u,C)=>`[${38+D};2;${F};${u};${C}m`;function AD(){let D=new Map,F={modifier:{reset:[0,0],bold:[1,22],dim:[2,22],italic:[3,23],underline:[4,24],overline:[53,55],inverse:[7,27],hidden:[8,28],strikethrough:[9,29]},color:{black:[30,39],red:[31,39],green:[32,39],yellow:[33,39],blue:[34,39],magenta:[35,39],cyan:[36,39],white:[37,39],blackBright:[90,39],redBright:[91,39],greenBright:[92,39],yellowBright:[93,39],blueBright:[94,39],magentaBright:[95,39],cyanBright:[96,39],whiteBright:[97,39]},bgColor:{bgBlack:[40,49],bgRed:[41,49],bgGreen:[42,49],bgYellow:[43,49],bgBlue:[44,49],bgMagenta:[45,49],bgCyan:[46,49],bgWhite:[47,49],bgBlackBright:[100,49],bgRedBright:[101,49],bgGreenBright:[102,49],bgYellowBright:[103,49],bgBlueBright:[104,49],bgMagentaBright:[105,49],bgCyanBright:[106,49],bgWhiteBright:[107,49]}};F.color.gray=F.color.blackBright,F.bgColor.bgGray=F.bgColor.bgBlackBright,F.color.grey=F.color.blackBright,F.bgColor.bgGrey=F.bgColor.bgBlackBright;for(let[u,C]of Object.entries(F)){for(let[t,E]of Object.entries(C))F[t]={open:`[${E[0]}m`,close:`[${E[1]}m`},C[t]=F[t],D.set(E[0],E[1]);Object.defineProperty(F,u,{value:C,enumerable:!1});}return Object.defineProperty(F,"codes",{value:D,enumerable:!1}),F.color.close="",F.bgColor.close="",F.color.ansi=U$1(),F.color.ansi256=V$1(),F.color.ansi16m=Y(),F.bgColor.ansi=U$1(O),F.bgColor.ansi256=V$1(O),F.bgColor.ansi16m=Y(O),Object.defineProperties(F,{rgbToAnsi256:{value:(u,C,t)=>u===C&&C===t?u<8?16:u>248?231:Math.round((u-8)/247*24)+232:16+36*Math.round(u/255*5)+6*Math.round(C/255*5)+Math.round(t/255*5),enumerable:!1},hexToRgb:{value:u=>{let C=/(?[a-f\d]{6}|[a-f\d]{3})/i.exec(u.toString(16));if(!C)return [0,0,0];let{colorString:t}=C.groups;t.length===3&&(t=t.split("").map(e=>e+e).join(""));let E=Number.parseInt(t,16);return [E>>16&255,E>>8&255,E&255]},enumerable:!1},hexToAnsi256:{value:u=>F.rgbToAnsi256(...F.hexToRgb(u)),enumerable:!1},ansi256ToAnsi:{value:u=>{if(u<8)return 30+u;if(u<16)return 90+(u-8);let C,t,E;if(u>=232)C=((u-232)*10+8)/255,t=C,E=C;else {u-=16;let n=u%36;C=Math.floor(u/36)/5,t=Math.floor(n/6)/5,E=n%6/5;}let e=Math.max(C,t,E)*2;if(e===0)return 30;let r=30+(Math.round(E)<<2|Math.round(t)<<1|Math.round(C));return e===2&&(r+=60),r},enumerable:!1},rgbToAnsi:{value:(u,C,t)=>F.ansi256ToAnsi(F.rgbToAnsi256(u,C,t)),enumerable:!1},hexToAnsi:{value:u=>F.ansi256ToAnsi(F.hexToAnsi256(u)),enumerable:!1}}),F}var fD=AD(),K=fD;var x$1=new Set(["","\x9B"]),gD=39,R$1="\x07",q$1="[",pD="]",H$1="m",M$1=`${pD}8;;`,J$1=D=>`${x$1.values().next().value}${q$1}${D}${H$1}`,Q=D=>`${x$1.values().next().value}${M$1}${D}${R$1}`,hD=D=>D.split(" ").map(F=>g(F)),S$1=(D,F,u)=>{let C=[...F],t=!1,E=!1,e=g(d$1(D[D.length-1]));for(let[r,n]of C.entries()){let o=g(n);if(e+o<=u?D[D.length-1]+=n:(D.push(n),e=0),x$1.has(n)&&(t=!0,E=C.slice(r+1).join("").startsWith(M$1)),t){E?n===R$1&&(t=!1,E=!1):n===H$1&&(t=!1);continue}e+=o,e===u&&r0&&D.length>1&&(D[D.length-2]+=D.pop());},cD=D=>{let F=D.split(" "),u=F.length;for(;u>0&&!(g(F[u-1])>0);)u--;return u===F.length?D:F.slice(0,u).join(" ")+F.slice(u).join("")},dD=(D,F,u={})=>{if(u.trim!==!1&&D.trim()==="")return "";let C="",t,E,e=hD(D),r=[""];for(let[o,B]of D.split(" ").entries()){u.trim!==!1&&(r[r.length-1]=r[r.length-1].trimStart());let a=g(r[r.length-1]);if(o!==0&&(a>=F&&(u.wordWrap===!1||u.trim===!1)&&(r.push(""),a=0),(a>0||u.trim===!1)&&(r[r.length-1]+=" ",a++)),u.hard&&e[o]>F){let s=F-a,l=1+Math.floor((e[o]-s-1)/F);Math.floor((e[o]-1)/F)F&&a>0&&e[o]>0){if(u.wordWrap===!1&&aF&&u.wordWrap===!1){S$1(r,B,F);continue}r[r.length-1]+=B;}u.trim!==!1&&(r=r.map(o=>cD(o)));let n=[...r.join(` -+`)];for(let[o,B]of n.entries()){if(C+=B,x$1.has(B)){let{groups:s}=new RegExp(`(?:\\${q$1}(?\\d+)m|\\${M$1}(?.*)${R$1})`).exec(n.slice(o).join(""))||{groups:{}};if(s.code!==void 0){let l=Number.parseFloat(s.code);t=l===gD?void 0:l;}else s.uri!==void 0&&(E=s.uri.length===0?void 0:s.uri);}let a=K.codes.get(Number(t));n[o+1]===` -+`?(E&&(C+=Q("")),t&&a&&(C+=J$1(a))):B===` -+`&&(t&&a&&(C+=J$1(t)),E&&(C+=Q(E)));}return C};function T$1(D,F,u){return String(D).normalize().replace(/\r\n/g,` - `).split(` --`).map(D=>AD(D,e,t)).join(` --`)}var $u=u=>Array.from({length:u}).fill("");function vD(u,e){let t=[],D=0;for(let n of u){let i=0,s=n.map(a=>{var h;let f=(h=e[D])!=null?h:"";D+=1,a.preprocess&&(f=a.preprocess(f)),pu(f)>a.width&&(f=bD(f,a.width,{hard:!0}));let g=f.split(` --`);if(a.postprocess){let{postprocess:F}=a;g=g.map((d,m)=>F.call(a,d,m))}return a.paddingTop&&g.unshift(...$u(a.paddingTop)),a.paddingBottom&&g.push(...$u(a.paddingBottom)),g.length>i&&(i=g.length),Re(Be({},a),{lines:g})}),o=[];for(let a=0;a{var g;let F=(g=f.lines[a])!=null?g:"",d=Number.isFinite(f.width)?" ".repeat(f.width-V(F)):"",m=f.paddingLeftString;return f.align==="right"&&(m+=d),m+=F,f.align==="left"&&(m+=d),m+f.paddingRightString}).join("");o.push(h)}t.push(o.join(` --`))}return t.join(` --`)}function SD(u,e){if(!u||u.length===0)return"";let t=pD(u),D=t.length;if(D===0)return"";let{stdoutColumns:n,columns:i}=aD(e);if(i.length>D)throw new Error(`${i.length} columns defined, but only ${D} columns found`);let s=CD(n,i,t);return u.map(o=>vD(s,o)).join(` --`)}x();var OD=["<",">","=",">=","<="];function kD(u){if(!OD.includes(u))throw new TypeError(`Invalid breakpoint operator: ${u}`)}function jD(u){let e=Object.keys(u).map(t=>{let[D,n]=t.split(" ");kD(D);let i=Number.parseInt(n,10);if(Number.isNaN(i))throw new TypeError(`Invalid breakpoint value: ${n}`);let s=u[t];return{operator:D,breakpoint:i,value:s}}).sort((t,D)=>D.breakpoint-t.breakpoint);return t=>{var D;return(D=e.find(({operator:n,breakpoint:i})=>n==="="&&t===i||n===">"&&t>i||n==="<"&&t="&&t>=i||n==="<="&&t<=i))==null?void 0:D.value}}const xD=u=>u.replace(/[\W_]([a-z\d])?/gi,(e,t)=>t?t.toUpperCase():""),TD=u=>u.replace(/\B([A-Z])/g,"-$1").toLowerCase(),RD={"> 80":[{width:"content-width",paddingLeft:2,paddingRight:8},{width:"auto"}],"> 40":[{width:"auto",paddingLeft:2,paddingRight:8,preprocess:u=>u.trim()},{width:"100%",paddingLeft:2,paddingBottom:1}],"> 0":{stdoutColumns:1e3,columns:[{width:"content-width",paddingLeft:2,paddingRight:8},{width:"content-width"}]}};function PD(u){let e=!1;return{type:"table",data:{tableData:Object.keys(u).sort((t,D)=>t.localeCompare(D)).map(t=>{const D=u[t],n="alias"in D;return n&&(e=!0),{name:t,flag:D,flagFormatted:`--${TD(t)}`,aliasesEnabled:e,aliasFormatted:n?`-${D.alias}`:void 0}}).map(t=>(t.aliasesEnabled=e,[{type:"flagName",data:t},{type:"flagDescription",data:t}])),tableBreakpoints:RD}}}const Au=u=>!u||(u.version??(u.help?u.help.version:void 0)),bu=u=>{const e="parent"in u&&u.parent?.name;return(e?`${e} `:"")+u.name};function LD(u){const e=[];u.name&&e.push(bu(u));const t=Au(u)??("parent"in u&&Au(u.parent));if(t&&e.push(`v${t}`),e.length!==0)return{id:"name",type:"text",data:`${e.join(" ")} --`}}function MD(u){const{help:e}=u;if(!(!e||!e.description))return{id:"description",type:"text",data:`${e.description} --`}}function ND(u){const e=u.help||{};if("usage"in e)return e.usage?{id:"usage",type:"section",data:{title:"Usage:",body:Array.isArray(e.usage)?e.usage.join(` --`):e.usage}}:void 0;if(u.name){const t=[],D=[bu(u)];if(u.flags&&Object.keys(u.flags).length>0&&D.push("[flags...]"),u.parameters&&u.parameters.length>0){const{parameters:n}=u,i=n.indexOf("--"),s=i>-1&&n.slice(i+1).some(o=>o.startsWith("<"));D.push(n.map(o=>o!=="--"?o:s?"--":"[--]").join(" "))}if(D.length>1&&t.push(D.join(" ")),"commands"in u&&u.commands?.length&&t.push(`${u.name} `),t.length>0)return{id:"usage",type:"section",data:{title:"Usage:",body:t.join(` --`)}}}}function ID(u){return!("commands"in u)||!u.commands?.length?void 0:{id:"commands",type:"section",data:{title:"Commands:",body:{type:"table",data:{tableData:u.commands.map(e=>[e.options.name,e.options.help?e.options.help.description:""]),tableOptions:[{width:"content-width",paddingLeft:2,paddingRight:8}]}},indentBody:0}}}function _D(u){if(!(!u.flags||Object.keys(u.flags).length===0))return{id:"flags",type:"section",data:{title:"Flags:",body:PD(u.flags),indentBody:0}}}function WD(u){const{help:e}=u;if(!e||!e.examples||e.examples.length===0)return;let{examples:t}=e;if(Array.isArray(t)&&(t=t.join(` --`)),t)return{id:"examples",type:"section",data:{title:"Examples:",body:t}}}function GD(u){if(!("alias"in u)||!u.alias)return;const{alias:e}=u;return{id:"aliases",type:"section",data:{title:"Aliases:",body:Array.isArray(e)?e.join(", "):e}}}const qD=u=>[LD,MD,ND,ID,_D,WD,GD].map(e=>e(u)).filter(Boolean),zD=Ot.WriteStream.prototype.hasColors();class JD{text(e){return e}bold(e){return zD?`\x1B[1m${e}\x1B[22m`:e.toLocaleUpperCase()}indentText({text:e,spaces:t}){return e.replace(/^/gm," ".repeat(t))}heading(e){return this.bold(e)}section({title:e,body:t,indentBody:D=2}){return`${(e?`${this.heading(e)} --`:"")+(t?this.indentText({text:this.render(t),spaces:D}):"")} --`}table({tableData:e,tableOptions:t,tableBreakpoints:D}){return SD(e.map(n=>n.map(i=>this.render(i))),D?jD(D):t)}flagParameter(e){return e===Boolean?"":e===String?"":e===Number?"":Array.isArray(e)?this.flagParameter(e[0]):""}flagOperator(e){return" "}flagName(e){const{flag:t,flagFormatted:D,aliasesEnabled:n,aliasFormatted:i}=e;let s="";if(i?s+=`${i}, `:n&&(s+=" "),s+=D,"placeholder"in t&&typeof t.placeholder=="string")s+=`${this.flagOperator(e)}${t.placeholder}`;else{const o=this.flagParameter("type"in t?t.type:t);o&&(s+=`${this.flagOperator(e)}${o}`)}return s}flagDefault(e){return JSON.stringify(e)}flagDescription({flag:e}){let t="description"in e?e.description??"":"";if("default"in e){let{default:D}=e;typeof D=="function"&&(D=D()),D&&(t+=` (default: ${this.flagDefault(D)})`)}return t}render(e){if(typeof e=="string")return e;if(Array.isArray(e))return e.map(t=>this.render(t)).join(` --`);if("type"in e&&this[e.type]){const t=this[e.type];if(typeof t=="function")return t.call(this,e.data)}throw new Error(`Invalid node type: ${JSON.stringify(e)}`)}}const Ie=/^[\w.-]+$/,{stringify:I}=JSON,VD=/[|\\{}()[\]^$+*?.]/;function _e(u){const e=[];let t,D;for(const n of u){if(D)throw new Error(`Invalid parameter: Spread parameter ${I(D)} must be last`);const i=n[0],s=n[n.length-1];let o;if(i==="<"&&s===">"&&(o=!0,t))throw new Error(`Invalid parameter: Required parameter ${I(n)} cannot come after optional parameter ${I(t)}`);if(i==="["&&s==="]"&&(o=!1,t=n),o===void 0)throw new Error(`Invalid parameter: ${I(n)}. Must be wrapped in <> (required parameter) or [] (optional parameter)`);let a=n.slice(1,-1);const h=a.slice(-3)==="...";h&&(D=n,a=a.slice(0,-3));const f=a.match(VD);if(f)throw new Error(`Invalid parameter: ${I(n)}. Invalid character found ${I(f[0])}`);e.push({name:a,required:o,spread:h})}return e}function We(u,e,t,D){for(let n=0;n{console.log(e.version)};if(i&&a.flags.version===!0)return h(),process.exit(0);const f=new JD,g=o&&s?.render?s.render:m=>f.render(m),F=m=>{const w=qD({...e,...m?{help:m}:{},flags:n});console.log(g(w,f))};if(o&&a.flags.help===!0)return F(),process.exit(0);if(e.parameters){let{parameters:m}=e,w=a._;const S=m.indexOf("--"),R=m.slice(S+1),M=Object.create(null);if(S>-1&&R.length>0){m=m.slice(0,S);const G=a._["--"];w=w.slice(0,-G.length||void 0),We(M,_e(m),w,F),We(M,_e(R),G,F)}else We(M,_e(m),w,F);Object.assign(a._,M)}const d={...a,showVersion:h,showHelp:F};return typeof t=="function"&&t(d),{command:u,...d}}function ZD(u,e){const t=new Map;for(const D of e){const n=[D.options.name],{alias:i}=D.options;i&&(Array.isArray(i)?n.push(...i):n.push(i));for(const s of n){if(t.has(s))throw new Error(`Duplicate command name found: ${I(s)}`);t.set(s,D)}}return t.get(u)}function HD(u,e,t=process.argv.slice(2)){if(!u)throw new Error("Options is required");if("name"in u&&(!u.name||!Ie.test(u.name)))throw new Error(`Invalid script name: ${I(u.name)}`);const D=t[0];if(u.commands&&Ie.test(D)){const n=ZD(D,u.commands);if(n)return vu(n.options.name,{...n.options,parent:u},n.callback,t.slice(1))}return vu(void 0,u,e,t)}function XD(u,e){if(!u)throw new Error("Command options are required");const{name:t}=u;if(u.name===void 0)throw new Error("Command name is required");if(!Ie.test(t))throw new Error(`Invalid command name ${JSON.stringify(t)}. Command names must be one word.`);return{options:u,callback:e}}function ye(){}function Su(){return typeof WeakMap<"u"?new WeakMap:YD()}function YD(){return{add:ye,delete:ye,get:ye,set:ye,has:function(u){return!1}}}var KD=Object.prototype.hasOwnProperty,Ge=function(u,e){return KD.call(u,e)};function qe(u,e){for(var t in e)Ge(e,t)&&(u[t]=e[t]);return u}var QD=/^[ \t]*(?:\r\n|\r|\n)/,en=/(?:\r\n|\r|\n)[ \t]*$/,un=/^(?:[\r\n]|$)/,tn=/(?:\r\n|\r|\n)([ \t]*)(?:[^ \t\r\n]|$)/,Dn=/^[ \t]*[\r\n][ \t\r\n]*$/;function Ou(u,e,t){var D=0,n=u[0].match(tn);n&&(D=n[1].length);var i="(\\r\\n|\\r|\\n).{0,"+D+"}",s=new RegExp(i,"g");e&&(u=u.slice(1));var o=t.newline,a=t.trimLeadingNewline,h=t.trimTrailingNewline,f=typeof o=="string",g=u.length,F=u.map(function(d,m){return d=d.replace(s,"$1"),m===0&&a&&(d=d.replace(QD,"")),m===g-1&&h&&(d=d.replace(en,"")),f&&(d=d.replace(/\r\n|\n|\r/g,function(w){return o})),d});return F}function nn(u,e){for(var t="",D=0,n=u.length;Di in H.process.env)?U=!0:U=process.stdout.isTTY,U&&(process.platform==="win32"||n&&(n==="truecolor"||n==="24bit")?se=3:D&&(D.endsWith("-256color")||D.endsWith("256"))?se=2:se=1)}let ju={enabled:U,supportLevel:se};function X(u,e,t=1){const D=`\x1B[${u}m`,n=`\x1B[${e}m`,i=new RegExp(`\\x1b\\[${e}m`,"g");return s=>ju.enabled&&ju.supportLevel>=t?D+(""+s).replace(i,D)+n:""+s}const rn=X(1,22),ze=X(2,22),xu=X(31,39),Tu=X(32,39),Je=X(35,39),re=X(36,39),oe=u=>O.access(u).then(()=>!0,()=>!1);var on=require;const Ru=async u=>{const e=await O.readFile(u,"utf8");return JSON.parse(e)},an="link.config.json",Pu="link.config.js",Lu=async u=>{const e=A.join(u,an);if(await oe(e))try{return Ru(e)}catch(D){throw new Error(`Failed to parse config JSON ${e}: ${D.message}`)}const t=A.join(u,Pu);if(await oe(t))try{return on(t)}catch(D){throw new Error(`Failed to load config file ${Pu}: ${D.message}`)}};var Mu=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function Nu(u){return u&&u.__esModule&&Object.prototype.hasOwnProperty.call(u,"default")?u.default:u}var $e={};$e.replaceDollarWithPercentPair=_u,$e.convertToSetCommand=Iu,$e.convertToSetCommands=cn;function Iu(u,e){var t="";return u=u||"",u=u.trim(),e=e||"",e=e.trim(),u&&e&&e.length>0&&(t="@SET "+u+"="+_u(e)+`\r --`),t}function ln(u){var e={};return u.map(function(t){var D=t.split("=");e[D[0]]=D[1]}),e}function cn(u){var e=ln(u.split(" ")),t="";return Object.keys(e).forEach(function(D){t+=Iu(D,e[D])}),t}function _u(u){var e=/\$\{?([^$@#?\- \t{}:]+)\}?/g,t="",D=0;do{var n=e.exec(u);if(n){var i=u.substring(D,n.index)||"";t+=i+"%"+n[1]+"%",D=e.lastIndex}}while(e.lastIndex>0);return t+=u.slice(D),t}const{chmod:Ve,mkdir:hn,readFile:pn,stat:Wu,unlink:fn,writeFile:Ue}=O,{dirname:Gu,relative:Fn}=A,dn=$e,Cn=/^#!\s*(?:\/usr\/bin\/env\s+(?:-S\s+)?((?:[^ \t=]+=[^ \t=]+\s+)*))?([^ \t]+)(.*)$/,En=(u,e)=>Wu(u).then(()=>He(u,e),()=>{}),Ze=u=>fn(u).catch(()=>{}),He=(u,e)=>Wu(u).then(()=>gn(u,e)),gn=(u,e)=>Promise.all([Ze(e),Ze(e+".cmd"),Ze(e+".ps1")]).then(()=>mn(u,e)),mn=(u,e)=>hn(Gu(e),{recursive:!0}).then(()=>pn(u,"utf8")).then(t=>{const n=t.trim().split(/\r*\n/)[0].match(Cn);if(!n)return Xe(u,e);const i=n[1]||"",s=n[2],o=n[3]||"";return Xe(u,e,s,o,i)},t=>Xe(u,e)),Xe=(u,e,t,D,n)=>{let i=Fn(Gu(e),u),s=i.split("/").join("\\"),o,a=t&&t.split("\\").join("/"),h,f=a&&`"${a}$exe"`,g;i=i.split("\\").join("/"),D=D||"",n=n||"",t?(o=`"%dp0%\\${t}.exe"`,h=`"$basedir/${t}"`,g=`"$basedir/${t}$exe"`,s=`"%dp0%\\${s}"`,i=`"$basedir/${i}"`):(t=`"%dp0%\\${s}"`,a=`"$basedir/${i}"`,f=a,D="",s="",i="");const F=`@ECHO off\r --GOTO start\r --:find_dp0\r --SET dp0=%~dp0\r --EXIT /b\r --:start\r --SETLOCAL\r --CALL :find_dp0\r --`;let d;if(o){h=h.trim(),D=D.trim();const S=dn.convertToSetCommands(n);d=F+S+`\r --IF EXIST ${o} (\r -- SET "_prog=${o.replace(/(^")|("$)/g,"")}"\r --) ELSE (\r -- SET "_prog=${t.replace(/(^")|("$)/g,"")}"\r -- SET PATHEXT=%PATHEXT:;.JS;=;%\r --)\r --\r --endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" ${D} ${s} %*\r --`}else d=`${F}${t} ${D} ${s} %*\r --`;let m=`#!/bin/sh --`;m=m+`basedir=$(dirname "$(echo "$0" | sed -e 's,\\\\,/,g')") -- --case \`uname\` in -- *CYGWIN*|*MINGW*|*MSYS*) basedir=\`cygpath -w "$basedir"\`;; --esac -- --`,h?m=m+`if [ -x ${h} ]; then -- exec ${n}${h} ${D} ${i} "$@" --else -- exec ${n}${a} ${D} ${i} "$@" --fi --`:m=m+`exec ${a} ${D} ${i} "$@" --`;let w=`#!/usr/bin/env pwsh --$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent -- --$exe="" --if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { -- # Fix case when both the Windows and Linux builds of Node -- # are installed in the same directory -- $exe=".exe" --} --`;return h?w=w+`$ret=0 --if (Test-Path ${g}) { -- # Support pipeline input -- if ($MyInvocation.ExpectingInput) { -- $input | & ${g} ${D} ${i} $args -+`).map(C=>dD(C,F,u)).join(` -+`)}var X=D=>Array.from({length:D}).fill("");function P(D,F){let u=[],C=0;for(let t of D){let E=0,e=t.map(n=>{var a;let o=(a=F[C])!=null?a:"";C+=1,n.preprocess&&(o=n.preprocess(o)),b$1(o)>n.width&&(o=T$1(o,n.width,{hard:!0}));let B=o.split(` -+`);if(n.postprocess){let{postprocess:s}=n;B=B.map((l,h)=>s.call(n,l,h));}return n.paddingTop&&B.unshift(...X(n.paddingTop)),n.paddingBottom&&B.push(...X(n.paddingBottom)),B.length>E&&(E=B.length),c(p({},n),{lines:B})}),r=[];for(let n=0;n{var h;let a=(h=B.lines[n])!=null?h:"",s=Number.isFinite(B.width)?" ".repeat(B.width-g(a)):"",l=B.paddingLeftString;return B.align==="right"&&(l+=s),l+=a,B.align==="left"&&(l+=s),l+B.paddingRightString}).join("");r.push(o);}u.push(r.join(` -+`));}return u.join(` -+`)}function mD(D,F){if(!D||D.length===0)return "";let u=k$1(D),C=u.length;if(C===0)return "";let{stdoutColumns:t,columns:E}=v(F);if(E.length>C)throw new Error(`${E.length} columns defined, but only ${C} columns found`);let e=Z$1(t,E,u);return D.map(r=>P(e,r)).join(` -+`)}i();var bD=["<",">","=",">=","<="];function xD(D){if(!bD.includes(D))throw new TypeError(`Invalid breakpoint operator: ${D}`)}function wD(D){let F=Object.keys(D).map(u=>{let[C,t]=u.split(" ");xD(C);let E=Number.parseInt(t,10);if(Number.isNaN(E))throw new TypeError(`Invalid breakpoint value: ${t}`);let e=D[u];return {operator:C,breakpoint:E,value:e}}).sort((u,C)=>C.breakpoint-u.breakpoint);return u=>{var C;return (C=F.find(({operator:t,breakpoint:E})=>t==="="&&u===E||t===">"&&u>E||t==="<"&&u="&&u>=E||t==="<="&&u<=E))==null?void 0:C.value}} -+ -+const S=t=>t.replace(/[\W_]([a-z\d])?/gi,(e,r)=>r?r.toUpperCase():""),q=t=>t.replace(/\B([A-Z])/g,"-$1").toLowerCase(),I={"> 80":[{width:"content-width",paddingLeft:2,paddingRight:8},{width:"auto"}],"> 40":[{width:"auto",paddingLeft:2,paddingRight:8,preprocess:t=>t.trim()},{width:"100%",paddingLeft:2,paddingBottom:1}],"> 0":{stdoutColumns:1e3,columns:[{width:"content-width",paddingLeft:2,paddingRight:8},{width:"content-width"}]}};function D(t){let e=!1;return {type:"table",data:{tableData:Object.keys(t).sort((a,i)=>a.localeCompare(i)).map(a=>{const i=t[a],s="alias"in i;return s&&(e=!0),{name:a,flag:i,flagFormatted:`--${q(a)}`,aliasesEnabled:e,aliasFormatted:s?`-${i.alias}`:void 0}}).map(a=>(a.aliasesEnabled=e,[{type:"flagName",data:a},{type:"flagDescription",data:a}])),tableBreakpoints:I}}}const A=t=>!t||(t.version??(t.help?t.help.version:void 0)),C=t=>{const e="parent"in t&&t.parent?.name;return (e?`${e} `:"")+t.name};function R(t){const e=[];t.name&&e.push(C(t));const r=A(t)??("parent"in t&&A(t.parent));if(r&&e.push(`v${r}`),e.length!==0)return {id:"name",type:"text",data:`${e.join(" ")} -+`}}function L(t){const{help:e}=t;if(!(!e||!e.description))return {id:"description",type:"text",data:`${e.description} -+`}}function T(t){const e=t.help||{};if("usage"in e)return e.usage?{id:"usage",type:"section",data:{title:"Usage:",body:Array.isArray(e.usage)?e.usage.join(` -+`):e.usage}}:void 0;if(t.name){const r=[],n=[C(t)];if(t.flags&&Object.keys(t.flags).length>0&&n.push("[flags...]"),t.parameters&&t.parameters.length>0){const{parameters:a}=t,i=a.indexOf("--"),s=i>-1&&a.slice(i+1).some(o=>o.startsWith("<"));n.push(a.map(o=>o!=="--"?o:s?"--":"[--]").join(" "));}if(n.length>1&&r.push(n.join(" ")),"commands"in t&&t.commands?.length&&r.push(`${t.name} `),r.length>0)return {id:"usage",type:"section",data:{title:"Usage:",body:r.join(` -+`)}}}}function _(t){return !("commands"in t)||!t.commands?.length?void 0:{id:"commands",type:"section",data:{title:"Commands:",body:{type:"table",data:{tableData:t.commands.map(n=>[n.options.name,n.options.help?n.options.help.description:""]),tableOptions:[{width:"content-width",paddingLeft:2,paddingRight:8}]}},indentBody:0}}}function k(t){if(!(!t.flags||Object.keys(t.flags).length===0))return {id:"flags",type:"section",data:{title:"Flags:",body:D(t.flags),indentBody:0}}}function F(t){const{help:e}=t;if(!e||!e.examples||e.examples.length===0)return;let{examples:r}=e;if(Array.isArray(r)&&(r=r.join(` -+`)),r)return {id:"examples",type:"section",data:{title:"Examples:",body:r}}}function H(t){if(!("alias"in t)||!t.alias)return;const{alias:e}=t;return {id:"aliases",type:"section",data:{title:"Aliases:",body:Array.isArray(e)?e.join(", "):e}}}const U=t=>[R,L,T,_,k,F,H].map(e=>e(t)).filter(Boolean),J=N$1.WriteStream.prototype.hasColors();class M{text(e){return e}bold(e){return J?`\x1B[1m${e}\x1B[22m`:e.toLocaleUpperCase()}indentText({text:e,spaces:r}){return e.replace(/^/gm," ".repeat(r))}heading(e){return this.bold(e)}section({title:e,body:r,indentBody:n=2}){return `${(e?`${this.heading(e)} -+`:"")+(r?this.indentText({text:this.render(r),spaces:n}):"")} -+`}table({tableData:e,tableOptions:r,tableBreakpoints:n}){return mD(e.map(a=>a.map(i=>this.render(i))),n?wD(n):r)}flagParameter(e){return e===Boolean?"":e===String?"":e===Number?"":Array.isArray(e)?this.flagParameter(e[0]):""}flagOperator(e){return " "}flagName(e){const{flag:r,flagFormatted:n,aliasesEnabled:a,aliasFormatted:i}=e;let s="";if(i?s+=`${i}, `:a&&(s+=" "),s+=n,"placeholder"in r&&typeof r.placeholder=="string")s+=`${this.flagOperator(e)}${r.placeholder}`;else {const o=this.flagParameter("type"in r?r.type:r);o&&(s+=`${this.flagOperator(e)}${o}`);}return s}flagDefault(e){return JSON.stringify(e)}flagDescription({flag:e}){let r="description"in e?e.description??"":"";if("default"in e){let{default:n}=e;typeof n=="function"&&(n=n()),n&&(r+=` (default: ${this.flagDefault(n)})`);}return r}render(e){if(typeof e=="string")return e;if(Array.isArray(e))return e.map(r=>this.render(r)).join(` -+`);if("type"in e&&this[e.type]){const r=this[e.type];if(typeof r=="function")return r.call(this,e.data)}throw new Error(`Invalid node type: ${JSON.stringify(e)}`)}}const y=/^[\w.-]+$/,{stringify:d}=JSON,V=/[|\\{}()[\]^$+*?.]/;function w(t){const e=[];let r,n;for(const a of t){if(n)throw new Error(`Invalid parameter: Spread parameter ${d(n)} must be last`);const i=a[0],s=a[a.length-1];let o;if(i==="<"&&s===">"&&(o=!0,r))throw new Error(`Invalid parameter: Required parameter ${d(a)} cannot come after optional parameter ${d(r)}`);if(i==="["&&s==="]"&&(o=!1,r=a),o===void 0)throw new Error(`Invalid parameter: ${d(a)}. Must be wrapped in <> (required parameter) or [] (optional parameter)`);let l=a.slice(1,-1);const f=l.slice(-3)==="...";f&&(n=a,l=l.slice(0,-3));const p=l.match(V);if(p)throw new Error(`Invalid parameter: ${d(a)}. Invalid character found ${d(p[0])}`);e.push({name:l,required:o,spread:f});}return e}function b(t,e,r,n){for(let a=0;a{console.log(e.version);};if(i&&l.flags.version===!0)return f(),process.exit(0);const p=new M,O=o&&s?.render?s.render:c=>p.render(c),u=c=>{const m=U({...e,...c?{help:c}:{},flags:a});console.log(O(m,p));};if(o&&l.flags.help===!0)return u(),process.exit(0);if(e.parameters){let{parameters:c}=e,m=l._;const g=c.indexOf("--"),v=c.slice(g+1),h=Object.create(null);if(g>-1&&v.length>0){c=c.slice(0,g);const E=l._["--"];m=m.slice(0,-E.length||void 0),b(h,w(c),m,u),b(h,w(v),E,u);}else b(h,w(c),m,u);Object.assign(l._,h);}const $={...l,showVersion:f,showHelp:u};return typeof r=="function"&&r($),{command:t,...$}}function z(t,e){const r=new Map;for(const n of e){const a=[n.options.name],{alias:i}=n.options;i&&(Array.isArray(i)?a.push(...i):a.push(i));for(const s of a){if(r.has(s))throw new Error(`Duplicate command name found: ${d(s)}`);r.set(s,n);}}return r.get(t)}function Z(t,e,r=process.argv.slice(2)){if(!t)throw new Error("Options is required");if("name"in t&&(!t.name||!y.test(t.name)))throw new Error(`Invalid script name: ${d(t.name)}`);const n=r[0];if(t.commands&&y.test(n)){const a=z(n,t.commands);if(a)return x(a.options.name,{...a.options,parent:t},a.callback,r.slice(1))}return x(void 0,t,e,r)}function G(t,e){if(!t)throw new Error("Command options are required");const{name:r}=t;if(t.name===void 0)throw new Error("Command name is required");if(!y.test(r))throw new Error(`Invalid command name ${JSON.stringify(r)}. Command names must be one word.`);return {options:t,callback:e}} -+ -+// In the absence of a WeakSet or WeakMap implementation, don't break, but don't cache either. -+function noop() { -+} -+function createWeakMap() { -+ if (typeof WeakMap !== "undefined") { -+ return new WeakMap(); -+ } -+ else { -+ return fakeSetOrMap(); -+ } -+} -+/** -+ * Creates and returns a no-op implementation of a WeakMap / WeakSet that never stores anything. -+ */ -+function fakeSetOrMap() { -+ return { -+ add: noop, -+ delete: noop, -+ get: noop, -+ set: noop, -+ has: function (k) { -+ return false; -+ }, -+ }; -+} -+// Safe hasOwnProperty -+var hop = Object.prototype.hasOwnProperty; -+var has = function (obj, prop) { -+ return hop.call(obj, prop); -+}; -+// Copy all own enumerable properties from source to target -+function extend(target, source) { -+ for (var prop in source) { -+ if (has(source, prop)) { -+ target[prop] = source[prop]; -+ } -+ } -+ return target; -+} -+var reLeadingNewline = /^[ \t]*(?:\r\n|\r|\n)/; -+var reTrailingNewline = /(?:\r\n|\r|\n)[ \t]*$/; -+var reStartsWithNewlineOrIsEmpty = /^(?:[\r\n]|$)/; -+var reDetectIndentation = /(?:\r\n|\r|\n)([ \t]*)(?:[^ \t\r\n]|$)/; -+var reOnlyWhitespaceWithAtLeastOneNewline = /^[ \t]*[\r\n][ \t\r\n]*$/; -+function _outdentArray(strings, firstInterpolatedValueSetsIndentationLevel, options) { -+ // If first interpolated value is a reference to outdent, -+ // determine indentation level from the indentation of the interpolated value. -+ var indentationLevel = 0; -+ var match = strings[0].match(reDetectIndentation); -+ if (match) { -+ indentationLevel = match[1].length; -+ } -+ var reSource = "(\\r\\n|\\r|\\n).{0," + indentationLevel + "}"; -+ var reMatchIndent = new RegExp(reSource, "g"); -+ if (firstInterpolatedValueSetsIndentationLevel) { -+ strings = strings.slice(1); -+ } -+ var newline = options.newline, trimLeadingNewline = options.trimLeadingNewline, trimTrailingNewline = options.trimTrailingNewline; -+ var normalizeNewlines = typeof newline === "string"; -+ var l = strings.length; -+ var outdentedStrings = strings.map(function (v, i) { -+ // Remove leading indentation from all lines -+ v = v.replace(reMatchIndent, "$1"); -+ // Trim a leading newline from the first string -+ if (i === 0 && trimLeadingNewline) { -+ v = v.replace(reLeadingNewline, ""); -+ } -+ // Trim a trailing newline from the last string -+ if (i === l - 1 && trimTrailingNewline) { -+ v = v.replace(reTrailingNewline, ""); -+ } -+ // Normalize newlines -+ if (normalizeNewlines) { -+ v = v.replace(/\r\n|\n|\r/g, function (_) { return newline; }); -+ } -+ return v; -+ }); -+ return outdentedStrings; -+} -+function concatStringsAndValues(strings, values) { -+ var ret = ""; -+ for (var i = 0, l = strings.length; i < l; i++) { -+ ret += strings[i]; -+ if (i < l - 1) { -+ ret += values[i]; -+ } -+ } -+ return ret; -+} -+function isTemplateStringsArray(v) { -+ return has(v, "raw") && has(v, "length"); -+} -+/** -+ * It is assumed that opts will not change. If this is a problem, clone your options object and pass the clone to -+ * makeInstance -+ * @param options -+ * @return {outdent} -+ */ -+function createInstance(options) { -+ /** Cache of pre-processed template literal arrays */ -+ var arrayAutoIndentCache = createWeakMap(); -+ /** -+ * Cache of pre-processed template literal arrays, where first interpolated value is a reference to outdent, -+ * before interpolated values are injected. -+ */ -+ var arrayFirstInterpSetsIndentCache = createWeakMap(); -+ function outdent(stringsOrOptions) { -+ var values = []; -+ for (var _i = 1; _i < arguments.length; _i++) { -+ values[_i - 1] = arguments[_i]; -+ } -+ /* tslint:enable:no-shadowed-variable */ -+ if (isTemplateStringsArray(stringsOrOptions)) { -+ var strings = stringsOrOptions; -+ // Is first interpolated value a reference to outdent, alone on its own line, without any preceding non-whitespace? -+ var firstInterpolatedValueSetsIndentationLevel = (values[0] === outdent || values[0] === defaultOutdent) && -+ reOnlyWhitespaceWithAtLeastOneNewline.test(strings[0]) && -+ reStartsWithNewlineOrIsEmpty.test(strings[1]); -+ // Perform outdentation -+ var cache = firstInterpolatedValueSetsIndentationLevel -+ ? arrayFirstInterpSetsIndentCache -+ : arrayAutoIndentCache; -+ var renderedArray = cache.get(strings); -+ if (!renderedArray) { -+ renderedArray = _outdentArray(strings, firstInterpolatedValueSetsIndentationLevel, options); -+ cache.set(strings, renderedArray); -+ } -+ /** If no interpolated values, skip concatenation step */ -+ if (values.length === 0) { -+ return renderedArray[0]; -+ } -+ /** Concatenate string literals with interpolated values */ -+ var rendered = concatStringsAndValues(renderedArray, firstInterpolatedValueSetsIndentationLevel ? values.slice(1) : values); -+ return rendered; -+ } -+ else { -+ // Create and return a new instance of outdent with the given options -+ return createInstance(extend(extend({}, options), stringsOrOptions || {})); -+ } -+ } -+ var fullOutdent = extend(outdent, { -+ string: function (str) { -+ return _outdentArray([str], false, options)[0]; -+ }, -+ }); -+ return fullOutdent; -+} -+var defaultOutdent = createInstance({ -+ trimLeadingNewline: true, -+ trimTrailingNewline: true, -+}); -+if (typeof module !== "undefined") { -+ // In webpack harmony-modules environments, module.exports is read-only, -+ // so we fail gracefully. -+ try { -+ module.exports = defaultOutdent; -+ Object.defineProperty(defaultOutdent, "__esModule", { value: true }); -+ defaultOutdent.default = defaultOutdent; -+ defaultOutdent.outdent = defaultOutdent; -+ } -+ catch (e) { } -+} -+ -+let enabled = true; -+// Support both browser and node environments -+const globalVar = typeof self !== 'undefined' -+ ? self -+ : typeof window !== 'undefined' -+ ? window -+ : typeof global !== 'undefined' -+ ? global -+ : {}; -+/** -+ * Detect how much colors the current terminal supports -+ */ -+let supportLevel = 0 /* none */; -+if (globalVar.process && globalVar.process.env && globalVar.process.stdout) { -+ const { FORCE_COLOR, NODE_DISABLE_COLORS, NO_COLOR, TERM, COLORTERM } = globalVar.process.env; -+ if (NODE_DISABLE_COLORS || NO_COLOR || FORCE_COLOR === '0') { -+ enabled = false; -+ } -+ else if (FORCE_COLOR === '1' || -+ FORCE_COLOR === '2' || -+ FORCE_COLOR === '3') { -+ enabled = true; -+ } -+ else if (TERM === 'dumb') { -+ enabled = false; -+ } -+ else if ('CI' in globalVar.process.env && -+ [ -+ 'TRAVIS', -+ 'CIRCLECI', -+ 'APPVEYOR', -+ 'GITLAB_CI', -+ 'GITHUB_ACTIONS', -+ 'BUILDKITE', -+ 'DRONE', -+ ].some(vendor => vendor in globalVar.process.env)) { -+ enabled = true; -+ } -+ else { -+ enabled = process.stdout.isTTY; -+ } -+ if (enabled) { -+ // Windows supports 24bit True Colors since Windows 10 revision #14931, -+ // see https://devblogs.microsoft.com/commandline/24-bit-color-in-the-windows-console/ -+ if (process.platform === 'win32') { -+ supportLevel = 3 /* trueColor */; -+ } -+ else { -+ if (COLORTERM && (COLORTERM === 'truecolor' || COLORTERM === '24bit')) { -+ supportLevel = 3 /* trueColor */; -+ } -+ else if (TERM && (TERM.endsWith('-256color') || TERM.endsWith('256'))) { -+ supportLevel = 2 /* ansi256 */; -+ } -+ else { -+ supportLevel = 1 /* ansi */; -+ } -+ } -+ } -+} -+let options = { -+ enabled, -+ supportLevel, -+}; -+function kolorist(start, end, level = 1 /* ansi */) { -+ const open = `\x1b[${start}m`; -+ const close = `\x1b[${end}m`; -+ const regex = new RegExp(`\\x1b\\[${end}m`, 'g'); -+ return (str) => { -+ return options.enabled && options.supportLevel >= level -+ ? open + ('' + str).replace(regex, open) + close -+ : '' + str; -+ }; -+} -+const bold = kolorist(1, 22); -+const dim = kolorist(2, 22); -+const red = kolorist(31, 39); -+const green = kolorist(32, 39); -+const yellow = kolorist(33, 39); -+const magenta = kolorist(35, 39); -+const cyan = kolorist(36, 39); -+ -+const fsExists = (path) => fs$1.access(path).then( -+ () => true, -+ () => false -+); -+ -+var require$1 = ( -+ false -+ ? /* @__PURE__ */ module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.src || new URL('cli.js', document.baseURI).href))) -+ : require -+ ); -+ -+const readJsonFile = async (filePath) => { -+ const jsonString = await fs$1.readFile(filePath, "utf8"); -+ return JSON.parse(jsonString); -+}; -+ -+const configJsonFile = "link.config.json"; -+const configJsFile = "link.config.js"; -+const loadConfig = async (packageDirectory) => { -+ const configJsonPath = path$1.join(packageDirectory, configJsonFile); -+ if (await fsExists(configJsonPath)) { -+ try { -+ return readJsonFile(configJsonPath); -+ } catch (error) { -+ throw new Error(`Failed to parse config JSON ${configJsonPath}: ${error.message}`); -+ } -+ } -+ const configJsPath = path$1.join(packageDirectory, configJsFile); -+ if (await fsExists(configJsPath)) { -+ try { -+ return require$1(configJsPath); -+ } catch (error) { -+ throw new Error(`Failed to load config file ${configJsFile}: ${error.message}`); -+ } -+ } -+}; -+ -+var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; -+ -+function getDefaultExportFromCjs (x) { -+ return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x; -+} -+ -+var toBatchSyntax$1 = {}; -+ -+toBatchSyntax$1.replaceDollarWithPercentPair = replaceDollarWithPercentPair; -+toBatchSyntax$1.convertToSetCommand = convertToSetCommand; -+toBatchSyntax$1.convertToSetCommands = convertToSetCommands; -+ -+function convertToSetCommand (key, value) { -+ var line = ''; -+ key = key || ''; -+ key = key.trim(); -+ value = value || ''; -+ value = value.trim(); -+ if (key && value && value.length > 0) { -+ line = '@SET ' + key + '=' + replaceDollarWithPercentPair(value) + '\r\n'; -+ } -+ return line -+} -+ -+function extractVariableValuePairs (declarations) { -+ var pairs = {}; -+ declarations.map(function (declaration) { -+ var split = declaration.split('='); -+ pairs[split[0]] = split[1]; -+ }); -+ return pairs -+} -+ -+function convertToSetCommands (variableString) { -+ var variableValuePairs = extractVariableValuePairs(variableString.split(' ')); -+ var variableDeclarationsAsBatch = ''; -+ Object.keys(variableValuePairs).forEach(function (key) { -+ variableDeclarationsAsBatch += convertToSetCommand(key, variableValuePairs[key]); -+ }); -+ return variableDeclarationsAsBatch -+} -+ -+function replaceDollarWithPercentPair (value) { -+ var dollarExpressions = /\$\{?([^$@#?\- \t{}:]+)\}?/g; -+ var result = ''; -+ var startIndex = 0; -+ do { -+ var match = dollarExpressions.exec(value); -+ if (match) { -+ var betweenMatches = value.substring(startIndex, match.index) || ''; -+ result += betweenMatches + '%' + match[1] + '%'; -+ startIndex = dollarExpressions.lastIndex; -+ } -+ } while (dollarExpressions.lastIndex > 0) -+ result += value.slice(startIndex); -+ return result -+} -+ -+// On windows, create a .cmd file. -+// Read the #! in the file to see what it uses. The vast majority -+// of the time, this will be either: -+// "#!/usr/bin/env " -+// or: -+// "#! " -+// -+// Write a binroot/pkg.bin + ".cmd" file that has this line in it: -+// @ %dp0% %* -+ -+const { -+ chmod, -+ mkdir, -+ readFile: readFile$1, -+ stat, -+ unlink, -+ writeFile, -+} = fs$1; -+ -+const { dirname: dirname$1, relative: relative$1 } = path$1; -+const toBatchSyntax = toBatchSyntax$1; -+// linting disabled because this regex is really long -+// eslint-disable-next-line max-len -+const shebangExpr = /^#!\s*(?:\/usr\/bin\/env\s+(?:-S\s+)?((?:[^ \t=]+=[^ \t=]+\s+)*))?([^ \t]+)(.*)$/; -+ -+const cmdShimIfExists = (from, to) => -+ stat(from).then(() => cmdShim(from, to), () => {}); -+ -+// Try to unlink, but ignore errors. -+// Any problems will surface later. -+const rm = path => unlink(path).catch(() => {}); -+ -+const cmdShim = (from, to) => -+ stat(from).then(() => cmdShim_(from, to)); -+ -+const cmdShim_ = (from, to) => Promise.all([ -+ rm(to), -+ rm(to + '.cmd'), -+ rm(to + '.ps1'), -+]).then(() => writeShim(from, to)); -+ -+const writeShim = (from, to) => -+ // make a cmd file and a sh script -+ // First, check if the bin is a #! of some sort. -+ // If not, then assume it's something that'll be compiled, or some other -+ // sort of script, and just call it directly. -+ mkdir(dirname$1(to), { recursive: true }) -+ .then(() => readFile$1(from, 'utf8')) -+ .then(data => { -+ const firstLine = data.trim().split(/\r*\n/)[0]; -+ const shebang = firstLine.match(shebangExpr); -+ if (!shebang) { -+ return writeShim_(from, to) -+ } -+ const vars = shebang[1] || ''; -+ const prog = shebang[2]; -+ const args = shebang[3] || ''; -+ return writeShim_(from, to, prog, args, vars) -+ }, () => writeShim_(from, to)); -+ -+const writeShim_ = (from, to, prog, args, variables) => { -+ let shTarget = relative$1(dirname$1(to), from); -+ let target = shTarget.split('/').join('\\'); -+ let longProg; -+ let shProg = prog && prog.split('\\').join('/'); -+ let shLongProg; -+ let pwshProg = shProg && `"${shProg}$exe"`; -+ let pwshLongProg; -+ shTarget = shTarget.split('\\').join('/'); -+ args = args || ''; -+ variables = variables || ''; -+ if (!prog) { -+ prog = `"%dp0%\\${target}"`; -+ shProg = `"$basedir/${shTarget}"`; -+ pwshProg = shProg; -+ args = ''; -+ target = ''; -+ shTarget = ''; - } else { -- & ${g} ${D} ${i} $args -+ longProg = `"%dp0%\\${prog}.exe"`; -+ shLongProg = `"$basedir/${prog}"`; -+ pwshLongProg = `"$basedir/${prog}$exe"`; -+ target = `"%dp0%\\${target}"`; -+ shTarget = `"$basedir/${shTarget}"`; -+ } -+ -+ // Subroutine trick to fix https://github.com/npm/cmd-shim/issues/10 -+ // and https://github.com/npm/cli/issues/969 -+ const head = '@ECHO off\r\n' + -+ 'GOTO start\r\n' + -+ ':find_dp0\r\n' + -+ 'SET dp0=%~dp0\r\n' + -+ 'EXIT /b\r\n' + -+ ':start\r\n' + -+ 'SETLOCAL\r\n' + -+ 'CALL :find_dp0\r\n'; -+ -+ let cmd; -+ if (longProg) { -+ shLongProg = shLongProg.trim(); -+ args = args.trim(); -+ const variablesBatch = toBatchSyntax.convertToSetCommands(variables); -+ cmd = head -+ + variablesBatch -+ + '\r\n' -+ + `IF EXIST ${longProg} (\r\n` -+ + ` SET "_prog=${longProg.replace(/(^")|("$)/g, '')}"\r\n` -+ + ') ELSE (\r\n' -+ + ` SET "_prog=${prog.replace(/(^")|("$)/g, '')}"\r\n` -+ + ' SET PATHEXT=%PATHEXT:;.JS;=;%\r\n' -+ + ')\r\n' -+ + '\r\n' -+ // prevent "Terminate Batch Job? (Y/n)" message -+ // https://github.com/npm/cli/issues/969#issuecomment-737496588 -+ + 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & ' -+ + `"%_prog%" ${args} ${target} %*\r\n`; -+ } else { -+ cmd = `${head}${prog} ${args} ${target} %*\r\n`; -+ } -+ -+ // #!/bin/sh -+ // basedir=`dirname "$0"` -+ // -+ // case `uname` in -+ // *CYGWIN*|*MINGW*|*MSYS*) -+ // if command -v cygpath > /dev/null 2>&1; then -+ // basedir=`cygpath -w "$basedir"` -+ // fi -+ // ;; -+ // esac -+ // -+ // if [ -x "$basedir/node.exe" ]; then -+ // exec "$basedir/node.exe" "$basedir/node_modules/npm/bin/npm-cli.js" "$@" -+ // else -+ // exec node "$basedir/node_modules/npm/bin/npm-cli.js" "$@" -+ // fi -+ -+ let sh = '#!/bin/sh\n'; -+ -+ sh = sh -+ + `basedir=$(dirname "$(echo "$0" | sed -e 's,\\\\,/,g')")\n` -+ + '\n' -+ + 'case `uname` in\n' -+ + ' *CYGWIN*|*MINGW*|*MSYS*)\n' -+ + ' if command -v cygpath > /dev/null 2>&1; then\n' -+ + ' basedir=`cygpath -w "$basedir"`\n' -+ + ' fi\n' -+ + ' ;;\n' -+ + 'esac\n' -+ + '\n'; -+ -+ if (shLongProg) { -+ sh = sh -+ + `if [ -x ${shLongProg} ]; then\n` -+ + ` exec ${variables}${shLongProg} ${args} ${shTarget} "$@"\n` -+ + 'else \n' -+ + ` exec ${variables}${shProg} ${args} ${shTarget} "$@"\n` -+ + 'fi\n'; -+ } else { -+ sh = sh -+ + `exec ${shProg} ${args} ${shTarget} "$@"\n`; -+ } -+ -+ // #!/usr/bin/env pwsh -+ // $basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent -+ // -+ // $ret=0 -+ // $exe = "" -+ // if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { -+ // # Fix case when both the Windows and Linux builds of Node -+ // # are installed in the same directory -+ // $exe = ".exe" -+ // } -+ // if (Test-Path "$basedir/node") { -+ // # Suport pipeline input -+ // if ($MyInvocation.ExpectingInput) { -+ // input | & "$basedir/node$exe" "$basedir/node_modules/npm/bin/npm-cli.js" $args -+ // } else { -+ // & "$basedir/node$exe" "$basedir/node_modules/npm/bin/npm-cli.js" $args -+ // } -+ // $ret=$LASTEXITCODE -+ // } else { -+ // # Support pipeline input -+ // if ($MyInvocation.ExpectingInput) { -+ // $input | & "node$exe" "$basedir/node_modules/npm/bin/npm-cli.js" $args -+ // } else { -+ // & "node$exe" "$basedir/node_modules/npm/bin/npm-cli.js" $args -+ // } -+ // $ret=$LASTEXITCODE -+ // } -+ // exit $ret -+ let pwsh = '#!/usr/bin/env pwsh\n' -+ + '$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent\n' -+ + '\n' -+ + '$exe=""\n' -+ + 'if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {\n' -+ + ' # Fix case when both the Windows and Linux builds of Node\n' -+ + ' # are installed in the same directory\n' -+ + ' $exe=".exe"\n' -+ + '}\n'; -+ if (shLongProg) { -+ pwsh = pwsh -+ + '$ret=0\n' -+ + `if (Test-Path ${pwshLongProg}) {\n` -+ + ' # Support pipeline input\n' -+ + ' if ($MyInvocation.ExpectingInput) {\n' -+ + ` $input | & ${pwshLongProg} ${args} ${shTarget} $args\n` -+ + ' } else {\n' -+ + ` & ${pwshLongProg} ${args} ${shTarget} $args\n` -+ + ' }\n' -+ + ' $ret=$LASTEXITCODE\n' -+ + '} else {\n' -+ + ' # Support pipeline input\n' -+ + ' if ($MyInvocation.ExpectingInput) {\n' -+ + ` $input | & ${pwshProg} ${args} ${shTarget} $args\n` -+ + ' } else {\n' -+ + ` & ${pwshProg} ${args} ${shTarget} $args\n` -+ + ' }\n' -+ + ' $ret=$LASTEXITCODE\n' -+ + '}\n' -+ + 'exit $ret\n'; -+ } else { -+ pwsh = pwsh -+ + '# Support pipeline input\n' -+ + 'if ($MyInvocation.ExpectingInput) {\n' -+ + ` $input | & ${pwshProg} ${args} ${shTarget} $args\n` -+ + '} else {\n' -+ + ` & ${pwshProg} ${args} ${shTarget} $args\n` -+ + '}\n' -+ + 'exit $LASTEXITCODE\n'; -+ } -+ -+ return Promise.all([ -+ writeFile(to + '.ps1', pwsh, 'utf8'), -+ writeFile(to + '.cmd', cmd, 'utf8'), -+ writeFile(to, sh, 'utf8'), -+ ]).then(() => chmodShim(to)) -+}; -+ -+const chmodShim = to => Promise.all([ -+ chmod(to, 0o755), -+ chmod(to + '.cmd', 0o755), -+ chmod(to + '.ps1', 0o755), -+]); -+ -+var lib$2 = cmdShim; -+cmdShim.ifExists = cmdShimIfExists; -+ -+var cmdShim$1 = /*@__PURE__*/getDefaultExportFromCjs(lib$2); -+ -+const readPackageJson = async (packagePath) => { -+ const packageJsonPath = path$1.join(packagePath, "package.json"); -+ const packageJsonExists = await fsExists(packageJsonPath); -+ if (!packageJsonExists) { -+ throw new Error(`package.json not found in ${packagePath}`); -+ } -+ const packageJson = await readJsonFile(packageJsonPath); -+ if (!packageJson.name) { -+ throw new Error(`package.json must contain a name: ${packageJsonPath}`); -+ } -+ return packageJson; -+}; -+ -+const symlink = async (targetPath, symlinkPath, type) => { -+ const stats = await fs$1.lstat(symlinkPath).catch(() => null); -+ if (stats) { -+ if (stats.isSymbolicLink()) { -+ const symlinkRealpath = await fs$1.realpath(symlinkPath).catch(() => null); -+ if (targetPath === symlinkRealpath) { -+ return; -+ } -+ } -+ await fs$1.rm(symlinkPath, { -+ recursive: true -+ }); -+ } -+ await fs$1.symlink( -+ targetPath, -+ symlinkPath, -+ type -+ ); -+}; -+const symlinkBinary = async (binaryPath, linkPath) => { -+ await symlink(binaryPath, linkPath); -+ await fs$1.chmod(linkPath, 493); -+}; -+const hardlink = async (sourcePath, hardlinkPath) => { -+ if (await fsExists(hardlinkPath)) { -+ const [ -+ existingStat, -+ sourceStat -+ ] = await Promise.all([ -+ fs$1.stat(hardlinkPath), -+ fs$1.stat(sourcePath) -+ ]); -+ if (existingStat.ino === sourceStat.ino) { -+ return; -+ } -+ await fs$1.rm(hardlinkPath, { -+ recursive: true -+ }); -+ } -+ await fs$1.link(sourcePath, hardlinkPath); -+}; -+ -+const linkBinaries = async (linkPackagePath, nodeModulesPath, { -+ name, -+ bin -+}, linkFunction) => { -+ if (!bin) { -+ return []; -+ } -+ if (name?.startsWith("@")) { -+ [, name] = name.split("/"); -+ } -+ const binDirectoryPath = path$1.join(nodeModulesPath, ".bin"); -+ await fs$1.mkdir(binDirectoryPath, { -+ recursive: true -+ }); -+ if (typeof bin === "string") { -+ await linkFunction( -+ path$1.resolve(linkPackagePath, bin), -+ path$1.join(binDirectoryPath, name) -+ ); -+ return; -+ } -+ await Promise.all( -+ Object.entries(bin).map( -+ async ([binaryName, binaryPath]) => await linkFunction( -+ path$1.resolve(linkPackagePath, binaryPath), -+ path$1.join(binDirectoryPath, binaryName) -+ ) -+ ) -+ ); -+}; -+ -+const nodeModulesDirectory = "node_modules"; -+const symlinkPackage = async (basePackagePath, linkPackagePath) => { -+ const absoluteLinkPackagePath = path$1.resolve(basePackagePath, linkPackagePath); -+ const packageJson = await readPackageJson(absoluteLinkPackagePath); -+ const nodeModulesPath = path$1.join(basePackagePath, nodeModulesDirectory); -+ const symlinkPath = path$1.join(nodeModulesPath, packageJson.name); -+ const symlinkDirectory = path$1.dirname(symlinkPath); -+ await fs$1.mkdir(symlinkDirectory, { -+ recursive: true -+ }); -+ const targetPath = path$1.relative(symlinkDirectory, absoluteLinkPackagePath); -+ await symlink( -+ targetPath, -+ symlinkPath, -+ /** -+ * On Windows, 'dir' requires admin privileges so use 'junction' instead -+ * -+ * npm also uses junction: -+ * https://github.com/npm/cli/blob/v9.9.3/workspaces/arborist/lib/arborist/reify.js#L738 -+ */ -+ "junction" -+ ); -+ await linkBinaries( -+ absoluteLinkPackagePath, -+ nodeModulesPath, -+ packageJson, -+ process.platform === "win32" ? cmdShim$1 : symlinkBinary -+ ); -+ return { -+ name: packageJson.name, -+ path: symlinkPath, -+ target: targetPath -+ }; -+}; -+ -+const linkPackage = async (basePackagePath, linkPackagePath, options) => { -+ const absoluteLinkPackagePath = path$1.resolve(basePackagePath, linkPackagePath); -+ const pathExists = await fsExists(absoluteLinkPackagePath); -+ if (!pathExists) { -+ console.warn(red("\u2716"), `Package path does not exist: ${linkPackagePath}`); -+ process.exitCode = 1; -+ return; -+ } -+ try { -+ const link = await symlinkPackage( -+ basePackagePath, -+ linkPackagePath -+ ); -+ console.log(green("\u2714"), `Symlinked ${magenta(link.name)}:`, cyan(link.path), "\u2192", cyan(link.target)); -+ } catch (error) { -+ console.warn(red("\u2716"), "Failed to symlink", cyan(linkPackagePath), "with error:", error.message); -+ process.exitCode = 1; -+ return; -+ } -+ if (options.deep) { -+ const config = await loadConfig(absoluteLinkPackagePath); -+ if (config) { -+ await linkFromConfig( -+ absoluteLinkPackagePath, -+ config, -+ options -+ ); -+ } -+ } -+}; -+const linkFromConfig = async (basePackagePath, config, options) => { -+ if (!config.packages) { -+ return; -+ } -+ const newOptions = { -+ deep: options.deep ?? config.deepLink ?? false -+ }; -+ await Promise.all( -+ config.packages.map( -+ async (linkPackagePath) => await linkPackage( -+ basePackagePath, -+ linkPackagePath, -+ newOptions -+ ) -+ ) -+ ); -+}; -+ -+var debounce$2 = {exports: {}}; -+ -+function debounce(function_, wait = 100, options = {}) { -+ if (typeof function_ !== 'function') { -+ throw new TypeError(`Expected the first parameter to be a function, got \`${typeof function_}\`.`); -+ } -+ -+ if (wait < 0) { -+ throw new RangeError('`wait` must not be negative.'); -+ } -+ -+ // TODO: Deprecate the boolean parameter at some point. -+ const {immediate} = typeof options === 'boolean' ? {immediate: options} : options; -+ -+ let storedContext; -+ let storedArguments; -+ let timeoutId; -+ let timestamp; -+ let result; -+ -+ function run() { -+ const callContext = storedContext; -+ const callArguments = storedArguments; -+ storedContext = undefined; -+ storedArguments = undefined; -+ result = function_.apply(callContext, callArguments); -+ return result; -+ } -+ -+ function later() { -+ const last = Date.now() - timestamp; -+ -+ if (last < wait && last >= 0) { -+ timeoutId = setTimeout(later, wait - last); -+ } else { -+ timeoutId = undefined; -+ -+ if (!immediate) { -+ result = run(); -+ } -+ } -+ } -+ -+ const debounced = function (...arguments_) { -+ if (storedContext && this !== storedContext) { -+ throw new Error('Debounced method called with different contexts.'); -+ } -+ -+ storedContext = this; // eslint-disable-line unicorn/no-this-assignment -+ storedArguments = arguments_; -+ timestamp = Date.now(); -+ -+ const callNow = immediate && !timeoutId; -+ -+ if (!timeoutId) { -+ timeoutId = setTimeout(later, wait); -+ } -+ -+ if (callNow) { -+ result = run(); -+ } -+ -+ return result; -+ }; -+ -+ debounced.clear = () => { -+ if (!timeoutId) { -+ return; -+ } -+ -+ clearTimeout(timeoutId); -+ timeoutId = undefined; -+ }; -+ -+ debounced.flush = () => { -+ if (!timeoutId) { -+ return; -+ } -+ -+ debounced.trigger(); -+ }; -+ -+ debounced.trigger = () => { -+ result = run(); -+ -+ debounced.clear(); -+ }; -+ -+ return debounced; -+} -+ -+// Adds compatibility for ES modules -+debounce$2.exports.debounce = debounce; -+ -+debounce$2.exports = debounce; -+ -+var debounceExports = debounce$2.exports; -+var debounce$1 = /*@__PURE__*/getDefaultExportFromCjs(debounceExports); -+ -+const pDebounce = (fn, wait, options = {}) => { -+ if (!Number.isFinite(wait)) { -+ throw new TypeError('Expected `wait` to be a finite number'); -+ } -+ -+ let leadingValue; -+ let timeout; -+ let resolveList = []; -+ -+ return function (...arguments_) { -+ return new Promise(resolve => { -+ const shouldCallNow = options.before && !timeout; -+ -+ clearTimeout(timeout); -+ -+ timeout = setTimeout(() => { -+ timeout = null; -+ -+ const result = options.before ? leadingValue : fn.apply(this, arguments_); -+ -+ for (resolve of resolveList) { -+ resolve(result); -+ } -+ -+ resolveList = []; -+ }, wait); -+ -+ if (shouldCallNow) { -+ leadingValue = fn.apply(this, arguments_); -+ resolve(leadingValue); -+ } else { -+ resolveList.push(resolve); -+ } -+ }); -+ }; -+}; -+ -+pDebounce.promise = function_ => { -+ let currentPromise; -+ -+ return async function (...arguments_) { -+ if (currentPromise) { -+ return currentPromise; -+ } -+ -+ try { -+ currentPromise = function_.apply(this, arguments_); -+ return await currentPromise; -+ } finally { -+ currentPromise = undefined; -+ } -+ }; -+}; -+ -+var globToRegexp = function (glob, opts) { -+ if (typeof glob !== 'string') { -+ throw new TypeError('Expected a string'); -+ } -+ -+ var str = String(glob); -+ -+ // The regexp we are building, as a string. -+ var reStr = ""; -+ -+ // Whether we are matching so called "extended" globs (like bash) and should -+ // support single character matching, matching ranges of characters, group -+ // matching, etc. -+ var extended = opts ? !!opts.extended : false; -+ -+ // When globstar is _false_ (default), '/foo/*' is translated a regexp like -+ // '^\/foo\/.*$' which will match any string beginning with '/foo/' -+ // When globstar is _true_, '/foo/*' is translated to regexp like -+ // '^\/foo\/[^/]*$' which will match any string beginning with '/foo/' BUT -+ // which does not have a '/' to the right of it. -+ // E.g. with '/foo/*' these will match: '/foo/bar', '/foo/bar.txt' but -+ // these will not '/foo/bar/baz', '/foo/bar/baz.txt' -+ // Lastely, when globstar is _true_, '/foo/**' is equivelant to '/foo/*' when -+ // globstar is _false_ -+ var globstar = opts ? !!opts.globstar : false; -+ -+ // If we are doing extended matching, this boolean is true when we are inside -+ // a group (eg {*.html,*.js}), and false otherwise. -+ var inGroup = false; -+ -+ // RegExp flags (eg "i" ) to pass in to RegExp constructor. -+ var flags = opts && typeof( opts.flags ) === "string" ? opts.flags : ""; -+ -+ var c; -+ for (var i = 0, len = str.length; i < len; i++) { -+ c = str[i]; -+ -+ switch (c) { -+ case "/": -+ case "$": -+ case "^": -+ case "+": -+ case ".": -+ case "(": -+ case ")": -+ case "=": -+ case "!": -+ case "|": -+ reStr += "\\" + c; -+ break; -+ -+ case "?": -+ if (extended) { -+ reStr += "."; -+ break; -+ } -+ -+ case "[": -+ case "]": -+ if (extended) { -+ reStr += c; -+ break; -+ } -+ -+ case "{": -+ if (extended) { -+ inGroup = true; -+ reStr += "("; -+ break; -+ } -+ -+ case "}": -+ if (extended) { -+ inGroup = false; -+ reStr += ")"; -+ break; -+ } -+ -+ case ",": -+ if (inGroup) { -+ reStr += "|"; -+ break; -+ } -+ reStr += "\\" + c; -+ break; -+ -+ case "*": -+ // Move over all consecutive "*"'s. -+ // Also store the previous and next characters -+ var prevChar = str[i - 1]; -+ var starCount = 1; -+ while(str[i + 1] === "*") { -+ starCount++; -+ i++; -+ } -+ var nextChar = str[i + 1]; -+ -+ if (!globstar) { -+ // globstar is disabled, so treat any number of "*" as one -+ reStr += ".*"; -+ } else { -+ // globstar is enabled, so determine if this is a globstar segment -+ var isGlobstar = starCount > 1 // multiple "*"'s -+ && (prevChar === "/" || prevChar === undefined) // from the start of the segment -+ && (nextChar === "/" || nextChar === undefined); // to the end of the segment -+ -+ if (isGlobstar) { -+ // it's a globstar, so match zero or more path segments -+ reStr += "((?:[^/]*(?:\/|$))*)"; -+ i++; // move over the "/" -+ } else { -+ // it's not a globstar, so only match one path segment -+ reStr += "([^/]*)"; -+ } -+ } -+ break; -+ -+ default: -+ reStr += c; -+ } - } -- $ret=$LASTEXITCODE --} else { -- # Support pipeline input -- if ($MyInvocation.ExpectingInput) { -- $input | & ${f} ${D} ${i} $args -+ -+ // When regexp 'g' flag is specified don't -+ // constrain the regular expression with ^ & $ -+ if (!flags || !~flags.indexOf('g')) { -+ reStr = "^" + reStr + "$"; -+ } -+ -+ return new RegExp(reStr, flags); -+}; -+ -+var globToRegexp$1 = /*@__PURE__*/getDefaultExportFromCjs(globToRegexp); -+ -+var commonjs = {}; -+ -+var balancedMatch = balanced$1; -+function balanced$1(a, b, str) { -+ if (a instanceof RegExp) a = maybeMatch(a, str); -+ if (b instanceof RegExp) b = maybeMatch(b, str); -+ -+ var r = range(a, b, str); -+ -+ return r && { -+ start: r[0], -+ end: r[1], -+ pre: str.slice(0, r[0]), -+ body: str.slice(r[0] + a.length, r[1]), -+ post: str.slice(r[1] + b.length) -+ }; -+} -+ -+function maybeMatch(reg, str) { -+ var m = str.match(reg); -+ return m ? m[0] : null; -+} -+ -+balanced$1.range = range; -+function range(a, b, str) { -+ var begs, beg, left, right, result; -+ var ai = str.indexOf(a); -+ var bi = str.indexOf(b, ai + 1); -+ var i = ai; -+ -+ if (ai >= 0 && bi > 0) { -+ if(a===b) { -+ return [ai, bi]; -+ } -+ begs = []; -+ left = str.length; -+ -+ while (i >= 0 && !result) { -+ if (i == ai) { -+ begs.push(i); -+ ai = str.indexOf(a, i + 1); -+ } else if (begs.length == 1) { -+ result = [ begs.pop(), bi ]; -+ } else { -+ beg = begs.pop(); -+ if (beg < left) { -+ left = beg; -+ right = bi; -+ } -+ -+ bi = str.indexOf(b, i + 1); -+ } -+ -+ i = ai < bi && ai >= 0 ? ai : bi; -+ } -+ -+ if (begs.length) { -+ result = [ left, right ]; -+ } -+ } -+ -+ return result; -+} -+ -+var balanced = balancedMatch; -+ -+var braceExpansion = expandTop; -+ -+var escSlash = '\0SLASH'+Math.random()+'\0'; -+var escOpen = '\0OPEN'+Math.random()+'\0'; -+var escClose = '\0CLOSE'+Math.random()+'\0'; -+var escComma = '\0COMMA'+Math.random()+'\0'; -+var escPeriod = '\0PERIOD'+Math.random()+'\0'; -+ -+function numeric(str) { -+ return parseInt(str, 10) == str -+ ? parseInt(str, 10) -+ : str.charCodeAt(0); -+} -+ -+function escapeBraces(str) { -+ return str.split('\\\\').join(escSlash) -+ .split('\\{').join(escOpen) -+ .split('\\}').join(escClose) -+ .split('\\,').join(escComma) -+ .split('\\.').join(escPeriod); -+} -+ -+function unescapeBraces(str) { -+ return str.split(escSlash).join('\\') -+ .split(escOpen).join('{') -+ .split(escClose).join('}') -+ .split(escComma).join(',') -+ .split(escPeriod).join('.'); -+} -+ -+ -+// Basically just str.split(","), but handling cases -+// where we have nested braced sections, which should be -+// treated as individual members, like {a,{b,c},d} -+function parseCommaParts(str) { -+ if (!str) -+ return ['']; -+ -+ var parts = []; -+ var m = balanced('{', '}', str); -+ -+ if (!m) -+ return str.split(','); -+ -+ var pre = m.pre; -+ var body = m.body; -+ var post = m.post; -+ var p = pre.split(','); -+ -+ p[p.length-1] += '{' + body + '}'; -+ var postParts = parseCommaParts(post); -+ if (post.length) { -+ p[p.length-1] += postParts.shift(); -+ p.push.apply(p, postParts); -+ } -+ -+ parts.push.apply(parts, p); -+ -+ return parts; -+} -+ -+function expandTop(str) { -+ if (!str) -+ return []; -+ -+ // I don't know why Bash 4.3 does this, but it does. -+ // Anything starting with {} will have the first two bytes preserved -+ // but *only* at the top level, so {},a}b will not expand to anything, -+ // but a{},b}c will be expanded to [a}c,abc]. -+ // One could argue that this is a bug in Bash, but since the goal of -+ // this module is to match Bash's rules, we escape a leading {} -+ if (str.substr(0, 2) === '{}') { -+ str = '\\{\\}' + str.substr(2); -+ } -+ -+ return expand(escapeBraces(str), true).map(unescapeBraces); -+} -+ -+function embrace(str) { -+ return '{' + str + '}'; -+} -+function isPadded(el) { -+ return /^-?0\d/.test(el); -+} -+ -+function lte(i, y) { -+ return i <= y; -+} -+function gte(i, y) { -+ return i >= y; -+} -+ -+function expand(str, isTop) { -+ var expansions = []; -+ -+ var m = balanced('{', '}', str); -+ if (!m) return [str]; -+ -+ // no need to expand pre, since it is guaranteed to be free of brace-sets -+ var pre = m.pre; -+ var post = m.post.length -+ ? expand(m.post, false) -+ : ['']; -+ -+ if (/\$$/.test(m.pre)) { -+ for (var k = 0; k < post.length; k++) { -+ var expansion = pre+ '{' + m.body + '}' + post[k]; -+ expansions.push(expansion); -+ } - } else { -- & ${f} ${D} ${i} $args -- } -- $ret=$LASTEXITCODE --} --exit $ret --`:w=w+`# Support pipeline input --if ($MyInvocation.ExpectingInput) { -- $input | & ${f} ${D} ${i} $args --} else { -- & ${f} ${D} ${i} $args --} --exit $LASTEXITCODE --`,Promise.all([Ue(e+".ps1",w,"utf8"),Ue(e+".cmd",d,"utf8"),Ue(e,m,"utf8")]).then(()=>Bn(e))},Bn=u=>Promise.all([Ve(u,493),Ve(u+".cmd",493),Ve(u+".ps1",493)]);var wn=He;He.ifExists=En;var yn=Nu(wn);const qu=async u=>{const e=A.join(u,"package.json");if(!await oe(e))throw new Error(`package.json not found in ${u}`);const D=await Ru(e);if(!D.name)throw new Error(`package.json must contain a name: ${e}`);return D},zu=async(u,e,t)=>{const D=await O.lstat(e).catch(()=>null);if(D){if(D.isSymbolicLink()){const n=await O.realpath(e).catch(()=>null);if(u===n)return}await O.rm(e,{recursive:!0})}await O.symlink(u,e,t)},$n=async(u,e)=>{await zu(u,e),await O.chmod(e,493)},An=async(u,e)=>{await oe(e)&&await O.rm(e,{recursive:!0}),await O.link(u,e)},bn=async(u,e,{name:t,bin:D},n)=>{if(!D)return[];t?.startsWith("@")&&([,t]=t.split("/"));const i=A.join(e,".bin");if(await O.mkdir(i,{recursive:!0}),typeof D=="string"){await n(A.resolve(u,D),A.join(i,t));return}await Promise.all(Object.entries(D).map(async([s,o])=>await n(A.resolve(u,o),A.join(i,s))))},vn="node_modules",Sn=async(u,e)=>{const t=A.resolve(u,e),D=await qu(t),n=A.join(u,vn),i=A.join(n,D.name),s=A.dirname(i);await O.mkdir(s,{recursive:!0});const o=A.relative(s,t);return await zu(o,i,"junction"),await bn(t,n,D,process.platform==="win32"?yn:$n),{name:D.name,path:i,target:o}},Ju=async(u,e,t)=>{const D=A.resolve(u,e);if(!await oe(D)){console.warn(xu("\u2716"),`Package path does not exist: ${e}`),process.exitCode=1;return}try{const i=await Sn(u,e);console.log(Tu("\u2714"),`Symlinked ${Je(i.name)}:`,re(i.path),"\u2192",re(i.target))}catch(i){console.warn(xu("\u2716"),"Failed to symlink",re(e),"with error:",i.message),process.exitCode=1;return}if(t.deep){const i=await Lu(D);i&&await Vu(D,i,t)}},Vu=async(u,e,t)=>{if(!e.packages)return;const D={deep:t.deep??e.deepLink??!1};await Promise.all(e.packages.map(async n=>await Ju(u,n,D)))};var Uu={},On=Zu;function Zu(u,e,t){u instanceof RegExp&&(u=Hu(u,t)),e instanceof RegExp&&(e=Hu(e,t));var D=Xu(u,e,t);return D&&{start:D[0],end:D[1],pre:t.slice(0,D[0]),body:t.slice(D[0]+u.length,D[1]),post:t.slice(D[1]+e.length)}}function Hu(u,e){var t=e.match(u);return t?t[0]:null}Zu.range=Xu;function Xu(u,e,t){var D,n,i,s,o,a=t.indexOf(u),h=t.indexOf(e,a+1),f=a;if(a>=0&&h>0){if(u===e)return[a,h];for(D=[],i=t.length;f>=0&&!o;)f==a?(D.push(f),a=t.indexOf(u,f+1)):D.length==1?o=[D.pop(),h]:(n=D.pop(),n=0?a:h;D.length&&(o=[i,s])}return o}var Yu=On,kn=Tn,Ku="\0SLASH"+Math.random()+"\0",Qu="\0OPEN"+Math.random()+"\0",Ye="\0CLOSE"+Math.random()+"\0",et="\0COMMA"+Math.random()+"\0",ut="\0PERIOD"+Math.random()+"\0";function Ke(u){return parseInt(u,10)==u?parseInt(u,10):u.charCodeAt(0)}function jn(u){return u.split("\\\\").join(Ku).split("\\{").join(Qu).split("\\}").join(Ye).split("\\,").join(et).split("\\.").join(ut)}function xn(u){return u.split(Ku).join("\\").split(Qu).join("{").split(Ye).join("}").split(et).join(",").split(ut).join(".")}function tt(u){if(!u)return[""];var e=[],t=Yu("{","}",u);if(!t)return u.split(",");var D=t.pre,n=t.body,i=t.post,s=D.split(",");s[s.length-1]+="{"+n+"}";var o=tt(i);return i.length&&(s[s.length-1]+=o.shift(),s.push.apply(s,o)),e.push.apply(e,s),e}function Tn(u){return u?(u.substr(0,2)==="{}"&&(u="\\{\\}"+u.substr(2)),ae(jn(u),!0).map(xn)):[]}function Rn(u){return"{"+u+"}"}function Pn(u){return/^-?0\d/.test(u)}function Ln(u,e){return u<=e}function Mn(u,e){return u>=e}function ae(u,e){var t=[],D=Yu("{","}",u);if(!D)return[u];var n=D.pre,i=D.post.length?ae(D.post,!1):[""];if(/\$$/.test(D.pre))for(var s=0;s=0;if(!f&&!g)return D.post.match(/,.*\}/)?(u=D.pre+"{"+D.body+Ye+D.post,ae(u)):[u];var F;if(f)F=D.body.split(/\.\./);else if(F=tt(D.body),F.length===1&&(F=ae(F[0],!1).map(Rn),F.length===1))return i.map(function(Ee){return D.pre+F[0]+Ee});var d;if(f){var m=Ke(F[0]),w=Ke(F[1]),S=Math.max(F[0].length,F[1].length),R=F.length==3?Math.abs(Ke(F[2])):1,M=Ln,G=w0){var Ce=new Array(de+1).join("0");J<0?P="-"+Ce+P.slice(1):P=Ce+P}}d.push(P)}}else{d=[];for(var _=0;_{if(typeof u!="string")throw new TypeError("invalid pattern");if(u.length>Nn)throw new TypeError("pattern is too long")};Ae.assertValidPattern=In;var le={},be={};Object.defineProperty(be,"__esModule",{value:!0}),be.parseClass=void 0;const _n={"[:alnum:]":["\\p{L}\\p{Nl}\\p{Nd}",!0],"[:alpha:]":["\\p{L}\\p{Nl}",!0],"[:ascii:]":["\\x00-\\x7f",!1],"[:blank:]":["\\p{Zs}\\t",!0],"[:cntrl:]":["\\p{Cc}",!0],"[:digit:]":["\\p{Nd}",!0],"[:graph:]":["\\p{Z}\\p{C}",!0,!0],"[:lower:]":["\\p{Ll}",!0],"[:print:]":["\\p{C}",!0],"[:punct:]":["\\p{P}",!0],"[:space:]":["\\p{Z}\\t\\r\\n\\v\\f",!0],"[:upper:]":["\\p{Lu}",!0],"[:word:]":["\\p{L}\\p{Nl}\\p{Nd}\\p{Pc}",!0],"[:xdigit:]":["A-Fa-f0-9",!1]},ce=u=>u.replace(/[[\]\\-]/g,"\\$&"),Wn=u=>u.replace(/[-[\]{}()*+?.,\\^$|#\s]/g,"\\$&"),Dt=u=>u.join(""),Gn=(u,e)=>{const t=e;if(u.charAt(t)!=="[")throw new Error("not in a brace expression");const D=[],n=[];let i=t+1,s=!1,o=!1,a=!1,h=!1,f=t,g="";e:for(;ig?D.push(ce(g)+"-"+ce(w)):w===g&&D.push(ce(w)),g="",i++;continue}if(u.startsWith("-]",i+1)){D.push(ce(w+"-")),i+=2;continue}if(u.startsWith("-",i+1)){g=w,i+=2;continue}D.push(ce(w)),i++}if(fe?u.replace(/\[([^\/\\])\]/g,"$1"):u.replace(/((?!\\).|^)\[([^\/\\])\]/g,"$1$2").replace(/\\([^\/])/g,"$1");Y.unescape=qn,Object.defineProperty(le,"__esModule",{value:!0}),le.AST=void 0;const zn=be,ve=Y,Jn=new Set(["!","?","+","*","@"]),nt=u=>Jn.has(u),Vn="(?!(?:^|/)\\.\\.?(?:$|/))",Se="(?!\\.)",Un=new Set(["[","."]),Zn=new Set(["..","."]),Hn=new Set("().*{}+?[]^$\\!"),Xn=u=>u.replace(/[-[\]{}()*+?.,\\^$|#\s]/g,"\\$&"),Qe="[^/]",it=Qe+"*?",st=Qe+"+?";class T{type;#t;#D;#n=!1;#e=[];#u;#s;#o;#r=!1;#i;#a;#c=!1;constructor(e,t,D={}){this.type=e,e&&(this.#D=!0),this.#u=t,this.#t=this.#u?this.#u.#t:this,this.#i=this.#t===this?D:this.#t.#i,this.#o=this.#t===this?[]:this.#t.#o,e==="!"&&!this.#t.#r&&this.#o.push(this),this.#s=this.#u?this.#u.#e.length:0}get hasMagic(){if(this.#D!==void 0)return this.#D;for(const e of this.#e)if(typeof e!="string"&&(e.type||e.hasMagic))return this.#D=!0;return this.#D}toString(){return this.#a!==void 0?this.#a:this.type?this.#a=this.type+"("+this.#e.map(e=>String(e)).join("|")+")":this.#a=this.#e.map(e=>String(e)).join("")}#p(){if(this!==this.#t)throw new Error("should only call on root");if(this.#r)return this;this.toString(),this.#r=!0;let e;for(;e=this.#o.pop();){if(e.type!=="!")continue;let t=e,D=t.#u;for(;D;){for(let n=t.#s+1;!D.type&&ntypeof t=="string"?t:t.toJSON()):[this.type,...this.#e.map(t=>t.toJSON())];return this.isStart()&&!this.type&&e.unshift([]),this.isEnd()&&(this===this.#t||this.#t.#r&&this.#u?.type==="!")&&e.push({}),e}isStart(){if(this.#t===this)return!0;if(!this.#u?.isStart())return!1;if(this.#s===0)return!0;const e=this.#u;for(let t=0;t{const[m,w,S,R]=typeof d=="string"?T.#f(d,this.#D,a):d.toRegExpSource(e);return this.#D=this.#D||S,this.#n=this.#n||R,m}).join("");let f="";if(this.isStart()&&typeof this.#e[0]=="string"&&!(this.#e.length===1&&Zn.has(this.#e[0]))){const m=Un,w=t&&m.has(h.charAt(0))||h.startsWith("\\.")&&m.has(h.charAt(2))||h.startsWith("\\.\\.")&&m.has(h.charAt(4)),S=!t&&!e&&m.has(h.charAt(0));f=w?Vn:S?Se:""}let g="";return this.isEnd()&&this.#t.#r&&this.#u?.type==="!"&&(g="(?:$|\\/)"),[f+h+g,(0,ve.unescape)(h),this.#D=!!this.#D,this.#n]}const D=this.type==="*"||this.type==="+",n=this.type==="!"?"(?:(?!(?:":"(?:";let i=this.#h(t);if(this.isStart()&&this.isEnd()&&!i&&this.type!=="!"){const a=this.toString();return this.#e=[a],this.type=null,this.#D=void 0,[a,(0,ve.unescape)(this.toString()),!1,!1]}let s=!D||e||t||!Se?"":this.#h(!0);s===i&&(s=""),s&&(i=`(?:${i})(?:${s})*?`);let o="";if(this.type==="!"&&this.#c)o=(this.isStart()&&!t?Se:"")+st;else{const a=this.type==="!"?"))"+(this.isStart()&&!t&&!e?Se:"")+it+")":this.type==="@"?")":this.type==="?"?")?":this.type==="+"&&s?")":this.type==="*"&&s?")?":`)${this.type}`;o=n+i+a}return[o,(0,ve.unescape)(i),this.#D=!!this.#D,this.#n]}#h(e){return this.#e.map(t=>{if(typeof t=="string")throw new Error("string type in extglob ast??");const[D,n,i,s]=t.toRegExpSource(e);return this.#n=this.#n||s,D}).filter(t=>!(this.isStart()&&this.isEnd())||!!t).join("|")}static#f(e,t,D=!1){let n=!1,i="",s=!1;for(let o=0;oe?u.replace(/[?*()[\]]/g,"[$&]"):u.replace(/[?*()[\]\\]/g,"\\$&");he.escape=Yn,function(u){var e=Mu&&Mu.__importDefault||function(C){return C&&C.__esModule?C:{default:C}};Object.defineProperty(u,"__esModule",{value:!0}),u.unescape=u.escape=u.AST=u.Minimatch=u.match=u.makeRe=u.braceExpand=u.defaults=u.filter=u.GLOBSTAR=u.sep=u.minimatch=void 0;const t=e(kn),D=Ae,n=le,i=he,s=Y,o=(C,r,l={})=>((0,D.assertValidPattern)(r),!l.nocomment&&r.charAt(0)==="#"?!1:new ue(r,l).match(C));u.minimatch=o;const a=/^\*+([^+@!?\*\[\(]*)$/,h=C=>r=>!r.startsWith(".")&&r.endsWith(C),f=C=>r=>r.endsWith(C),g=C=>(C=C.toLowerCase(),r=>!r.startsWith(".")&&r.toLowerCase().endsWith(C)),F=C=>(C=C.toLowerCase(),r=>r.toLowerCase().endsWith(C)),d=/^\*+\.\*+$/,m=C=>!C.startsWith(".")&&C.includes("."),w=C=>C!=="."&&C!==".."&&C.includes("."),S=/^\.\*+$/,R=C=>C!=="."&&C!==".."&&C.startsWith("."),M=/^\*+$/,G=C=>C.length!==0&&!C.startsWith("."),Te=C=>C.length!==0&&C!=="."&&C!=="..",J=/^\?+([^+@!?\*\[\(]*)?$/,P=([C,r=""])=>{const l=Ee([C]);return r?(r=r.toLowerCase(),c=>l(c)&&c.toLowerCase().endsWith(r)):l},de=([C,r=""])=>{const l=uu([C]);return r?(r=r.toLowerCase(),c=>l(c)&&c.toLowerCase().endsWith(r)):l},Ce=([C,r=""])=>{const l=uu([C]);return r?c=>l(c)&&c.endsWith(r):l},_=([C,r=""])=>{const l=Ee([C]);return r?c=>l(c)&&c.endsWith(r):l},Ee=([C])=>{const r=C.length;return l=>l.length===r&&!l.startsWith(".")},uu=([C])=>{const r=C.length;return l=>l.length===r&&l!=="."&&l!==".."},tu=typeof process=="object"&&process?typeof process.env=="object"&&process.env&&process.env.__MINIMATCH_TESTING_PLATFORM__||process.platform:"posix",Du={win32:{sep:"\\"},posix:{sep:"/"}};u.sep=tu==="win32"?Du.win32.sep:Du.posix.sep,u.minimatch.sep=u.sep,u.GLOBSTAR=Symbol("globstar **"),u.minimatch.GLOBSTAR=u.GLOBSTAR;const ft="[^/]"+"*?",Ft="(?:(?!(?:\\/|^)(?:\\.{1,2})($|\\/)).)*?",dt="(?:(?!(?:\\/|^)\\.).)*?",Ct=(C,r={})=>l=>(0,u.minimatch)(l,C,r);u.filter=Ct,u.minimatch.filter=u.filter;const L=(C,r={})=>Object.assign({},C,r),Et=C=>{if(!C||typeof C!="object"||!Object.keys(C).length)return u.minimatch;const r=u.minimatch;return Object.assign((c,p,E={})=>r(c,p,L(C,E)),{Minimatch:class extends r.Minimatch{constructor(p,E={}){super(p,L(C,E))}static defaults(p){return r.defaults(L(C,p)).Minimatch}},AST:class extends r.AST{constructor(p,E,B={}){super(p,E,L(C,B))}static fromGlob(p,E={}){return r.AST.fromGlob(p,L(C,E))}},unescape:(c,p={})=>r.unescape(c,L(C,p)),escape:(c,p={})=>r.escape(c,L(C,p)),filter:(c,p={})=>r.filter(c,L(C,p)),defaults:c=>r.defaults(L(C,c)),makeRe:(c,p={})=>r.makeRe(c,L(C,p)),braceExpand:(c,p={})=>r.braceExpand(c,L(C,p)),match:(c,p,E={})=>r.match(c,p,L(C,E)),sep:r.sep,GLOBSTAR:u.GLOBSTAR})};u.defaults=Et,u.minimatch.defaults=u.defaults;const gt=(C,r={})=>((0,D.assertValidPattern)(C),r.nobrace||!/\{(?:(?!\{).)*\}/.test(C)?[C]:(0,t.default)(C));u.braceExpand=gt,u.minimatch.braceExpand=u.braceExpand;const mt=(C,r={})=>new ue(C,r).makeRe();u.makeRe=mt,u.minimatch.makeRe=u.makeRe;const Bt=(C,r,l={})=>{const c=new ue(r,l);return C=C.filter(p=>c.match(p)),c.options.nonull&&!C.length&&C.push(r),C};u.match=Bt,u.minimatch.match=u.match;const nu=/[?*]|[+@!]\(.*?\)|\[|\]/,wt=C=>C.replace(/[-[\]{}()*+?.,\\^$|#\s]/g,"\\$&");class ue{options;set;pattern;windowsPathsNoEscape;nonegate;negate;comment;empty;preserveMultipleSlashes;partial;globSet;globParts;nocase;isWindows;platform;windowsNoMagicRoot;regexp;constructor(r,l={}){(0,D.assertValidPattern)(r),l=l||{},this.options=l,this.pattern=r,this.platform=l.platform||tu,this.isWindows=this.platform==="win32",this.windowsPathsNoEscape=!!l.windowsPathsNoEscape||l.allowWindowsEscape===!1,this.windowsPathsNoEscape&&(this.pattern=this.pattern.replace(/\\/g,"/")),this.preserveMultipleSlashes=!!l.preserveMultipleSlashes,this.regexp=null,this.negate=!1,this.nonegate=!!l.nonegate,this.comment=!1,this.empty=!1,this.partial=!!l.partial,this.nocase=!!this.options.nocase,this.windowsNoMagicRoot=l.windowsNoMagicRoot!==void 0?l.windowsNoMagicRoot:!!(this.isWindows&&this.nocase),this.globSet=[],this.globParts=[],this.set=[],this.make()}hasMagic(){if(this.options.magicalBraces&&this.set.length>1)return!0;for(const r of this.set)for(const l of r)if(typeof l!="string")return!0;return!1}debug(...r){}make(){const r=this.pattern,l=this.options;if(!l.nocomment&&r.charAt(0)==="#"){this.comment=!0;return}if(!r){this.empty=!0;return}this.parseNegate(),this.globSet=[...new Set(this.braceExpand())],l.debug&&(this.debug=(...E)=>console.error(...E)),this.debug(this.pattern,this.globSet);const c=this.globSet.map(E=>this.slashSplit(E));this.globParts=this.preprocess(c),this.debug(this.pattern,this.globParts);let p=this.globParts.map((E,B,y)=>{if(this.isWindows&&this.windowsNoMagicRoot){const b=E[0]===""&&E[1]===""&&(E[2]==="?"||!nu.test(E[2]))&&!nu.test(E[3]),v=/^[a-z]:/i.test(E[0]);if(b)return[...E.slice(0,4),...E.slice(4).map($=>this.parse($))];if(v)return[E[0],...E.slice(1).map($=>this.parse($))]}return E.map(b=>this.parse(b))});if(this.debug(this.pattern,p),this.set=p.filter(E=>E.indexOf(!1)===-1),this.isWindows)for(let E=0;E=2?(r=this.firstPhasePreProcess(r),r=this.secondPhasePreProcess(r)):l>=1?r=this.levelOneOptimize(r):r=this.adjascentGlobstarOptimize(r),r}adjascentGlobstarOptimize(r){return r.map(l=>{let c=-1;for(;(c=l.indexOf("**",c+1))!==-1;){let p=c;for(;l[p+1]==="**";)p++;p!==c&&l.splice(c,p-c)}return l})}levelOneOptimize(r){return r.map(l=>(l=l.reduce((c,p)=>{const E=c[c.length-1];return p==="**"&&E==="**"?c:p===".."&&E&&E!==".."&&E!=="."&&E!=="**"?(c.pop(),c):(c.push(p),c)},[]),l.length===0?[""]:l))}levelTwoFileOptimize(r){Array.isArray(r)||(r=this.slashSplit(r));let l=!1;do{if(l=!1,!this.preserveMultipleSlashes){for(let p=1;pp&&c.splice(p+1,B-p);let y=c[p+1];const b=c[p+2],v=c[p+3];if(y!==".."||!b||b==="."||b===".."||!v||v==="."||v==="..")continue;l=!0,c.splice(p,1);const $=c.slice(0);$[p]="**",r.push($),p--}if(!this.preserveMultipleSlashes){for(let B=1;Bl.length)}partsMatch(r,l,c=!1){let p=0,E=0,B=[],y="";for(;pDe?l=l.slice(Z):De>Z&&(r=r.slice(De)))}}const{optimizationLevel:E=1}=this.options;E>=2&&(r=this.levelTwoFileOptimize(r)),this.debug("matchOne",this,{file:r,pattern:l}),this.debug("matchOne",r.length,l.length);for(var B=0,y=0,b=r.length,v=l.length;B>> no match, partial?`,r,j,l,N),j===b))}let q;if(typeof $=="string"?(q=k===$,this.debug("string match",$,k,q)):(q=$.test(k),this.debug("pattern match",$,k,q)),!q)return!1}if(B===b&&y===v)return!0;if(B===b)return c;if(y===v)return B===b-1&&r[B]==="";throw new Error("wtf?")}braceExpand(){return(0,u.braceExpand)(this.pattern,this.options)}parse(r){(0,D.assertValidPattern)(r);const l=this.options;if(r==="**")return u.GLOBSTAR;if(r==="")return"";let c,p=null;(c=r.match(M))?p=l.dot?Te:G:(c=r.match(a))?p=(l.nocase?l.dot?F:g:l.dot?f:h)(c[1]):(c=r.match(J))?p=(l.nocase?l.dot?de:P:l.dot?Ce:_)(c):(c=r.match(d))?p=l.dot?w:m:(c=r.match(S))&&(p=R);const E=n.AST.fromGlob(r,this.options).toMMPattern();return p?Object.assign(E,{test:p}):E}makeRe(){if(this.regexp||this.regexp===!1)return this.regexp;const r=this.set;if(!r.length)return this.regexp=!1,this.regexp;const l=this.options,c=l.noglobstar?ft:l.dot?Ft:dt,p=new Set(l.nocase?["i"]:[]);let E=r.map(b=>{const v=b.map($=>{if($ instanceof RegExp)for(const k of $.flags.split(""))p.add(k);return typeof $=="string"?wt($):$===u.GLOBSTAR?u.GLOBSTAR:$._src});return v.forEach(($,k)=>{const j=v[k+1],N=v[k-1];$!==u.GLOBSTAR||N===u.GLOBSTAR||(N===void 0?j!==void 0&&j!==u.GLOBSTAR?v[k+1]="(?:\\/|"+c+"\\/)?"+j:v[k]=c:j===void 0?v[k-1]=N+"(?:\\/|"+c+")?":j!==u.GLOBSTAR&&(v[k-1]=N+"(?:\\/|\\/"+c+"\\/)"+j,v[k+1]=u.GLOBSTAR))}),v.filter($=>$!==u.GLOBSTAR).join("/")}).join("|");const[B,y]=r.length>1?["(?:",")"]:["",""];E="^"+B+E+y+"$",this.negate&&(E="^(?!"+E+").+$");try{this.regexp=new RegExp(E,[...p].join(""))}catch{this.regexp=!1}return this.regexp}slashSplit(r){return this.preserveMultipleSlashes?r.split("/"):this.isWindows&&/^\/\/[^\/]+/.test(r)?["",...r.split(/\/+/)]:r.split(/\/+/)}match(r,l=this.partial){if(this.debug("match",r,this.pattern),this.comment)return!1;if(this.empty)return r==="";if(r==="/"&&l)return!0;const c=this.options;this.isWindows&&(r=r.split("\\").join("/"));const p=this.slashSplit(r);this.debug(this.pattern,"split",p);const E=this.set;this.debug(this.pattern,"set",E);let B=p[p.length-1];if(!B)for(let y=p.length-2;!B&&y>=0;y--)B=p[y];for(let y=0;y/^@/.test(n)?`./${n}`:n).sort(this.sort),this.result=t),e==="error"&&this.parent?D=this.parent.emit("error",t):D=super.emit(e,t)),D}start(){return z.readdir(this.path,(e,t)=>e?this.emit("error",e):this.onReaddir(t)),this}isIgnoreFile(e){return e!=="."&&e!==".."&&this.ignoreFiles.indexOf(e)!==-1}onReaddir(e){this.entries=e,e.length===0?(this.includeEmpty&&this.result.add(this.path.slice(this.root.length+1)),this.emit("done",this.result)):this.entries.some(D=>this.isIgnoreFile(D))?this.addIgnoreFiles():this.filterEntries()}addIgnoreFiles(){const e=this.entries.filter(n=>this.isIgnoreFile(n));let t=e.length;const D=n=>{--t===0&&this.filterEntries()};e.forEach(n=>this.addIgnoreFile(n,D))}addIgnoreFile(e,t){const D=eu.resolve(this.path,e);z.readFile(D,"utf8",(n,i)=>n?this.emit("error",n):this.onReadIgnoreFile(e,i,t))}onReadIgnoreFile(e,t,D){const n={matchBase:!0,dot:!0,flipNegate:!0,nocase:!0},i=t.split(/\r?\n/).filter(s=>!/^#|^$/.test(s.trim())).map(s=>new Qn(s.trim(),n));this.ignoreRules[e]=i,D()}filterEntries(){const e=this.entries.map(D=>{const n=this.filterEntry(D),i=this.filterEntry(D,!0);return n||i?[D,n,i]:!1}).filter(D=>D);let t=e.length;if(t===0)this.emit("done",this.result);else{const D=n=>{--t===0&&this.emit("done",this.result)};e.forEach(n=>{const i=n[0],s=n[1],o=n[2];this.stat({entry:i,file:s,dir:o},D)})}}onstat({st:e,entry:t,file:D,dir:n,isSymbolicLink:i},s){const o=this.path+"/"+t;e.isDirectory()?n?this.walker(t,{isSymbolicLink:i},s):s():(D&&this.result.add(o.slice(this.root.length+1)),s())}stat({entry:e,file:t,dir:D},n){const i=this.path+"/"+e;z.lstat(i,(s,o)=>{if(s)this.emit("error",s);else{const a=o.isSymbolicLink();this.follow&&a?z.stat(i,(h,f)=>{h?this.emit("error",h):this.onstat({st:f,entry:e,file:t,dir:D,isSymbolicLink:a},n)}):this.onstat({st:o,entry:e,file:t,dir:D,isSymbolicLink:a},n)}})}walkerOpt(e,t){return{path:this.path+"/"+e,parent:this,ignoreFiles:this.ignoreFiles,follow:this.follow,includeEmpty:this.includeEmpty,...t}}walker(e,t,D){new fe(this.walkerOpt(e,t)).on("done",D).start()}filterEntry(e,t){let D=!0;if(this.parent&&this.parent.filterEntry){var n=this.basename+"/"+e;D=this.parent.filterEntry(n,t)}return this.ignoreFiles.forEach(i=>{this.ignoreRules[i]&&this.ignoreRules[i].forEach(s=>{s.negate!==D&&(s.match("/"+e)||s.match(e)||t&&(s.match("/"+e+"/")||s.match(e+"/"))||t&&s.negate&&(s.match("/"+e,!0)||s.match(e,!0)))&&(D=s.negate)})}),D}}class xe extends fe{start(){return this.onReaddir(z.readdirSync(this.path)),this}addIgnoreFile(e,t){const D=eu.resolve(this.path,e);this.onReadIgnoreFile(e,z.readFileSync(D,"utf8"),t)}stat({entry:e,file:t,dir:D},n){const i=this.path+"/"+e;let s=z.lstatSync(i);const o=s.isSymbolicLink();this.follow&&o&&(s=z.statSync(i)),this.onstat({st:s,entry:e,file:t,dir:D,isSymbolicLink:o},n)}walker(e,t,D){new xe(this.walkerOpt(e,t)).start(),D()}}const Oe=(u,e)=>{const t=new Promise((D,n)=>{new fe(u).on("done",D).on("error",n).start()});return e?t.then(D=>e(null,D),e):t},ei=u=>new xe(u).start().result;var ui=Oe;Oe.sync=ei,Oe.Walker=fe,Oe.WalkerSync=xe;const{Walker:ti}=ui,{lstatSync:Di,readFileSync:ni}=ru,{basename:rt,dirname:ii,extname:ot,join:K,relative:Q,resolve:si,sep:ri}=A,pe=Symbol("npm-packlist.rules.default"),ee=Symbol("npm-packlist.rules.strict"),oi=u=>/\*/.test(u),ai=[".npmignore",".gitignore","**/.git","**/.svn","**/.hg","**/CVS","**/.git/**","**/.svn/**","**/.hg/**","**/CVS/**","/.lock-wscript","/.wafpickle-*","/build/config.gypi","npm-debug.log","**/.npmrc",".*.swp",".DS_Store","**/.DS_Store/**","._*","**/._*/**","*.orig","/archived-packages/**"],at=["/.git"],ke=["!/readme{,.*[^~$]}","!/copying{,.*[^~$]}","!/license{,.*[^~$]}","!/licence{,.*[^~$]}"],li=[/^!.*readme/i,/^!.*copying/i,/^!.*licen[sc]e/i],je=u=>u.split("\\").join("/"),lt=(u,e,t=[])=>{for(const s of[".npmignore",".gitignore"])try{const o=ni(K(u,s),{encoding:"utf8"});t.push(o);break}catch(o){if(o.code!=="ENOENT")throw o}if(!e)return t;const D=e.split(ri,1)[0],n=K(u,D),i=Q(n,K(u,e));return lt(n,i,t)};class Fe extends ti{constructor(e,t){const D={...t,includeEmpty:!1,follow:!1,path:si(t?.path||e.path).replace(/\\/g,"/"),ignoreFiles:t?.ignoreFiles||[pe,"package.json",".npmignore",".gitignore",ee]};super(D),this.isPackage=D.isPackage,this.seen=D.seen||new Set,this.tree=e,this.requiredFiles=D.requiredFiles||[];const n=[];if(D.prefix&&D.workspaces){const i=je(D.path),s=je(D.prefix),o=D.workspaces.map(a=>je(a));if(i!==s&&o.includes(i)){const a=Q(D.prefix,ii(D.path));n.push(...lt(D.prefix,a))}else i===s&&n.push(...o.map(a=>je(Q(D.path,a))))}this.injectRules(pe,[...ai,...n]),this.isPackage||this.injectRules(ee,[...at,...ke,...this.requiredFiles.map(i=>`!${i}`)])}addIgnoreFile(e,t){return e!=="package.json"||!this.isPackage?super.addIgnoreFile(e,t):this.processPackage(t)}emit(e,t){return e!=="done"||!this.isPackage?super.emit(e,t):(this.gatherBundles().then(()=>{super.emit("done",this.result)}),!0)}filterEntries(){return this.ignoreRules["package.json"]?(this.ignoreRules[".npmignore"]=null,this.ignoreRules[".gitignore"]=null):this.ignoreRules[".npmignore"]&&(this.ignoreRules[".gitignore"]=null),super.filterEntries()}onstat(e,t){return!e.st.isFile()&&!e.st.isDirectory()?t():super.onstat(e,t)}stat(e,t){return oi(e.entry)?t():super.stat(e,t)}walkerOpt(e,t){let D=null;if(this.tree.workspaces){const n=[...this.tree.workspaces.values()].map(s=>s.replace(/\\/g,"/")),i=K(this.path,e).replace(/\\/g,"/");n.includes(i)&&(D=[pe,"package.json",".npmignore",".gitignore",ee])}else D=[pe,".npmignore",".gitignore",ee];return{...super.walkerOpt(e,t),ignoreFiles:D,requiredFiles:this.requiredFiles.map(n=>Q(n,e)===".."?Q(e,n).replace(/\\/g,"/"):!1).filter(Boolean)}}walker(e,t,D){new Fe(this.tree,this.walkerOpt(e,t)).on("done",D).start()}sort(e,t){const D=ot(e).toLowerCase(),n=ot(t).toLowerCase(),i=rt(e).toLowerCase(),s=rt(t).toLowerCase();return D.localeCompare(n,"en")||i.localeCompare(s,"en")||e.localeCompare(t,"en")}injectRules(e,t,D=()=>{}){this.onReadIgnoreFile(e,`${t.join(` --`)} --`,D)}processPackage(e){const{bin:t,browser:D,files:n,main:i}=this.tree.package,s=[],o=[...at,...ke,"!/package.json","/.git","/node_modules","/package-lock.json","/yarn.lock","/pnpm-lock.yaml"];if(n){for(let a of n){a.startsWith("/")?a=a.slice(1):a.startsWith("./")?a=a.slice(2):a.endsWith("/*")&&(a=a.slice(0,-2));const h=`!${a}`;this.excludeNonRoot(a);try{const f=Di(K(this.path,a.replace(/^!+/,"")).replace(/\\/g,"/"));f.isFile()?(o.unshift(h),this.requiredFiles.push(a)):f.isDirectory()&&(s.push(h),s.push(`${h}/**`))}catch{s.push(h)}}this.injectRules("package.json",["*",...s])}if(D&&o.push(`!/${D}`),i&&o.push(`!/${i}`),t)for(const a in t)o.push(`!/${t[a]}`);this.injectRules(ee,o,e)}excludeNonRoot(e){const t=li.find(D=>D.test(e));if(t){const D=ke.findIndex(n=>t.test(n));ke.splice(D,1)}}async gatherBundles(){if(this.seen.has(this.tree))return;this.seen.add(this.tree);let e;if(this.tree.isProjectRoot){const{bundleDependencies:t}=this.tree.package;e=t||[]}else{const{dependencies:t,optionalDependencies:D}=this.tree.package;e=Object.keys(t||{}).concat(Object.keys(D||{}))}for(const t of e){const D=this.tree.edgesOut.get(t);if(!D||D.peer||D.dev)continue;const n=this.tree.edgesOut.get(t).to;if(!n)continue;const i=n.path,s=n.target,o={path:i,isPackage:!0,ignoreFiles:[],seen:this.seen};n.isLink&&o.ignoreFiles.push(pe),o.ignoreFiles.push("package.json"),n.isLink&&(o.ignoreFiles.push(".npmignore"),o.ignoreFiles.push(".gitignore")),o.ignoreFiles.push(ee);const a=new Fe(s,o),h=await new Promise((g,F)=>{a.on("error",F),a.on("done",g),a.start()}),f=Q(this.root,a.path);for(const g of h)this.result.add(K(f,g).replace(/\\/g,"/"))}}}const ct=(u,e,t)=>{typeof e=="function"&&(t=e,e={});const D=new Promise((n,i)=>{new Fe(u,{...e,isPackage:!0}).on("done",n).on("error",i).start()});return t?D.then(n=>t(null,n),t):D};var ci=ct;ct.Walker=Fe;var ht=Nu(ci);const hi=async(u,e)=>{const t=A.resolve(u,e),D=await qu(t),n=A.join(u,"node_modules/"),i=A.join(n,D.name);if((await O.stat(i).catch(()=>null))?.isDirectory()){const o=await O.realpath(i);if(o.startsWith(n)){const a=new Map,[h,f]=await Promise.all([ht({path:o,package:D,edgesOut:a}),ht({path:t,package:D,edgesOut:a})]);console.log(`Symlinking ${Je(D.name)}:`),await Promise.all(f.map(async g=>{const F=A.join(t,g),d=A.join(i,g);await O.mkdir(A.dirname(d),{recursive:!0}),await An(F,d);const m=h.indexOf(g);m>-1&&h.splice(m,1),console.log(` ${Tu("\u2714")}`,re(A.relative(u,d)),"\u2192",re(A.relative(u,F)))})),await Promise.all(h.map(async g=>{const F=A.join(i,g);await O.rm(F)}));return}}console.error(W` -- Error: Package ${Je(D.name)} is not set up -- -- ${rn("Setup instructions")} -- 1. In the Dependency package, create a tarball: -- ${ze("$ npm pack")} -- -- 2. In the Consuming package, install the tarball and link the Dependency: -- ${ze("$ npm install --no-save ")} -- ${ze("$ npx link publish ")} -- -- 3. Start developing! -- -- Learn more: https://npmjs.com/link -- `)};var pt={command:XD({name:"publish",parameters:[""],flags:{},help:{description:"Link a package to simulate an environment similar to `npm install`"}}),handler:async(u,e)=>{e.length>0&&await Promise.all(e.map(t=>hi(u,t)))}};(async()=>{const u=HD({name:"link",parameters:["[package paths...]"],flags:{deep:{type:Boolean,alias:"d",description:"Run `npx link` on dependencies if they have a link.config.json"}},help:{description:"A better `npm link` -- symlink local dependencies to the current project",render:(t,D)=>(t[0].data=`npx link --`,t.splice(2,0,{type:"section",data:{title:"Website",body:"https://www.npmjs.com/package/link"}}),D.render(t))},commands:[pt.command]}),e=await O.realpath(process.cwd());if(u.command)u.command==="publish"&&await pt.handler(e,u._);else{const{packagePaths:t}=u._;if(t.length>0){await Promise.all(t.map(n=>Ju(e,n,u.flags)));return}const D=await Lu(e);if(!D){console.warn(W` -+ var isNumericSequence = /^-?\d+\.\.-?\d+(?:\.\.-?\d+)?$/.test(m.body); -+ var isAlphaSequence = /^[a-zA-Z]\.\.[a-zA-Z](?:\.\.-?\d+)?$/.test(m.body); -+ var isSequence = isNumericSequence || isAlphaSequence; -+ var isOptions = m.body.indexOf(',') >= 0; -+ if (!isSequence && !isOptions) { -+ // {a},b} -+ if (m.post.match(/,.*\}/)) { -+ str = m.pre + '{' + m.body + escClose + m.post; -+ return expand(str); -+ } -+ return [str]; -+ } -+ -+ var n; -+ if (isSequence) { -+ n = m.body.split(/\.\./); -+ } else { -+ n = parseCommaParts(m.body); -+ if (n.length === 1) { -+ // x{{a,b}}y ==> x{a}y x{b}y -+ n = expand(n[0], false).map(embrace); -+ if (n.length === 1) { -+ return post.map(function(p) { -+ return m.pre + n[0] + p; -+ }); -+ } -+ } -+ } -+ -+ // at this point, n is the parts, and we know it's not a comma set -+ // with a single entry. -+ var N; -+ -+ if (isSequence) { -+ var x = numeric(n[0]); -+ var y = numeric(n[1]); -+ var width = Math.max(n[0].length, n[1].length); -+ var incr = n.length == 3 -+ ? Math.abs(numeric(n[2])) -+ : 1; -+ var test = lte; -+ var reverse = y < x; -+ if (reverse) { -+ incr *= -1; -+ test = gte; -+ } -+ var pad = n.some(isPadded); -+ -+ N = []; -+ -+ for (var i = x; test(i, y); i += incr) { -+ var c; -+ if (isAlphaSequence) { -+ c = String.fromCharCode(i); -+ if (c === '\\') -+ c = ''; -+ } else { -+ c = String(i); -+ if (pad) { -+ var need = width - c.length; -+ if (need > 0) { -+ var z = new Array(need + 1).join('0'); -+ if (i < 0) -+ c = '-' + z + c.slice(1); -+ else -+ c = z + c; -+ } -+ } -+ } -+ N.push(c); -+ } -+ } else { -+ N = []; -+ -+ for (var j = 0; j < n.length; j++) { -+ N.push.apply(N, expand(n[j], false)); -+ } -+ } -+ -+ for (var j = 0; j < N.length; j++) { -+ for (var k = 0; k < post.length; k++) { -+ var expansion = pre + N[j] + post[k]; -+ if (!isTop || isSequence || expansion) -+ expansions.push(expansion); -+ } -+ } -+ } -+ -+ return expansions; -+} -+ -+var assertValidPattern$1 = {}; -+ -+Object.defineProperty(assertValidPattern$1, "__esModule", { value: true }); -+assertValidPattern$1.assertValidPattern = void 0; -+const MAX_PATTERN_LENGTH = 1024 * 64; -+const assertValidPattern = (pattern) => { -+ if (typeof pattern !== 'string') { -+ throw new TypeError('invalid pattern'); -+ } -+ if (pattern.length > MAX_PATTERN_LENGTH) { -+ throw new TypeError('pattern is too long'); -+ } -+}; -+assertValidPattern$1.assertValidPattern = assertValidPattern; -+ -+var ast = {}; -+ -+var braceExpressions = {}; -+ -+// translate the various posix character classes into unicode properties -+// this works across all unicode locales -+Object.defineProperty(braceExpressions, "__esModule", { value: true }); -+braceExpressions.parseClass = void 0; -+// { : [, /u flag required, negated] -+const posixClasses = { -+ '[:alnum:]': ['\\p{L}\\p{Nl}\\p{Nd}', true], -+ '[:alpha:]': ['\\p{L}\\p{Nl}', true], -+ '[:ascii:]': ['\\x' + '00-\\x' + '7f', false], -+ '[:blank:]': ['\\p{Zs}\\t', true], -+ '[:cntrl:]': ['\\p{Cc}', true], -+ '[:digit:]': ['\\p{Nd}', true], -+ '[:graph:]': ['\\p{Z}\\p{C}', true, true], -+ '[:lower:]': ['\\p{Ll}', true], -+ '[:print:]': ['\\p{C}', true], -+ '[:punct:]': ['\\p{P}', true], -+ '[:space:]': ['\\p{Z}\\t\\r\\n\\v\\f', true], -+ '[:upper:]': ['\\p{Lu}', true], -+ '[:word:]': ['\\p{L}\\p{Nl}\\p{Nd}\\p{Pc}', true], -+ '[:xdigit:]': ['A-Fa-f0-9', false], -+}; -+// only need to escape a few things inside of brace expressions -+// escapes: [ \ ] - -+const braceEscape = (s) => s.replace(/[[\]\\-]/g, '\\$&'); -+// escape all regexp magic characters -+const regexpEscape = (s) => s.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); -+// everything has already been escaped, we just have to join -+const rangesToString = (ranges) => ranges.join(''); -+// takes a glob string at a posix brace expression, and returns -+// an equivalent regular expression source, and boolean indicating -+// whether the /u flag needs to be applied, and the number of chars -+// consumed to parse the character class. -+// This also removes out of order ranges, and returns ($.) if the -+// entire class just no good. -+const parseClass = (glob, position) => { -+ const pos = position; -+ /* c8 ignore start */ -+ if (glob.charAt(pos) !== '[') { -+ throw new Error('not in a brace expression'); -+ } -+ /* c8 ignore stop */ -+ const ranges = []; -+ const negs = []; -+ let i = pos + 1; -+ let sawStart = false; -+ let uflag = false; -+ let escaping = false; -+ let negate = false; -+ let endPos = pos; -+ let rangeStart = ''; -+ WHILE: while (i < glob.length) { -+ const c = glob.charAt(i); -+ if ((c === '!' || c === '^') && i === pos + 1) { -+ negate = true; -+ i++; -+ continue; -+ } -+ if (c === ']' && sawStart && !escaping) { -+ endPos = i + 1; -+ break; -+ } -+ sawStart = true; -+ if (c === '\\') { -+ if (!escaping) { -+ escaping = true; -+ i++; -+ continue; -+ } -+ // escaped \ char, fall through and treat like normal char -+ } -+ if (c === '[' && !escaping) { -+ // either a posix class, a collation equivalent, or just a [ -+ for (const [cls, [unip, u, neg]] of Object.entries(posixClasses)) { -+ if (glob.startsWith(cls, i)) { -+ // invalid, [a-[] is fine, but not [a-[:alpha]] -+ if (rangeStart) { -+ return ['$.', false, glob.length - pos, true]; -+ } -+ i += cls.length; -+ if (neg) -+ negs.push(unip); -+ else -+ ranges.push(unip); -+ uflag = uflag || u; -+ continue WHILE; -+ } -+ } -+ } -+ // now it's just a normal character, effectively -+ escaping = false; -+ if (rangeStart) { -+ // throw this range away if it's not valid, but others -+ // can still match. -+ if (c > rangeStart) { -+ ranges.push(braceEscape(rangeStart) + '-' + braceEscape(c)); -+ } -+ else if (c === rangeStart) { -+ ranges.push(braceEscape(c)); -+ } -+ rangeStart = ''; -+ i++; -+ continue; -+ } -+ // now might be the start of a range. -+ // can be either c-d or c-] or c] or c] at this point -+ if (glob.startsWith('-]', i + 1)) { -+ ranges.push(braceEscape(c + '-')); -+ i += 2; -+ continue; -+ } -+ if (glob.startsWith('-', i + 1)) { -+ rangeStart = c; -+ i += 2; -+ continue; -+ } -+ // not the start of a range, just a single character -+ ranges.push(braceEscape(c)); -+ i++; -+ } -+ if (endPos < i) { -+ // didn't see the end of the class, not a valid class, -+ // but might still be valid as a literal match. -+ return ['', false, 0, false]; -+ } -+ // if we got no ranges and no negates, then we have a range that -+ // cannot possibly match anything, and that poisons the whole glob -+ if (!ranges.length && !negs.length) { -+ return ['$.', false, glob.length - pos, true]; -+ } -+ // if we got one positive range, and it's a single character, then that's -+ // not actually a magic pattern, it's just that one literal character. -+ // we should not treat that as "magic", we should just return the literal -+ // character. [_] is a perfectly valid way to escape glob magic chars. -+ if (negs.length === 0 && -+ ranges.length === 1 && -+ /^\\?.$/.test(ranges[0]) && -+ !negate) { -+ const r = ranges[0].length === 2 ? ranges[0].slice(-1) : ranges[0]; -+ return [regexpEscape(r), false, endPos - pos, false]; -+ } -+ const sranges = '[' + (negate ? '^' : '') + rangesToString(ranges) + ']'; -+ const snegs = '[' + (negate ? '' : '^') + rangesToString(negs) + ']'; -+ const comb = ranges.length && negs.length -+ ? '(' + sranges + '|' + snegs + ')' -+ : ranges.length -+ ? sranges -+ : snegs; -+ return [comb, uflag, endPos - pos, true]; -+}; -+braceExpressions.parseClass = parseClass; -+ -+var _unescape = {}; -+ -+Object.defineProperty(_unescape, "__esModule", { value: true }); -+_unescape.unescape = void 0; -+/** -+ * Un-escape a string that has been escaped with {@link escape}. -+ * -+ * If the {@link windowsPathsNoEscape} option is used, then square-brace -+ * escapes are removed, but not backslash escapes. For example, it will turn -+ * the string `'[*]'` into `*`, but it will not turn `'\\*'` into `'*'`, -+ * becuase `\` is a path separator in `windowsPathsNoEscape` mode. -+ * -+ * When `windowsPathsNoEscape` is not set, then both brace escapes and -+ * backslash escapes are removed. -+ * -+ * Slashes (and backslashes in `windowsPathsNoEscape` mode) cannot be escaped -+ * or unescaped. -+ */ -+const unescape = (s, { windowsPathsNoEscape = false, } = {}) => { -+ return windowsPathsNoEscape -+ ? s.replace(/\[([^\/\\])\]/g, '$1') -+ : s.replace(/((?!\\).|^)\[([^\/\\])\]/g, '$1$2').replace(/\\([^\/])/g, '$1'); -+}; -+_unescape.unescape = unescape; -+ -+// parse a single path portion -+Object.defineProperty(ast, "__esModule", { value: true }); -+ast.AST = void 0; -+const brace_expressions_js_1 = braceExpressions; -+const unescape_js_1 = _unescape; -+const types = new Set(['!', '?', '+', '*', '@']); -+const isExtglobType = (c) => types.has(c); -+// Patterns that get prepended to bind to the start of either the -+// entire string, or just a single path portion, to prevent dots -+// and/or traversal patterns, when needed. -+// Exts don't need the ^ or / bit, because the root binds that already. -+const startNoTraversal = '(?!(?:^|/)\\.\\.?(?:$|/))'; -+const startNoDot = '(?!\\.)'; -+// characters that indicate a start of pattern needs the "no dots" bit, -+// because a dot *might* be matched. ( is not in the list, because in -+// the case of a child extglob, it will handle the prevention itself. -+const addPatternStart = new Set(['[', '.']); -+// cases where traversal is A-OK, no dot prevention needed -+const justDots = new Set(['..', '.']); -+const reSpecials = new Set('().*{}+?[]^$\\!'); -+const regExpEscape = (s) => s.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); -+// any single thing other than / -+const qmark = '[^/]'; -+// * => any number of characters -+const star = qmark + '*?'; -+// use + when we need to ensure that *something* matches, because the * is -+// the only thing in the path portion. -+const starNoEmpty = qmark + '+?'; -+// remove the \ chars that we added if we end up doing a nonmagic compare -+// const deslash = (s: string) => s.replace(/\\(.)/g, '$1') -+class AST { -+ type; -+ #root; -+ #hasMagic; -+ #uflag = false; -+ #parts = []; -+ #parent; -+ #parentIndex; -+ #negs; -+ #filledNegs = false; -+ #options; -+ #toString; -+ // set to true if it's an extglob with no children -+ // (which really means one child of '') -+ #emptyExt = false; -+ constructor(type, parent, options = {}) { -+ this.type = type; -+ // extglobs are inherently magical -+ if (type) -+ this.#hasMagic = true; -+ this.#parent = parent; -+ this.#root = this.#parent ? this.#parent.#root : this; -+ this.#options = this.#root === this ? options : this.#root.#options; -+ this.#negs = this.#root === this ? [] : this.#root.#negs; -+ if (type === '!' && !this.#root.#filledNegs) -+ this.#negs.push(this); -+ this.#parentIndex = this.#parent ? this.#parent.#parts.length : 0; -+ } -+ get hasMagic() { -+ /* c8 ignore start */ -+ if (this.#hasMagic !== undefined) -+ return this.#hasMagic; -+ /* c8 ignore stop */ -+ for (const p of this.#parts) { -+ if (typeof p === 'string') -+ continue; -+ if (p.type || p.hasMagic) -+ return (this.#hasMagic = true); -+ } -+ // note: will be undefined until we generate the regexp src and find out -+ return this.#hasMagic; -+ } -+ // reconstructs the pattern -+ toString() { -+ if (this.#toString !== undefined) -+ return this.#toString; -+ if (!this.type) { -+ return (this.#toString = this.#parts.map(p => String(p)).join('')); -+ } -+ else { -+ return (this.#toString = -+ this.type + '(' + this.#parts.map(p => String(p)).join('|') + ')'); -+ } -+ } -+ #fillNegs() { -+ /* c8 ignore start */ -+ if (this !== this.#root) -+ throw new Error('should only call on root'); -+ if (this.#filledNegs) -+ return this; -+ /* c8 ignore stop */ -+ // call toString() once to fill this out -+ this.toString(); -+ this.#filledNegs = true; -+ let n; -+ while ((n = this.#negs.pop())) { -+ if (n.type !== '!') -+ continue; -+ // walk up the tree, appending everthing that comes AFTER parentIndex -+ let p = n; -+ let pp = p.#parent; -+ while (pp) { -+ for (let i = p.#parentIndex + 1; !pp.type && i < pp.#parts.length; i++) { -+ for (const part of n.#parts) { -+ /* c8 ignore start */ -+ if (typeof part === 'string') { -+ throw new Error('string part in extglob AST??'); -+ } -+ /* c8 ignore stop */ -+ part.copyIn(pp.#parts[i]); -+ } -+ } -+ p = pp; -+ pp = p.#parent; -+ } -+ } -+ return this; -+ } -+ push(...parts) { -+ for (const p of parts) { -+ if (p === '') -+ continue; -+ /* c8 ignore start */ -+ if (typeof p !== 'string' && !(p instanceof AST && p.#parent === this)) { -+ throw new Error('invalid part: ' + p); -+ } -+ /* c8 ignore stop */ -+ this.#parts.push(p); -+ } -+ } -+ toJSON() { -+ const ret = this.type === null -+ ? this.#parts.slice().map(p => (typeof p === 'string' ? p : p.toJSON())) -+ : [this.type, ...this.#parts.map(p => p.toJSON())]; -+ if (this.isStart() && !this.type) -+ ret.unshift([]); -+ if (this.isEnd() && -+ (this === this.#root || -+ (this.#root.#filledNegs && this.#parent?.type === '!'))) { -+ ret.push({}); -+ } -+ return ret; -+ } -+ isStart() { -+ if (this.#root === this) -+ return true; -+ // if (this.type) return !!this.#parent?.isStart() -+ if (!this.#parent?.isStart()) -+ return false; -+ if (this.#parentIndex === 0) -+ return true; -+ // if everything AHEAD of this is a negation, then it's still the "start" -+ const p = this.#parent; -+ for (let i = 0; i < this.#parentIndex; i++) { -+ const pp = p.#parts[i]; -+ if (!(pp instanceof AST && pp.type === '!')) { -+ return false; -+ } -+ } -+ return true; -+ } -+ isEnd() { -+ if (this.#root === this) -+ return true; -+ if (this.#parent?.type === '!') -+ return true; -+ if (!this.#parent?.isEnd()) -+ return false; -+ if (!this.type) -+ return this.#parent?.isEnd(); -+ // if not root, it'll always have a parent -+ /* c8 ignore start */ -+ const pl = this.#parent ? this.#parent.#parts.length : 0; -+ /* c8 ignore stop */ -+ return this.#parentIndex === pl - 1; -+ } -+ copyIn(part) { -+ if (typeof part === 'string') -+ this.push(part); -+ else -+ this.push(part.clone(this)); -+ } -+ clone(parent) { -+ const c = new AST(this.type, parent); -+ for (const p of this.#parts) { -+ c.copyIn(p); -+ } -+ return c; -+ } -+ static #parseAST(str, ast, pos, opt) { -+ let escaping = false; -+ let inBrace = false; -+ let braceStart = -1; -+ let braceNeg = false; -+ if (ast.type === null) { -+ // outside of a extglob, append until we find a start -+ let i = pos; -+ let acc = ''; -+ while (i < str.length) { -+ const c = str.charAt(i++); -+ // still accumulate escapes at this point, but we do ignore -+ // starts that are escaped -+ if (escaping || c === '\\') { -+ escaping = !escaping; -+ acc += c; -+ continue; -+ } -+ if (inBrace) { -+ if (i === braceStart + 1) { -+ if (c === '^' || c === '!') { -+ braceNeg = true; -+ } -+ } -+ else if (c === ']' && !(i === braceStart + 2 && braceNeg)) { -+ inBrace = false; -+ } -+ acc += c; -+ continue; -+ } -+ else if (c === '[') { -+ inBrace = true; -+ braceStart = i; -+ braceNeg = false; -+ acc += c; -+ continue; -+ } -+ if (!opt.noext && isExtglobType(c) && str.charAt(i) === '(') { -+ ast.push(acc); -+ acc = ''; -+ const ext = new AST(c, ast); -+ i = AST.#parseAST(str, ext, i, opt); -+ ast.push(ext); -+ continue; -+ } -+ acc += c; -+ } -+ ast.push(acc); -+ return i; -+ } -+ // some kind of extglob, pos is at the ( -+ // find the next | or ) -+ let i = pos + 1; -+ let part = new AST(null, ast); -+ const parts = []; -+ let acc = ''; -+ while (i < str.length) { -+ const c = str.charAt(i++); -+ // still accumulate escapes at this point, but we do ignore -+ // starts that are escaped -+ if (escaping || c === '\\') { -+ escaping = !escaping; -+ acc += c; -+ continue; -+ } -+ if (inBrace) { -+ if (i === braceStart + 1) { -+ if (c === '^' || c === '!') { -+ braceNeg = true; -+ } -+ } -+ else if (c === ']' && !(i === braceStart + 2 && braceNeg)) { -+ inBrace = false; -+ } -+ acc += c; -+ continue; -+ } -+ else if (c === '[') { -+ inBrace = true; -+ braceStart = i; -+ braceNeg = false; -+ acc += c; -+ continue; -+ } -+ if (isExtglobType(c) && str.charAt(i) === '(') { -+ part.push(acc); -+ acc = ''; -+ const ext = new AST(c, part); -+ part.push(ext); -+ i = AST.#parseAST(str, ext, i, opt); -+ continue; -+ } -+ if (c === '|') { -+ part.push(acc); -+ acc = ''; -+ parts.push(part); -+ part = new AST(null, ast); -+ continue; -+ } -+ if (c === ')') { -+ if (acc === '' && ast.#parts.length === 0) { -+ ast.#emptyExt = true; -+ } -+ part.push(acc); -+ acc = ''; -+ ast.push(...parts, part); -+ return i; -+ } -+ acc += c; -+ } -+ // unfinished extglob -+ // if we got here, it was a malformed extglob! not an extglob, but -+ // maybe something else in there. -+ ast.type = null; -+ ast.#hasMagic = undefined; -+ ast.#parts = [str.substring(pos - 1)]; -+ return i; -+ } -+ static fromGlob(pattern, options = {}) { -+ const ast = new AST(null, undefined, options); -+ AST.#parseAST(pattern, ast, 0, options); -+ return ast; -+ } -+ // returns the regular expression if there's magic, or the unescaped -+ // string if not. -+ toMMPattern() { -+ // should only be called on root -+ /* c8 ignore start */ -+ if (this !== this.#root) -+ return this.#root.toMMPattern(); -+ /* c8 ignore stop */ -+ const glob = this.toString(); -+ const [re, body, hasMagic, uflag] = this.toRegExpSource(); -+ // if we're in nocase mode, and not nocaseMagicOnly, then we do -+ // still need a regular expression if we have to case-insensitively -+ // match capital/lowercase characters. -+ const anyMagic = hasMagic || -+ this.#hasMagic || -+ (this.#options.nocase && -+ !this.#options.nocaseMagicOnly && -+ glob.toUpperCase() !== glob.toLowerCase()); -+ if (!anyMagic) { -+ return body; -+ } -+ const flags = (this.#options.nocase ? 'i' : '') + (uflag ? 'u' : ''); -+ return Object.assign(new RegExp(`^${re}$`, flags), { -+ _src: re, -+ _glob: glob, -+ }); -+ } -+ get options() { -+ return this.#options; -+ } -+ // returns the string match, the regexp source, whether there's magic -+ // in the regexp (so a regular expression is required) and whether or -+ // not the uflag is needed for the regular expression (for posix classes) -+ // TODO: instead of injecting the start/end at this point, just return -+ // the BODY of the regexp, along with the start/end portions suitable -+ // for binding the start/end in either a joined full-path makeRe context -+ // (where we bind to (^|/), or a standalone matchPart context (where -+ // we bind to ^, and not /). Otherwise slashes get duped! -+ // -+ // In part-matching mode, the start is: -+ // - if not isStart: nothing -+ // - if traversal possible, but not allowed: ^(?!\.\.?$) -+ // - if dots allowed or not possible: ^ -+ // - if dots possible and not allowed: ^(?!\.) -+ // end is: -+ // - if not isEnd(): nothing -+ // - else: $ -+ // -+ // In full-path matching mode, we put the slash at the START of the -+ // pattern, so start is: -+ // - if first pattern: same as part-matching mode -+ // - if not isStart(): nothing -+ // - if traversal possible, but not allowed: /(?!\.\.?(?:$|/)) -+ // - if dots allowed or not possible: / -+ // - if dots possible and not allowed: /(?!\.) -+ // end is: -+ // - if last pattern, same as part-matching mode -+ // - else nothing -+ // -+ // Always put the (?:$|/) on negated tails, though, because that has to be -+ // there to bind the end of the negated pattern portion, and it's easier to -+ // just stick it in now rather than try to inject it later in the middle of -+ // the pattern. -+ // -+ // We can just always return the same end, and leave it up to the caller -+ // to know whether it's going to be used joined or in parts. -+ // And, if the start is adjusted slightly, can do the same there: -+ // - if not isStart: nothing -+ // - if traversal possible, but not allowed: (?:/|^)(?!\.\.?$) -+ // - if dots allowed or not possible: (?:/|^) -+ // - if dots possible and not allowed: (?:/|^)(?!\.) -+ // -+ // But it's better to have a simpler binding without a conditional, for -+ // performance, so probably better to return both start options. -+ // -+ // Then the caller just ignores the end if it's not the first pattern, -+ // and the start always gets applied. -+ // -+ // But that's always going to be $ if it's the ending pattern, or nothing, -+ // so the caller can just attach $ at the end of the pattern when building. -+ // -+ // So the todo is: -+ // - better detect what kind of start is needed -+ // - return both flavors of starting pattern -+ // - attach $ at the end of the pattern when creating the actual RegExp -+ // -+ // Ah, but wait, no, that all only applies to the root when the first pattern -+ // is not an extglob. If the first pattern IS an extglob, then we need all -+ // that dot prevention biz to live in the extglob portions, because eg -+ // +(*|.x*) can match .xy but not .yx. -+ // -+ // So, return the two flavors if it's #root and the first child is not an -+ // AST, otherwise leave it to the child AST to handle it, and there, -+ // use the (?:^|/) style of start binding. -+ // -+ // Even simplified further: -+ // - Since the start for a join is eg /(?!\.) and the start for a part -+ // is ^(?!\.), we can just prepend (?!\.) to the pattern (either root -+ // or start or whatever) and prepend ^ or / at the Regexp construction. -+ toRegExpSource(allowDot) { -+ const dot = allowDot ?? !!this.#options.dot; -+ if (this.#root === this) -+ this.#fillNegs(); -+ if (!this.type) { -+ const noEmpty = this.isStart() && this.isEnd(); -+ const src = this.#parts -+ .map(p => { -+ const [re, _, hasMagic, uflag] = typeof p === 'string' -+ ? AST.#parseGlob(p, this.#hasMagic, noEmpty) -+ : p.toRegExpSource(allowDot); -+ this.#hasMagic = this.#hasMagic || hasMagic; -+ this.#uflag = this.#uflag || uflag; -+ return re; -+ }) -+ .join(''); -+ let start = ''; -+ if (this.isStart()) { -+ if (typeof this.#parts[0] === 'string') { -+ // this is the string that will match the start of the pattern, -+ // so we need to protect against dots and such. -+ // '.' and '..' cannot match unless the pattern is that exactly, -+ // even if it starts with . or dot:true is set. -+ const dotTravAllowed = this.#parts.length === 1 && justDots.has(this.#parts[0]); -+ if (!dotTravAllowed) { -+ const aps = addPatternStart; -+ // check if we have a possibility of matching . or .., -+ // and prevent that. -+ const needNoTrav = -+ // dots are allowed, and the pattern starts with [ or . -+ (dot && aps.has(src.charAt(0))) || -+ // the pattern starts with \., and then [ or . -+ (src.startsWith('\\.') && aps.has(src.charAt(2))) || -+ // the pattern starts with \.\., and then [ or . -+ (src.startsWith('\\.\\.') && aps.has(src.charAt(4))); -+ // no need to prevent dots if it can't match a dot, or if a -+ // sub-pattern will be preventing it anyway. -+ const needNoDot = !dot && !allowDot && aps.has(src.charAt(0)); -+ start = needNoTrav ? startNoTraversal : needNoDot ? startNoDot : ''; -+ } -+ } -+ } -+ // append the "end of path portion" pattern to negation tails -+ let end = ''; -+ if (this.isEnd() && -+ this.#root.#filledNegs && -+ this.#parent?.type === '!') { -+ end = '(?:$|\\/)'; -+ } -+ const final = start + src + end; -+ return [ -+ final, -+ (0, unescape_js_1.unescape)(src), -+ (this.#hasMagic = !!this.#hasMagic), -+ this.#uflag, -+ ]; -+ } -+ // We need to calculate the body *twice* if it's a repeat pattern -+ // at the start, once in nodot mode, then again in dot mode, so a -+ // pattern like *(?) can match 'x.y' -+ const repeated = this.type === '*' || this.type === '+'; -+ // some kind of extglob -+ const start = this.type === '!' ? '(?:(?!(?:' : '(?:'; -+ let body = this.#partsToRegExp(dot); -+ if (this.isStart() && this.isEnd() && !body && this.type !== '!') { -+ // invalid extglob, has to at least be *something* present, if it's -+ // the entire path portion. -+ const s = this.toString(); -+ this.#parts = [s]; -+ this.type = null; -+ this.#hasMagic = undefined; -+ return [s, (0, unescape_js_1.unescape)(this.toString()), false, false]; -+ } -+ // XXX abstract out this map method -+ let bodyDotAllowed = !repeated || allowDot || dot || !startNoDot -+ ? '' -+ : this.#partsToRegExp(true); -+ if (bodyDotAllowed === body) { -+ bodyDotAllowed = ''; -+ } -+ if (bodyDotAllowed) { -+ body = `(?:${body})(?:${bodyDotAllowed})*?`; -+ } -+ // an empty !() is exactly equivalent to a starNoEmpty -+ let final = ''; -+ if (this.type === '!' && this.#emptyExt) { -+ final = (this.isStart() && !dot ? startNoDot : '') + starNoEmpty; -+ } -+ else { -+ const close = this.type === '!' -+ ? // !() must match something,but !(x) can match '' -+ '))' + -+ (this.isStart() && !dot && !allowDot ? startNoDot : '') + -+ star + -+ ')' -+ : this.type === '@' -+ ? ')' -+ : this.type === '?' -+ ? ')?' -+ : this.type === '+' && bodyDotAllowed -+ ? ')' -+ : this.type === '*' && bodyDotAllowed -+ ? `)?` -+ : `)${this.type}`; -+ final = start + body + close; -+ } -+ return [ -+ final, -+ (0, unescape_js_1.unescape)(body), -+ (this.#hasMagic = !!this.#hasMagic), -+ this.#uflag, -+ ]; -+ } -+ #partsToRegExp(dot) { -+ return this.#parts -+ .map(p => { -+ // extglob ASTs should only contain parent ASTs -+ /* c8 ignore start */ -+ if (typeof p === 'string') { -+ throw new Error('string type in extglob ast??'); -+ } -+ /* c8 ignore stop */ -+ // can ignore hasMagic, because extglobs are already always magic -+ const [re, _, _hasMagic, uflag] = p.toRegExpSource(dot); -+ this.#uflag = this.#uflag || uflag; -+ return re; -+ }) -+ .filter(p => !(this.isStart() && this.isEnd()) || !!p) -+ .join('|'); -+ } -+ static #parseGlob(glob, hasMagic, noEmpty = false) { -+ let escaping = false; -+ let re = ''; -+ let uflag = false; -+ for (let i = 0; i < glob.length; i++) { -+ const c = glob.charAt(i); -+ if (escaping) { -+ escaping = false; -+ re += (reSpecials.has(c) ? '\\' : '') + c; -+ continue; -+ } -+ if (c === '\\') { -+ if (i === glob.length - 1) { -+ re += '\\\\'; -+ } -+ else { -+ escaping = true; -+ } -+ continue; -+ } -+ if (c === '[') { -+ const [src, needUflag, consumed, magic] = (0, brace_expressions_js_1.parseClass)(glob, i); -+ if (consumed) { -+ re += src; -+ uflag = uflag || needUflag; -+ i += consumed - 1; -+ hasMagic = hasMagic || magic; -+ continue; -+ } -+ } -+ if (c === '*') { -+ if (noEmpty && glob === '*') -+ re += starNoEmpty; -+ else -+ re += star; -+ hasMagic = true; -+ continue; -+ } -+ if (c === '?') { -+ re += qmark; -+ hasMagic = true; -+ continue; -+ } -+ re += regExpEscape(c); -+ } -+ return [re, (0, unescape_js_1.unescape)(glob), !!hasMagic, uflag]; -+ } -+} -+ast.AST = AST; -+ -+var _escape = {}; -+ -+Object.defineProperty(_escape, "__esModule", { value: true }); -+_escape.escape = void 0; -+/** -+ * Escape all magic characters in a glob pattern. -+ * -+ * If the {@link windowsPathsNoEscape | GlobOptions.windowsPathsNoEscape} -+ * option is used, then characters are escaped by wrapping in `[]`, because -+ * a magic character wrapped in a character class can only be satisfied by -+ * that exact character. In this mode, `\` is _not_ escaped, because it is -+ * not interpreted as a magic character, but instead as a path separator. -+ */ -+const escape = (s, { windowsPathsNoEscape = false, } = {}) => { -+ // don't need to escape +@! because we escape the parens -+ // that make those magic, and escaping ! as [!] isn't valid, -+ // because [!]] is a valid glob class meaning not ']'. -+ return windowsPathsNoEscape -+ ? s.replace(/[?*()[\]]/g, '[$&]') -+ : s.replace(/[?*()[\]\\]/g, '\\$&'); -+}; -+_escape.escape = escape; -+ -+(function (exports) { -+ var __importDefault = (commonjsGlobal && commonjsGlobal.__importDefault) || function (mod) { -+ return (mod && mod.__esModule) ? mod : { "default": mod }; -+ }; -+ Object.defineProperty(exports, "__esModule", { value: true }); -+ exports.unescape = exports.escape = exports.AST = exports.Minimatch = exports.match = exports.makeRe = exports.braceExpand = exports.defaults = exports.filter = exports.GLOBSTAR = exports.sep = exports.minimatch = void 0; -+ const brace_expansion_1 = __importDefault(braceExpansion); -+ const assert_valid_pattern_js_1 = assertValidPattern$1; -+ const ast_js_1 = ast; -+ const escape_js_1 = _escape; -+ const unescape_js_1 = _unescape; -+ const minimatch = (p, pattern, options = {}) => { -+ (0, assert_valid_pattern_js_1.assertValidPattern)(pattern); -+ // shortcut: comments match nothing. -+ if (!options.nocomment && pattern.charAt(0) === '#') { -+ return false; -+ } -+ return new Minimatch(pattern, options).match(p); -+ }; -+ exports.minimatch = minimatch; -+ // Optimized checking for the most common glob patterns. -+ const starDotExtRE = /^\*+([^+@!?\*\[\(]*)$/; -+ const starDotExtTest = (ext) => (f) => !f.startsWith('.') && f.endsWith(ext); -+ const starDotExtTestDot = (ext) => (f) => f.endsWith(ext); -+ const starDotExtTestNocase = (ext) => { -+ ext = ext.toLowerCase(); -+ return (f) => !f.startsWith('.') && f.toLowerCase().endsWith(ext); -+ }; -+ const starDotExtTestNocaseDot = (ext) => { -+ ext = ext.toLowerCase(); -+ return (f) => f.toLowerCase().endsWith(ext); -+ }; -+ const starDotStarRE = /^\*+\.\*+$/; -+ const starDotStarTest = (f) => !f.startsWith('.') && f.includes('.'); -+ const starDotStarTestDot = (f) => f !== '.' && f !== '..' && f.includes('.'); -+ const dotStarRE = /^\.\*+$/; -+ const dotStarTest = (f) => f !== '.' && f !== '..' && f.startsWith('.'); -+ const starRE = /^\*+$/; -+ const starTest = (f) => f.length !== 0 && !f.startsWith('.'); -+ const starTestDot = (f) => f.length !== 0 && f !== '.' && f !== '..'; -+ const qmarksRE = /^\?+([^+@!?\*\[\(]*)?$/; -+ const qmarksTestNocase = ([$0, ext = '']) => { -+ const noext = qmarksTestNoExt([$0]); -+ if (!ext) -+ return noext; -+ ext = ext.toLowerCase(); -+ return (f) => noext(f) && f.toLowerCase().endsWith(ext); -+ }; -+ const qmarksTestNocaseDot = ([$0, ext = '']) => { -+ const noext = qmarksTestNoExtDot([$0]); -+ if (!ext) -+ return noext; -+ ext = ext.toLowerCase(); -+ return (f) => noext(f) && f.toLowerCase().endsWith(ext); -+ }; -+ const qmarksTestDot = ([$0, ext = '']) => { -+ const noext = qmarksTestNoExtDot([$0]); -+ return !ext ? noext : (f) => noext(f) && f.endsWith(ext); -+ }; -+ const qmarksTest = ([$0, ext = '']) => { -+ const noext = qmarksTestNoExt([$0]); -+ return !ext ? noext : (f) => noext(f) && f.endsWith(ext); -+ }; -+ const qmarksTestNoExt = ([$0]) => { -+ const len = $0.length; -+ return (f) => f.length === len && !f.startsWith('.'); -+ }; -+ const qmarksTestNoExtDot = ([$0]) => { -+ const len = $0.length; -+ return (f) => f.length === len && f !== '.' && f !== '..'; -+ }; -+ /* c8 ignore start */ -+ const defaultPlatform = (typeof process === 'object' && process -+ ? (typeof process.env === 'object' && -+ process.env && -+ process.env.__MINIMATCH_TESTING_PLATFORM__) || -+ process.platform -+ : 'posix'); -+ const path = { -+ win32: { sep: '\\' }, -+ posix: { sep: '/' }, -+ }; -+ /* c8 ignore stop */ -+ exports.sep = defaultPlatform === 'win32' ? path.win32.sep : path.posix.sep; -+ exports.minimatch.sep = exports.sep; -+ exports.GLOBSTAR = Symbol('globstar **'); -+ exports.minimatch.GLOBSTAR = exports.GLOBSTAR; -+ // any single thing other than / -+ // don't need to escape / when using new RegExp() -+ const qmark = '[^/]'; -+ // * => any number of characters -+ const star = qmark + '*?'; -+ // ** when dots are allowed. Anything goes, except .. and . -+ // not (^ or / followed by one or two dots followed by $ or /), -+ // followed by anything, any number of times. -+ const twoStarDot = '(?:(?!(?:\\/|^)(?:\\.{1,2})($|\\/)).)*?'; -+ // not a ^ or / followed by a dot, -+ // followed by anything, any number of times. -+ const twoStarNoDot = '(?:(?!(?:\\/|^)\\.).)*?'; -+ const filter = (pattern, options = {}) => (p) => (0, exports.minimatch)(p, pattern, options); -+ exports.filter = filter; -+ exports.minimatch.filter = exports.filter; -+ const ext = (a, b = {}) => Object.assign({}, a, b); -+ const defaults = (def) => { -+ if (!def || typeof def !== 'object' || !Object.keys(def).length) { -+ return exports.minimatch; -+ } -+ const orig = exports.minimatch; -+ const m = (p, pattern, options = {}) => orig(p, pattern, ext(def, options)); -+ return Object.assign(m, { -+ Minimatch: class Minimatch extends orig.Minimatch { -+ constructor(pattern, options = {}) { -+ super(pattern, ext(def, options)); -+ } -+ static defaults(options) { -+ return orig.defaults(ext(def, options)).Minimatch; -+ } -+ }, -+ AST: class AST extends orig.AST { -+ /* c8 ignore start */ -+ constructor(type, parent, options = {}) { -+ super(type, parent, ext(def, options)); -+ } -+ /* c8 ignore stop */ -+ static fromGlob(pattern, options = {}) { -+ return orig.AST.fromGlob(pattern, ext(def, options)); -+ } -+ }, -+ unescape: (s, options = {}) => orig.unescape(s, ext(def, options)), -+ escape: (s, options = {}) => orig.escape(s, ext(def, options)), -+ filter: (pattern, options = {}) => orig.filter(pattern, ext(def, options)), -+ defaults: (options) => orig.defaults(ext(def, options)), -+ makeRe: (pattern, options = {}) => orig.makeRe(pattern, ext(def, options)), -+ braceExpand: (pattern, options = {}) => orig.braceExpand(pattern, ext(def, options)), -+ match: (list, pattern, options = {}) => orig.match(list, pattern, ext(def, options)), -+ sep: orig.sep, -+ GLOBSTAR: exports.GLOBSTAR, -+ }); -+ }; -+ exports.defaults = defaults; -+ exports.minimatch.defaults = exports.defaults; -+ // Brace expansion: -+ // a{b,c}d -> abd acd -+ // a{b,}c -> abc ac -+ // a{0..3}d -> a0d a1d a2d a3d -+ // a{b,c{d,e}f}g -> abg acdfg acefg -+ // a{b,c}d{e,f}g -> abdeg acdeg abdeg abdfg -+ // -+ // Invalid sets are not expanded. -+ // a{2..}b -> a{2..}b -+ // a{b}c -> a{b}c -+ const braceExpand = (pattern, options = {}) => { -+ (0, assert_valid_pattern_js_1.assertValidPattern)(pattern); -+ // Thanks to Yeting Li for -+ // improving this regexp to avoid a ReDOS vulnerability. -+ if (options.nobrace || !/\{(?:(?!\{).)*\}/.test(pattern)) { -+ // shortcut. no need to expand. -+ return [pattern]; -+ } -+ return (0, brace_expansion_1.default)(pattern); -+ }; -+ exports.braceExpand = braceExpand; -+ exports.minimatch.braceExpand = exports.braceExpand; -+ // parse a component of the expanded set. -+ // At this point, no pattern may contain "/" in it -+ // so we're going to return a 2d array, where each entry is the full -+ // pattern, split on '/', and then turned into a regular expression. -+ // A regexp is made at the end which joins each array with an -+ // escaped /, and another full one which joins each regexp with |. -+ // -+ // Following the lead of Bash 4.1, note that "**" only has special meaning -+ // when it is the *only* thing in a path portion. Otherwise, any series -+ // of * is equivalent to a single *. Globstar behavior is enabled by -+ // default, and can be disabled by setting options.noglobstar. -+ const makeRe = (pattern, options = {}) => new Minimatch(pattern, options).makeRe(); -+ exports.makeRe = makeRe; -+ exports.minimatch.makeRe = exports.makeRe; -+ const match = (list, pattern, options = {}) => { -+ const mm = new Minimatch(pattern, options); -+ list = list.filter(f => mm.match(f)); -+ if (mm.options.nonull && !list.length) { -+ list.push(pattern); -+ } -+ return list; -+ }; -+ exports.match = match; -+ exports.minimatch.match = exports.match; -+ // replace stuff like \* with * -+ const globMagic = /[?*]|[+@!]\(.*?\)|\[|\]/; -+ const regExpEscape = (s) => s.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); -+ class Minimatch { -+ options; -+ set; -+ pattern; -+ windowsPathsNoEscape; -+ nonegate; -+ negate; -+ comment; -+ empty; -+ preserveMultipleSlashes; -+ partial; -+ globSet; -+ globParts; -+ nocase; -+ isWindows; -+ platform; -+ windowsNoMagicRoot; -+ regexp; -+ constructor(pattern, options = {}) { -+ (0, assert_valid_pattern_js_1.assertValidPattern)(pattern); -+ options = options || {}; -+ this.options = options; -+ this.pattern = pattern; -+ this.platform = options.platform || defaultPlatform; -+ this.isWindows = this.platform === 'win32'; -+ this.windowsPathsNoEscape = -+ !!options.windowsPathsNoEscape || options.allowWindowsEscape === false; -+ if (this.windowsPathsNoEscape) { -+ this.pattern = this.pattern.replace(/\\/g, '/'); -+ } -+ this.preserveMultipleSlashes = !!options.preserveMultipleSlashes; -+ this.regexp = null; -+ this.negate = false; -+ this.nonegate = !!options.nonegate; -+ this.comment = false; -+ this.empty = false; -+ this.partial = !!options.partial; -+ this.nocase = !!this.options.nocase; -+ this.windowsNoMagicRoot = -+ options.windowsNoMagicRoot !== undefined -+ ? options.windowsNoMagicRoot -+ : !!(this.isWindows && this.nocase); -+ this.globSet = []; -+ this.globParts = []; -+ this.set = []; -+ // make the set of regexps etc. -+ this.make(); -+ } -+ hasMagic() { -+ if (this.options.magicalBraces && this.set.length > 1) { -+ return true; -+ } -+ for (const pattern of this.set) { -+ for (const part of pattern) { -+ if (typeof part !== 'string') -+ return true; -+ } -+ } -+ return false; -+ } -+ debug(..._) { } -+ make() { -+ const pattern = this.pattern; -+ const options = this.options; -+ // empty patterns and comments match nothing. -+ if (!options.nocomment && pattern.charAt(0) === '#') { -+ this.comment = true; -+ return; -+ } -+ if (!pattern) { -+ this.empty = true; -+ return; -+ } -+ // step 1: figure out negation, etc. -+ this.parseNegate(); -+ // step 2: expand braces -+ this.globSet = [...new Set(this.braceExpand())]; -+ if (options.debug) { -+ this.debug = (...args) => console.error(...args); -+ } -+ this.debug(this.pattern, this.globSet); -+ // step 3: now we have a set, so turn each one into a series of -+ // path-portion matching patterns. -+ // These will be regexps, except in the case of "**", which is -+ // set to the GLOBSTAR object for globstar behavior, -+ // and will not contain any / characters -+ // -+ // First, we preprocess to make the glob pattern sets a bit simpler -+ // and deduped. There are some perf-killing patterns that can cause -+ // problems with a glob walk, but we can simplify them down a bit. -+ const rawGlobParts = this.globSet.map(s => this.slashSplit(s)); -+ this.globParts = this.preprocess(rawGlobParts); -+ this.debug(this.pattern, this.globParts); -+ // glob --> regexps -+ let set = this.globParts.map((s, _, __) => { -+ if (this.isWindows && this.windowsNoMagicRoot) { -+ // check if it's a drive or unc path. -+ const isUNC = s[0] === '' && -+ s[1] === '' && -+ (s[2] === '?' || !globMagic.test(s[2])) && -+ !globMagic.test(s[3]); -+ const isDrive = /^[a-z]:/i.test(s[0]); -+ if (isUNC) { -+ return [...s.slice(0, 4), ...s.slice(4).map(ss => this.parse(ss))]; -+ } -+ else if (isDrive) { -+ return [s[0], ...s.slice(1).map(ss => this.parse(ss))]; -+ } -+ } -+ return s.map(ss => this.parse(ss)); -+ }); -+ this.debug(this.pattern, set); -+ // filter out everything that didn't compile properly. -+ this.set = set.filter(s => s.indexOf(false) === -1); -+ // do not treat the ? in UNC paths as magic -+ if (this.isWindows) { -+ for (let i = 0; i < this.set.length; i++) { -+ const p = this.set[i]; -+ if (p[0] === '' && -+ p[1] === '' && -+ this.globParts[i][2] === '?' && -+ typeof p[3] === 'string' && -+ /^[a-z]:$/i.test(p[3])) { -+ p[2] = '?'; -+ } -+ } -+ } -+ this.debug(this.pattern, this.set); -+ } -+ // various transforms to equivalent pattern sets that are -+ // faster to process in a filesystem walk. The goal is to -+ // eliminate what we can, and push all ** patterns as far -+ // to the right as possible, even if it increases the number -+ // of patterns that we have to process. -+ preprocess(globParts) { -+ // if we're not in globstar mode, then turn all ** into * -+ if (this.options.noglobstar) { -+ for (let i = 0; i < globParts.length; i++) { -+ for (let j = 0; j < globParts[i].length; j++) { -+ if (globParts[i][j] === '**') { -+ globParts[i][j] = '*'; -+ } -+ } -+ } -+ } -+ const { optimizationLevel = 1 } = this.options; -+ if (optimizationLevel >= 2) { -+ // aggressive optimization for the purpose of fs walking -+ globParts = this.firstPhasePreProcess(globParts); -+ globParts = this.secondPhasePreProcess(globParts); -+ } -+ else if (optimizationLevel >= 1) { -+ // just basic optimizations to remove some .. parts -+ globParts = this.levelOneOptimize(globParts); -+ } -+ else { -+ // just collapse multiple ** portions into one -+ globParts = this.adjascentGlobstarOptimize(globParts); -+ } -+ return globParts; -+ } -+ // just get rid of adjascent ** portions -+ adjascentGlobstarOptimize(globParts) { -+ return globParts.map(parts => { -+ let gs = -1; -+ while (-1 !== (gs = parts.indexOf('**', gs + 1))) { -+ let i = gs; -+ while (parts[i + 1] === '**') { -+ i++; -+ } -+ if (i !== gs) { -+ parts.splice(gs, i - gs); -+ } -+ } -+ return parts; -+ }); -+ } -+ // get rid of adjascent ** and resolve .. portions -+ levelOneOptimize(globParts) { -+ return globParts.map(parts => { -+ parts = parts.reduce((set, part) => { -+ const prev = set[set.length - 1]; -+ if (part === '**' && prev === '**') { -+ return set; -+ } -+ if (part === '..') { -+ if (prev && prev !== '..' && prev !== '.' && prev !== '**') { -+ set.pop(); -+ return set; -+ } -+ } -+ set.push(part); -+ return set; -+ }, []); -+ return parts.length === 0 ? [''] : parts; -+ }); -+ } -+ levelTwoFileOptimize(parts) { -+ if (!Array.isArray(parts)) { -+ parts = this.slashSplit(parts); -+ } -+ let didSomething = false; -+ do { -+ didSomething = false; -+ //

// -> 
/
-+	            if (!this.preserveMultipleSlashes) {
-+	                for (let i = 1; i < parts.length - 1; i++) {
-+	                    const p = parts[i];
-+	                    // don't squeeze out UNC patterns
-+	                    if (i === 1 && p === '' && parts[0] === '')
-+	                        continue;
-+	                    if (p === '.' || p === '') {
-+	                        didSomething = true;
-+	                        parts.splice(i, 1);
-+	                        i--;
-+	                    }
-+	                }
-+	                if (parts[0] === '.' &&
-+	                    parts.length === 2 &&
-+	                    (parts[1] === '.' || parts[1] === '')) {
-+	                    didSomething = true;
-+	                    parts.pop();
-+	                }
-+	            }
-+	            // 
/

/../ ->

/
-+	            let dd = 0;
-+	            while (-1 !== (dd = parts.indexOf('..', dd + 1))) {
-+	                const p = parts[dd - 1];
-+	                if (p && p !== '.' && p !== '..' && p !== '**') {
-+	                    didSomething = true;
-+	                    parts.splice(dd - 1, 2);
-+	                    dd -= 2;
-+	                }
-+	            }
-+	        } while (didSomething);
-+	        return parts.length === 0 ? [''] : parts;
-+	    }
-+	    // First phase: single-pattern processing
-+	    // 
 is 1 or more portions
-+	    //  is 1 or more portions
-+	    // 

is any portion other than ., .., '', or ** -+ // is . or '' -+ // -+ // **/.. is *brutal* for filesystem walking performance, because -+ // it effectively resets the recursive walk each time it occurs, -+ // and ** cannot be reduced out by a .. pattern part like a regexp -+ // or most strings (other than .., ., and '') can be. -+ // -+ //

/**/../

/

/ -> {

/../

/

/,

/**/

/

/} -+ //

// -> 
/
-+	    // 
/

/../ ->

/
-+	    // **/**/ -> **/
-+	    //
-+	    // **/*/ -> */**/ <== not valid because ** doesn't follow
-+	    // this WOULD be allowed if ** did follow symlinks, or * didn't
-+	    firstPhasePreProcess(globParts) {
-+	        let didSomething = false;
-+	        do {
-+	            didSomething = false;
-+	            // 
/**/../

/

/ -> {

/../

/

/,

/**/

/

/} -+ for (let parts of globParts) { -+ let gs = -1; -+ while (-1 !== (gs = parts.indexOf('**', gs + 1))) { -+ let gss = gs; -+ while (parts[gss + 1] === '**') { -+ //

/**/**/ -> 
/**/
-+	                        gss++;
-+	                    }
-+	                    // eg, if gs is 2 and gss is 4, that means we have 3 **
-+	                    // parts, and can remove 2 of them.
-+	                    if (gss > gs) {
-+	                        parts.splice(gs + 1, gss - gs);
-+	                    }
-+	                    let next = parts[gs + 1];
-+	                    const p = parts[gs + 2];
-+	                    const p2 = parts[gs + 3];
-+	                    if (next !== '..')
-+	                        continue;
-+	                    if (!p ||
-+	                        p === '.' ||
-+	                        p === '..' ||
-+	                        !p2 ||
-+	                        p2 === '.' ||
-+	                        p2 === '..') {
-+	                        continue;
-+	                    }
-+	                    didSomething = true;
-+	                    // edit parts in place, and push the new one
-+	                    parts.splice(gs, 1);
-+	                    const other = parts.slice(0);
-+	                    other[gs] = '**';
-+	                    globParts.push(other);
-+	                    gs--;
-+	                }
-+	                // 
// -> 
/
-+	                if (!this.preserveMultipleSlashes) {
-+	                    for (let i = 1; i < parts.length - 1; i++) {
-+	                        const p = parts[i];
-+	                        // don't squeeze out UNC patterns
-+	                        if (i === 1 && p === '' && parts[0] === '')
-+	                            continue;
-+	                        if (p === '.' || p === '') {
-+	                            didSomething = true;
-+	                            parts.splice(i, 1);
-+	                            i--;
-+	                        }
-+	                    }
-+	                    if (parts[0] === '.' &&
-+	                        parts.length === 2 &&
-+	                        (parts[1] === '.' || parts[1] === '')) {
-+	                        didSomething = true;
-+	                        parts.pop();
-+	                    }
-+	                }
-+	                // 
/

/../ ->

/
-+	                let dd = 0;
-+	                while (-1 !== (dd = parts.indexOf('..', dd + 1))) {
-+	                    const p = parts[dd - 1];
-+	                    if (p && p !== '.' && p !== '..' && p !== '**') {
-+	                        didSomething = true;
-+	                        const needDot = dd === 1 && parts[dd + 1] === '**';
-+	                        const splin = needDot ? ['.'] : [];
-+	                        parts.splice(dd - 1, 2, ...splin);
-+	                        if (parts.length === 0)
-+	                            parts.push('');
-+	                        dd -= 2;
-+	                    }
-+	                }
-+	            }
-+	        } while (didSomething);
-+	        return globParts;
-+	    }
-+	    // second phase: multi-pattern dedupes
-+	    // {
/*/,
/

/} ->

/*/
-+	    // {
/,
/} -> 
/
-+	    // {
/**/,
/} -> 
/**/
-+	    //
-+	    // {
/**/,
/**/

/} ->

/**/
-+	    // ^-- not valid because ** doens't follow symlinks
-+	    secondPhasePreProcess(globParts) {
-+	        for (let i = 0; i < globParts.length - 1; i++) {
-+	            for (let j = i + 1; j < globParts.length; j++) {
-+	                const matched = this.partsMatch(globParts[i], globParts[j], !this.preserveMultipleSlashes);
-+	                if (matched) {
-+	                    globParts[i] = [];
-+	                    globParts[j] = matched;
-+	                    break;
-+	                }
-+	            }
-+	        }
-+	        return globParts.filter(gs => gs.length);
-+	    }
-+	    partsMatch(a, b, emptyGSMatch = false) {
-+	        let ai = 0;
-+	        let bi = 0;
-+	        let result = [];
-+	        let which = '';
-+	        while (ai < a.length && bi < b.length) {
-+	            if (a[ai] === b[bi]) {
-+	                result.push(which === 'b' ? b[bi] : a[ai]);
-+	                ai++;
-+	                bi++;
-+	            }
-+	            else if (emptyGSMatch && a[ai] === '**' && b[bi] === a[ai + 1]) {
-+	                result.push(a[ai]);
-+	                ai++;
-+	            }
-+	            else if (emptyGSMatch && b[bi] === '**' && a[ai] === b[bi + 1]) {
-+	                result.push(b[bi]);
-+	                bi++;
-+	            }
-+	            else if (a[ai] === '*' &&
-+	                b[bi] &&
-+	                (this.options.dot || !b[bi].startsWith('.')) &&
-+	                b[bi] !== '**') {
-+	                if (which === 'b')
-+	                    return false;
-+	                which = 'a';
-+	                result.push(a[ai]);
-+	                ai++;
-+	                bi++;
-+	            }
-+	            else if (b[bi] === '*' &&
-+	                a[ai] &&
-+	                (this.options.dot || !a[ai].startsWith('.')) &&
-+	                a[ai] !== '**') {
-+	                if (which === 'a')
-+	                    return false;
-+	                which = 'b';
-+	                result.push(b[bi]);
-+	                ai++;
-+	                bi++;
-+	            }
-+	            else {
-+	                return false;
-+	            }
-+	        }
-+	        // if we fall out of the loop, it means they two are identical
-+	        // as long as their lengths match
-+	        return a.length === b.length && result;
-+	    }
-+	    parseNegate() {
-+	        if (this.nonegate)
-+	            return;
-+	        const pattern = this.pattern;
-+	        let negate = false;
-+	        let negateOffset = 0;
-+	        for (let i = 0; i < pattern.length && pattern.charAt(i) === '!'; i++) {
-+	            negate = !negate;
-+	            negateOffset++;
-+	        }
-+	        if (negateOffset)
-+	            this.pattern = pattern.slice(negateOffset);
-+	        this.negate = negate;
-+	    }
-+	    // set partial to true to test if, for example,
-+	    // "/a/b" matches the start of "/*/b/*/d"
-+	    // Partial means, if you run out of file before you run
-+	    // out of pattern, then that's fine, as long as all
-+	    // the parts match.
-+	    matchOne(file, pattern, partial = false) {
-+	        const options = this.options;
-+	        // UNC paths like //?/X:/... can match X:/... and vice versa
-+	        // Drive letters in absolute drive or unc paths are always compared
-+	        // case-insensitively.
-+	        if (this.isWindows) {
-+	            const fileDrive = typeof file[0] === 'string' && /^[a-z]:$/i.test(file[0]);
-+	            const fileUNC = !fileDrive &&
-+	                file[0] === '' &&
-+	                file[1] === '' &&
-+	                file[2] === '?' &&
-+	                /^[a-z]:$/i.test(file[3]);
-+	            const patternDrive = typeof pattern[0] === 'string' && /^[a-z]:$/i.test(pattern[0]);
-+	            const patternUNC = !patternDrive &&
-+	                pattern[0] === '' &&
-+	                pattern[1] === '' &&
-+	                pattern[2] === '?' &&
-+	                typeof pattern[3] === 'string' &&
-+	                /^[a-z]:$/i.test(pattern[3]);
-+	            const fdi = fileUNC ? 3 : fileDrive ? 0 : undefined;
-+	            const pdi = patternUNC ? 3 : patternDrive ? 0 : undefined;
-+	            if (typeof fdi === 'number' && typeof pdi === 'number') {
-+	                const [fd, pd] = [file[fdi], pattern[pdi]];
-+	                if (fd.toLowerCase() === pd.toLowerCase()) {
-+	                    pattern[pdi] = fd;
-+	                    if (pdi > fdi) {
-+	                        pattern = pattern.slice(pdi);
-+	                    }
-+	                    else if (fdi > pdi) {
-+	                        file = file.slice(fdi);
-+	                    }
-+	                }
-+	            }
-+	        }
-+	        // resolve and reduce . and .. portions in the file as well.
-+	        // dont' need to do the second phase, because it's only one string[]
-+	        const { optimizationLevel = 1 } = this.options;
-+	        if (optimizationLevel >= 2) {
-+	            file = this.levelTwoFileOptimize(file);
-+	        }
-+	        this.debug('matchOne', this, { file, pattern });
-+	        this.debug('matchOne', file.length, pattern.length);
-+	        for (var fi = 0, pi = 0, fl = file.length, pl = pattern.length; fi < fl && pi < pl; fi++, pi++) {
-+	            this.debug('matchOne loop');
-+	            var p = pattern[pi];
-+	            var f = file[fi];
-+	            this.debug(pattern, p, f);
-+	            // should be impossible.
-+	            // some invalid regexp stuff in the set.
-+	            /* c8 ignore start */
-+	            if (p === false) {
-+	                return false;
-+	            }
-+	            /* c8 ignore stop */
-+	            if (p === exports.GLOBSTAR) {
-+	                this.debug('GLOBSTAR', [pattern, p, f]);
-+	                // "**"
-+	                // a/**/b/**/c would match the following:
-+	                // a/b/x/y/z/c
-+	                // a/x/y/z/b/c
-+	                // a/b/x/b/x/c
-+	                // a/b/c
-+	                // To do this, take the rest of the pattern after
-+	                // the **, and see if it would match the file remainder.
-+	                // If so, return success.
-+	                // If not, the ** "swallows" a segment, and try again.
-+	                // This is recursively awful.
-+	                //
-+	                // a/**/b/**/c matching a/b/x/y/z/c
-+	                // - a matches a
-+	                // - doublestar
-+	                //   - matchOne(b/x/y/z/c, b/**/c)
-+	                //     - b matches b
-+	                //     - doublestar
-+	                //       - matchOne(x/y/z/c, c) -> no
-+	                //       - matchOne(y/z/c, c) -> no
-+	                //       - matchOne(z/c, c) -> no
-+	                //       - matchOne(c, c) yes, hit
-+	                var fr = fi;
-+	                var pr = pi + 1;
-+	                if (pr === pl) {
-+	                    this.debug('** at the end');
-+	                    // a ** at the end will just swallow the rest.
-+	                    // We have found a match.
-+	                    // however, it will not swallow /.x, unless
-+	                    // options.dot is set.
-+	                    // . and .. are *never* matched by **, for explosively
-+	                    // exponential reasons.
-+	                    for (; fi < fl; fi++) {
-+	                        if (file[fi] === '.' ||
-+	                            file[fi] === '..' ||
-+	                            (!options.dot && file[fi].charAt(0) === '.'))
-+	                            return false;
-+	                    }
-+	                    return true;
-+	                }
-+	                // ok, let's see if we can swallow whatever we can.
-+	                while (fr < fl) {
-+	                    var swallowee = file[fr];
-+	                    this.debug('\nglobstar while', file, fr, pattern, pr, swallowee);
-+	                    // XXX remove this slice.  Just pass the start index.
-+	                    if (this.matchOne(file.slice(fr), pattern.slice(pr), partial)) {
-+	                        this.debug('globstar found match!', fr, fl, swallowee);
-+	                        // found a match.
-+	                        return true;
-+	                    }
-+	                    else {
-+	                        // can't swallow "." or ".." ever.
-+	                        // can only swallow ".foo" when explicitly asked.
-+	                        if (swallowee === '.' ||
-+	                            swallowee === '..' ||
-+	                            (!options.dot && swallowee.charAt(0) === '.')) {
-+	                            this.debug('dot detected!', file, fr, pattern, pr);
-+	                            break;
-+	                        }
-+	                        // ** swallows a segment, and continue.
-+	                        this.debug('globstar swallow a segment, and continue');
-+	                        fr++;
-+	                    }
-+	                }
-+	                // no match was found.
-+	                // However, in partial mode, we can't say this is necessarily over.
-+	                /* c8 ignore start */
-+	                if (partial) {
-+	                    // ran out of file
-+	                    this.debug('\n>>> no match, partial?', file, fr, pattern, pr);
-+	                    if (fr === fl) {
-+	                        return true;
-+	                    }
-+	                }
-+	                /* c8 ignore stop */
-+	                return false;
-+	            }
-+	            // something other than **
-+	            // non-magic patterns just have to match exactly
-+	            // patterns with magic have been turned into regexps.
-+	            let hit;
-+	            if (typeof p === 'string') {
-+	                hit = f === p;
-+	                this.debug('string match', p, f, hit);
-+	            }
-+	            else {
-+	                hit = p.test(f);
-+	                this.debug('pattern match', p, f, hit);
-+	            }
-+	            if (!hit)
-+	                return false;
-+	        }
-+	        // Note: ending in / means that we'll get a final ""
-+	        // at the end of the pattern.  This can only match a
-+	        // corresponding "" at the end of the file.
-+	        // If the file ends in /, then it can only match a
-+	        // a pattern that ends in /, unless the pattern just
-+	        // doesn't have any more for it. But, a/b/ should *not*
-+	        // match "a/b/*", even though "" matches against the
-+	        // [^/]*? pattern, except in partial mode, where it might
-+	        // simply not be reached yet.
-+	        // However, a/b/ should still satisfy a/*
-+	        // now either we fell off the end of the pattern, or we're done.
-+	        if (fi === fl && pi === pl) {
-+	            // ran out of pattern and filename at the same time.
-+	            // an exact hit!
-+	            return true;
-+	        }
-+	        else if (fi === fl) {
-+	            // ran out of file, but still had pattern left.
-+	            // this is ok if we're doing the match as part of
-+	            // a glob fs traversal.
-+	            return partial;
-+	        }
-+	        else if (pi === pl) {
-+	            // ran out of pattern, still have file left.
-+	            // this is only acceptable if we're on the very last
-+	            // empty segment of a file with a trailing slash.
-+	            // a/* should match a/b/
-+	            return fi === fl - 1 && file[fi] === '';
-+	            /* c8 ignore start */
-+	        }
-+	        else {
-+	            // should be unreachable.
-+	            throw new Error('wtf?');
-+	        }
-+	        /* c8 ignore stop */
-+	    }
-+	    braceExpand() {
-+	        return (0, exports.braceExpand)(this.pattern, this.options);
-+	    }
-+	    parse(pattern) {
-+	        (0, assert_valid_pattern_js_1.assertValidPattern)(pattern);
-+	        const options = this.options;
-+	        // shortcuts
-+	        if (pattern === '**')
-+	            return exports.GLOBSTAR;
-+	        if (pattern === '')
-+	            return '';
-+	        // far and away, the most common glob pattern parts are
-+	        // *, *.*, and *.  Add a fast check method for those.
-+	        let m;
-+	        let fastTest = null;
-+	        if ((m = pattern.match(starRE))) {
-+	            fastTest = options.dot ? starTestDot : starTest;
-+	        }
-+	        else if ((m = pattern.match(starDotExtRE))) {
-+	            fastTest = (options.nocase
-+	                ? options.dot
-+	                    ? starDotExtTestNocaseDot
-+	                    : starDotExtTestNocase
-+	                : options.dot
-+	                    ? starDotExtTestDot
-+	                    : starDotExtTest)(m[1]);
-+	        }
-+	        else if ((m = pattern.match(qmarksRE))) {
-+	            fastTest = (options.nocase
-+	                ? options.dot
-+	                    ? qmarksTestNocaseDot
-+	                    : qmarksTestNocase
-+	                : options.dot
-+	                    ? qmarksTestDot
-+	                    : qmarksTest)(m);
-+	        }
-+	        else if ((m = pattern.match(starDotStarRE))) {
-+	            fastTest = options.dot ? starDotStarTestDot : starDotStarTest;
-+	        }
-+	        else if ((m = pattern.match(dotStarRE))) {
-+	            fastTest = dotStarTest;
-+	        }
-+	        const re = ast_js_1.AST.fromGlob(pattern, this.options).toMMPattern();
-+	        if (fastTest && typeof re === 'object') {
-+	            // Avoids overriding in frozen environments
-+	            Reflect.defineProperty(re, 'test', { value: fastTest });
-+	        }
-+	        return re;
-+	    }
-+	    makeRe() {
-+	        if (this.regexp || this.regexp === false)
-+	            return this.regexp;
-+	        // at this point, this.set is a 2d array of partial
-+	        // pattern strings, or "**".
-+	        //
-+	        // It's better to use .match().  This function shouldn't
-+	        // be used, really, but it's pretty convenient sometimes,
-+	        // when you just want to work with a regex.
-+	        const set = this.set;
-+	        if (!set.length) {
-+	            this.regexp = false;
-+	            return this.regexp;
-+	        }
-+	        const options = this.options;
-+	        const twoStar = options.noglobstar
-+	            ? star
-+	            : options.dot
-+	                ? twoStarDot
-+	                : twoStarNoDot;
-+	        const flags = new Set(options.nocase ? ['i'] : []);
-+	        // regexpify non-globstar patterns
-+	        // if ** is only item, then we just do one twoStar
-+	        // if ** is first, and there are more, prepend (\/|twoStar\/)? to next
-+	        // if ** is last, append (\/twoStar|) to previous
-+	        // if ** is in the middle, append (\/|\/twoStar\/) to previous
-+	        // then filter out GLOBSTAR symbols
-+	        let re = set
-+	            .map(pattern => {
-+	            const pp = pattern.map(p => {
-+	                if (p instanceof RegExp) {
-+	                    for (const f of p.flags.split(''))
-+	                        flags.add(f);
-+	                }
-+	                return typeof p === 'string'
-+	                    ? regExpEscape(p)
-+	                    : p === exports.GLOBSTAR
-+	                        ? exports.GLOBSTAR
-+	                        : p._src;
-+	            });
-+	            pp.forEach((p, i) => {
-+	                const next = pp[i + 1];
-+	                const prev = pp[i - 1];
-+	                if (p !== exports.GLOBSTAR || prev === exports.GLOBSTAR) {
-+	                    return;
-+	                }
-+	                if (prev === undefined) {
-+	                    if (next !== undefined && next !== exports.GLOBSTAR) {
-+	                        pp[i + 1] = '(?:\\/|' + twoStar + '\\/)?' + next;
-+	                    }
-+	                    else {
-+	                        pp[i] = twoStar;
-+	                    }
-+	                }
-+	                else if (next === undefined) {
-+	                    pp[i - 1] = prev + '(?:\\/|' + twoStar + ')?';
-+	                }
-+	                else if (next !== exports.GLOBSTAR) {
-+	                    pp[i - 1] = prev + '(?:\\/|\\/' + twoStar + '\\/)' + next;
-+	                    pp[i + 1] = exports.GLOBSTAR;
-+	                }
-+	            });
-+	            return pp.filter(p => p !== exports.GLOBSTAR).join('/');
-+	        })
-+	            .join('|');
-+	        // need to wrap in parens if we had more than one thing with |,
-+	        // otherwise only the first will be anchored to ^ and the last to $
-+	        const [open, close] = set.length > 1 ? ['(?:', ')'] : ['', ''];
-+	        // must match entire pattern
-+	        // ending in a * or ** will make it less strict.
-+	        re = '^' + open + re + close + '$';
-+	        // can match anything, as long as it's not this.
-+	        if (this.negate)
-+	            re = '^(?!' + re + ').+$';
-+	        try {
-+	            this.regexp = new RegExp(re, [...flags].join(''));
-+	            /* c8 ignore start */
-+	        }
-+	        catch (ex) {
-+	            // should be impossible
-+	            this.regexp = false;
-+	        }
-+	        /* c8 ignore stop */
-+	        return this.regexp;
-+	    }
-+	    slashSplit(p) {
-+	        // if p starts with // on windows, we preserve that
-+	        // so that UNC paths aren't broken.  Otherwise, any number of
-+	        // / characters are coalesced into one, unless
-+	        // preserveMultipleSlashes is set to true.
-+	        if (this.preserveMultipleSlashes) {
-+	            return p.split('/');
-+	        }
-+	        else if (this.isWindows && /^\/\/[^\/]+/.test(p)) {
-+	            // add an extra '' for the one we lose
-+	            return ['', ...p.split(/\/+/)];
-+	        }
-+	        else {
-+	            return p.split(/\/+/);
-+	        }
-+	    }
-+	    match(f, partial = this.partial) {
-+	        this.debug('match', f, this.pattern);
-+	        // short-circuit in the case of busted things.
-+	        // comments, etc.
-+	        if (this.comment) {
-+	            return false;
-+	        }
-+	        if (this.empty) {
-+	            return f === '';
-+	        }
-+	        if (f === '/' && partial) {
-+	            return true;
-+	        }
-+	        const options = this.options;
-+	        // windows: need to use /, not \
-+	        if (this.isWindows) {
-+	            f = f.split('\\').join('/');
-+	        }
-+	        // treat the test path as a set of pathparts.
-+	        const ff = this.slashSplit(f);
-+	        this.debug(this.pattern, 'split', ff);
-+	        // just ONE of the pattern sets in this.set needs to match
-+	        // in order for it to be valid.  If negating, then just one
-+	        // match means that we have failed.
-+	        // Either way, return on the first hit.
-+	        const set = this.set;
-+	        this.debug(this.pattern, 'set', set);
-+	        // Find the basename of the path by looking for the last non-empty segment
-+	        let filename = ff[ff.length - 1];
-+	        if (!filename) {
-+	            for (let i = ff.length - 2; !filename && i >= 0; i--) {
-+	                filename = ff[i];
-+	            }
-+	        }
-+	        for (let i = 0; i < set.length; i++) {
-+	            const pattern = set[i];
-+	            let file = ff;
-+	            if (options.matchBase && pattern.length === 1) {
-+	                file = [filename];
-+	            }
-+	            const hit = this.matchOne(file, pattern, partial);
-+	            if (hit) {
-+	                if (options.flipNegate) {
-+	                    return true;
-+	                }
-+	                return !this.negate;
-+	            }
-+	        }
-+	        // didn't get any hits.  this is success if it's a negative
-+	        // pattern, failure otherwise.
-+	        if (options.flipNegate) {
-+	            return false;
-+	        }
-+	        return this.negate;
-+	    }
-+	    static defaults(def) {
-+	        return exports.minimatch.defaults(def).Minimatch;
-+	    }
-+	}
-+	exports.Minimatch = Minimatch;
-+	/* c8 ignore start */
-+	var ast_js_2 = ast;
-+	Object.defineProperty(exports, "AST", { enumerable: true, get: function () { return ast_js_2.AST; } });
-+	var escape_js_2 = _escape;
-+	Object.defineProperty(exports, "escape", { enumerable: true, get: function () { return escape_js_2.escape; } });
-+	var unescape_js_2 = _unescape;
-+	Object.defineProperty(exports, "unescape", { enumerable: true, get: function () { return unescape_js_2.unescape; } });
-+	/* c8 ignore stop */
-+	exports.minimatch.AST = ast_js_1.AST;
-+	exports.minimatch.Minimatch = Minimatch;
-+	exports.minimatch.escape = escape_js_1.escape;
-+	exports.minimatch.unescape = unescape_js_1.unescape;
-+	
-+} (commonjs));
-+
-+const fs = require$$0;
-+const path = path$1;
-+const EE = require$$2.EventEmitter;
-+const Minimatch = commonjs.Minimatch;
-+
-+class Walker extends EE {
-+  constructor (opts) {
-+    opts = opts || {};
-+    super(opts);
-+    // set to true if this.path is a symlink, whether follow is true or not
-+    this.isSymbolicLink = opts.isSymbolicLink;
-+    this.path = opts.path || process.cwd();
-+    this.basename = path.basename(this.path);
-+    this.ignoreFiles = opts.ignoreFiles || ['.ignore'];
-+    this.ignoreRules = {};
-+    this.parent = opts.parent || null;
-+    this.includeEmpty = !!opts.includeEmpty;
-+    this.root = this.parent ? this.parent.root : this.path;
-+    this.follow = !!opts.follow;
-+    this.result = this.parent ? this.parent.result : new Set();
-+    this.entries = null;
-+    this.sawError = false;
-+    this.exact = opts.exact;
-+  }
-+
-+  sort (a, b) {
-+    return a.localeCompare(b, 'en')
-+  }
-+
-+  emit (ev, data) {
-+    let ret = false;
-+    if (!(this.sawError && ev === 'error')) {
-+      if (ev === 'error') {
-+        this.sawError = true;
-+      } else if (ev === 'done' && !this.parent) {
-+        data = Array.from(data)
-+          .map(e => /^@/.test(e) ? `./${e}` : e).sort(this.sort);
-+        this.result = data;
-+      }
-+
-+      if (ev === 'error' && this.parent) {
-+        ret = this.parent.emit('error', data);
-+      } else {
-+        ret = super.emit(ev, data);
-+      }
-+    }
-+    return ret
-+  }
-+
-+  start () {
-+    fs.readdir(this.path, (er, entries) =>
-+      er ? this.emit('error', er) : this.onReaddir(entries));
-+    return this
-+  }
-+
-+  isIgnoreFile (e) {
-+    return e !== '.' &&
-+      e !== '..' &&
-+      this.ignoreFiles.indexOf(e) !== -1
-+  }
-+
-+  onReaddir (entries) {
-+    this.entries = entries;
-+    if (entries.length === 0) {
-+      if (this.includeEmpty) {
-+        this.result.add(this.path.slice(this.root.length + 1));
-+      }
-+      this.emit('done', this.result);
-+    } else {
-+      const hasIg = this.entries.some(e =>
-+        this.isIgnoreFile(e));
-+
-+      if (hasIg) {
-+        this.addIgnoreFiles();
-+      } else {
-+        this.filterEntries();
-+      }
-+    }
-+  }
-+
-+  addIgnoreFiles () {
-+    const newIg = this.entries
-+      .filter(e => this.isIgnoreFile(e));
-+
-+    let igCount = newIg.length;
-+    const then = () => {
-+      if (--igCount === 0) {
-+        this.filterEntries();
-+      }
-+    };
-+
-+    newIg.forEach(e => this.addIgnoreFile(e, then));
-+  }
-+
-+  addIgnoreFile (file, then) {
-+    const ig = path.resolve(this.path, file);
-+    fs.readFile(ig, 'utf8', (er, data) =>
-+      er ? this.emit('error', er) : this.onReadIgnoreFile(file, data, then));
-+  }
-+
-+  onReadIgnoreFile (file, data, then) {
-+    const mmopt = {
-+      matchBase: true,
-+      dot: true,
-+      flipNegate: true,
-+      nocase: true,
-+    };
-+    const rules = data.split(/\r?\n/)
-+      .filter(line => !/^#|^$/.test(line.trim()))
-+      .map(rule => {
-+        return new Minimatch(rule.trim(), mmopt)
-+      });
-+
-+    this.ignoreRules[file] = rules;
-+
-+    then();
-+  }
-+
-+  filterEntries () {
-+    // at this point we either have ignore rules, or just inheriting
-+    // this exclusion is at the point where we know the list of
-+    // entries in the dir, but don't know what they are.  since
-+    // some of them *might* be directories, we have to run the
-+    // match in dir-mode as well, so that we'll pick up partials
-+    // of files that will be included later.  Anything included
-+    // at this point will be checked again later once we know
-+    // what it is.
-+    const filtered = this.entries.map(entry => {
-+      // at this point, we don't know if it's a dir or not.
-+      const passFile = this.filterEntry(entry);
-+      const passDir = this.filterEntry(entry, true);
-+      return (passFile || passDir) ? [entry, passFile, passDir] : false
-+    }).filter(e => e);
-+
-+    // now we stat them all
-+    // if it's a dir, and passes as a dir, then recurse
-+    // if it's not a dir, but passes as a file, add to set
-+    let entryCount = filtered.length;
-+    if (entryCount === 0) {
-+      this.emit('done', this.result);
-+    } else {
-+      const then = () => {
-+        if (--entryCount === 0) {
-+          this.emit('done', this.result);
-+        }
-+      };
-+      filtered.forEach(filt => {
-+        const entry = filt[0];
-+        const file = filt[1];
-+        const dir = filt[2];
-+        this.stat({ entry, file, dir }, then);
-+      });
-+    }
-+  }
-+
-+  onstat ({ st, entry, file, dir, isSymbolicLink }, then) {
-+    const abs = this.path + '/' + entry;
-+    if (!st.isDirectory()) {
-+      if (file) {
-+        this.result.add(abs.slice(this.root.length + 1));
-+      }
-+      then();
-+    } else {
-+      // is a directory
-+      if (dir) {
-+        this.walker(entry, { isSymbolicLink, exact: file || this.filterEntry(entry + '/') }, then);
-+      } else {
-+        then();
-+      }
-+    }
-+  }
-+
-+  stat ({ entry, file, dir }, then) {
-+    const abs = this.path + '/' + entry;
-+    fs.lstat(abs, (lstatErr, lstatResult) => {
-+      if (lstatErr) {
-+        this.emit('error', lstatErr);
-+      } else {
-+        const isSymbolicLink = lstatResult.isSymbolicLink();
-+        if (this.follow && isSymbolicLink) {
-+          fs.stat(abs, (statErr, statResult) => {
-+            if (statErr) {
-+              this.emit('error', statErr);
-+            } else {
-+              this.onstat({ st: statResult, entry, file, dir, isSymbolicLink }, then);
-+            }
-+          });
-+        } else {
-+          this.onstat({ st: lstatResult, entry, file, dir, isSymbolicLink }, then);
-+        }
-+      }
-+    });
-+  }
-+
-+  walkerOpt (entry, opts) {
-+    return {
-+      path: this.path + '/' + entry,
-+      parent: this,
-+      ignoreFiles: this.ignoreFiles,
-+      follow: this.follow,
-+      includeEmpty: this.includeEmpty,
-+      ...opts,
-+    }
-+  }
-+
-+  walker (entry, opts, then) {
-+    new Walker(this.walkerOpt(entry, opts)).on('done', then).start();
-+  }
-+
-+  filterEntry (entry, partial, entryBasename) {
-+    let included = true;
-+
-+    // this = /a/b/c
-+    // entry = d
-+    // parent /a/b sees c/d
-+    if (this.parent && this.parent.filterEntry) {
-+      const parentEntry = this.basename + '/' + entry;
-+      const parentBasename = entryBasename || entry;
-+      included = this.parent.filterEntry(parentEntry, partial, parentBasename);
-+      if (!included && !this.exact) {
-+        return false
-+      }
-+    }
-+
-+    this.ignoreFiles.forEach(f => {
-+      if (this.ignoreRules[f]) {
-+        this.ignoreRules[f].forEach(rule => {
-+          // negation means inclusion
-+          // so if it's negated, and already included, no need to check
-+          // likewise if it's neither negated nor included
-+          if (rule.negate !== included) {
-+            const isRelativeRule = entryBasename && rule.globParts.some(part =>
-+              part.length <= (part.slice(-1)[0] ? 1 : 2)
-+            );
-+
-+            // first, match against /foo/bar
-+            // then, against foo/bar
-+            // then, in the case of partials, match with a /
-+            //   then, if also the rule is relative, match against basename
-+            const match = rule.match('/' + entry) ||
-+              rule.match(entry) ||
-+              !!partial && (
-+                rule.match('/' + entry + '/') ||
-+                rule.match(entry + '/') ||
-+                rule.negate && (
-+                  rule.match('/' + entry, true) ||
-+                  rule.match(entry, true)) ||
-+                isRelativeRule && (
-+                  rule.match('/' + entryBasename + '/') ||
-+                  rule.match(entryBasename + '/') ||
-+                  rule.negate && (
-+                    rule.match('/' + entryBasename, true) ||
-+                    rule.match(entryBasename, true))));
-+
-+            if (match) {
-+              included = rule.negate;
-+            }
-+          }
-+        });
-+      }
-+    });
-+
-+    return included
-+  }
-+}
-+
-+class WalkerSync extends Walker {
-+  start () {
-+    this.onReaddir(fs.readdirSync(this.path));
-+    return this
-+  }
-+
-+  addIgnoreFile (file, then) {
-+    const ig = path.resolve(this.path, file);
-+    this.onReadIgnoreFile(file, fs.readFileSync(ig, 'utf8'), then);
-+  }
-+
-+  stat ({ entry, file, dir }, then) {
-+    const abs = this.path + '/' + entry;
-+    let st = fs.lstatSync(abs);
-+    const isSymbolicLink = st.isSymbolicLink();
-+    if (this.follow && isSymbolicLink) {
-+      st = fs.statSync(abs);
-+    }
-+
-+    // console.error('STAT SYNC', {st, entry, file, dir, isSymbolicLink, then})
-+    this.onstat({ st, entry, file, dir, isSymbolicLink }, then);
-+  }
-+
-+  walker (entry, opts, then) {
-+    new WalkerSync(this.walkerOpt(entry, opts)).start();
-+    then();
-+  }
-+}
-+
-+const walk$1 = (opts, callback) => {
-+  const p = new Promise((resolve, reject) => {
-+    new Walker(opts).on('done', resolve).on('error', reject).start();
-+  });
-+  return callback ? p.then(res => callback(null, res), callback) : p
-+};
-+
-+const walkSync = opts => new WalkerSync(opts).start().result;
-+
-+var lib$1 = walk$1;
-+walk$1.sync = walkSync;
-+walk$1.Walker = Walker;
-+walk$1.WalkerSync = WalkerSync;
-+
-+const { Walker: IgnoreWalker } = lib$1;
-+const { lstatSync: lstat, readFileSync: readFile } = require$$0;
-+const { basename, dirname, extname, join, relative, resolve, sep } = path$1;
-+
-+// symbols used to represent synthetic rule sets
-+const defaultRules = Symbol('npm-packlist.rules.default');
-+const strictRules = Symbol('npm-packlist.rules.strict');
-+
-+// There may be others, but :?|<> are handled by node-tar
-+const nameIsBadForWindows = file => /\*/.test(file);
-+
-+// these are the default rules that are applied to everything except for non-link bundled deps
-+const defaults = [
-+  '.npmignore',
-+  '.gitignore',
-+  '**/.git',
-+  '**/.svn',
-+  '**/.hg',
-+  '**/CVS',
-+  '**/.git/**',
-+  '**/.svn/**',
-+  '**/.hg/**',
-+  '**/CVS/**',
-+  '/.lock-wscript',
-+  '/.wafpickle-*',
-+  '/build/config.gypi',
-+  'npm-debug.log',
-+  '**/.npmrc',
-+  '.*.swp',
-+  '.DS_Store',
-+  '**/.DS_Store/**',
-+  '._*',
-+  '**/._*/**',
-+  '*.orig',
-+  '/archived-packages/**',
-+];
-+
-+const strictDefaults = [
-+  // these are forcibly excluded
-+  '/.git',
-+];
-+
-+const normalizePath = (path) => path.split('\\').join('/');
-+
-+const readOutOfTreeIgnoreFiles = (root, rel, result = []) => {
-+  for (const file of ['.npmignore', '.gitignore']) {
-+    try {
-+      const ignoreContent = readFile(join(root, file), { encoding: 'utf8' });
-+      result.push(ignoreContent);
-+      // break the loop immediately after reading, this allows us to prioritize
-+      // the .npmignore and discard the .gitignore if one is present
-+      break
-+    } catch (err) {
-+      // we ignore ENOENT errors completely because we don't care if the file doesn't exist
-+      // but we throw everything else because failing to read a file that does exist is
-+      // something that the user likely wants to know about
-+      // istanbul ignore next -- we do not need to test a thrown error
-+      if (err.code !== 'ENOENT') {
-+        throw err
-+      }
-+    }
-+  }
-+
-+  if (!rel) {
-+    return result
-+  }
-+
-+  const firstRel = rel.split(sep, 1)[0];
-+  const newRoot = join(root, firstRel);
-+  const newRel = relative(newRoot, join(root, rel));
-+
-+  return readOutOfTreeIgnoreFiles(newRoot, newRel, result)
-+};
-+
-+class PackWalker extends IgnoreWalker {
-+  constructor (tree, opts) {
-+    const options = {
-+      ...opts,
-+      includeEmpty: false,
-+      follow: false,
-+      // we path.resolve() here because ignore-walk doesn't do it and we want full paths
-+      path: resolve(opts?.path || tree.path).replace(/\\/g, '/'),
-+      ignoreFiles: opts?.ignoreFiles || [
-+        defaultRules,
-+        'package.json',
-+        '.npmignore',
-+        '.gitignore',
-+        strictRules,
-+      ],
-+    };
-+
-+    super(options);
-+    this.isPackage = options.isPackage;
-+    this.seen = options.seen || new Set();
-+    this.tree = tree;
-+    this.requiredFiles = options.requiredFiles || [];
-+
-+    const additionalDefaults = [];
-+    if (options.prefix && options.workspaces) {
-+      const path = normalizePath(options.path);
-+      const prefix = normalizePath(options.prefix);
-+      const workspaces = options.workspaces.map((ws) => normalizePath(ws));
-+
-+      // istanbul ignore else - this does nothing unless we need it to
-+      if (path !== prefix && workspaces.includes(path)) {
-+        // if path and prefix are not the same directory, and workspaces has path in it
-+        // then we know path is a workspace directory. in order to not drop ignore rules
-+        // from directories between the workspaces root (prefix) and the workspace itself
-+        // (path) we need to find and read those now
-+        const relpath = relative(options.prefix, dirname(options.path));
-+        additionalDefaults.push(...readOutOfTreeIgnoreFiles(options.prefix, relpath));
-+      } else if (path === prefix) {
-+        // on the other hand, if the path and prefix are the same, then we ignore workspaces
-+        // so that we don't pack a workspace as part of the root project. append them as
-+        // normalized relative paths from the root
-+        additionalDefaults.push(...workspaces.map((w) => normalizePath(relative(options.path, w))));
-+      }
-+    }
-+
-+    // go ahead and inject the default rules now
-+    this.injectRules(defaultRules, [...defaults, ...additionalDefaults]);
-+
-+    if (!this.isPackage) {
-+      // if this instance is not a package, then place some strict default rules, and append
-+      // known required files for this directory
-+      this.injectRules(strictRules, [
-+        ...strictDefaults,
-+        ...this.requiredFiles.map((file) => `!${file}`),
-+      ]);
-+    }
-+  }
-+
-+  // overridden method: we intercept the reading of the package.json file here so that we can
-+  // process it into both the package.json file rules as well as the strictRules synthetic rule set
-+  addIgnoreFile (file, callback) {
-+    // if we're adding anything other than package.json, then let ignore-walk handle it
-+    if (file !== 'package.json' || !this.isPackage) {
-+      return super.addIgnoreFile(file, callback)
-+    }
-+
-+    return this.processPackage(callback)
-+  }
-+
-+  // overridden method: if we're done, but we're a package, then we also need to evaluate bundles
-+  // before we actually emit our done event
-+  emit (ev, data) {
-+    if (ev !== 'done' || !this.isPackage) {
-+      return super.emit(ev, data)
-+    }
-+
-+    // we intentionally delay the done event while keeping the function sync here
-+    // eslint-disable-next-line promise/catch-or-return, promise/always-return
-+    this.gatherBundles().then(() => {
-+      super.emit('done', this.result);
-+    });
-+    return true
-+  }
-+
-+  // overridden method: before actually filtering, we make sure that we've removed the rules for
-+  // files that should no longer take effect due to our order of precedence
-+  filterEntries () {
-+    if (this.ignoreRules['package.json']) {
-+      // package.json means no .npmignore or .gitignore
-+      this.ignoreRules['.npmignore'] = null;
-+      this.ignoreRules['.gitignore'] = null;
-+    } else if (this.ignoreRules['.npmignore']) {
-+      // .npmignore means no .gitignore
-+      this.ignoreRules['.gitignore'] = null;
-+    }
-+
-+    return super.filterEntries()
-+  }
-+
-+  // overridden method: we never want to include anything that isn't a file or directory
-+  onstat (opts, callback) {
-+    if (!opts.st.isFile() && !opts.st.isDirectory()) {
-+      return callback()
-+    }
-+
-+    return super.onstat(opts, callback)
-+  }
-+
-+  // overridden method: we want to refuse to pack files that are invalid, node-tar protects us from
-+  // a lot of them but not all
-+  stat (opts, callback) {
-+    if (nameIsBadForWindows(opts.entry)) {
-+      return callback()
-+    }
-+
-+    return super.stat(opts, callback)
-+  }
-+
-+  // overridden method: this is called to create options for a child walker when we step
-+  // in to a normal child directory (this will never be a bundle). the default method here
-+  // copies the root's `ignoreFiles` value, but we don't want to respect package.json for
-+  // subdirectories, so we override it with a list that intentionally omits package.json
-+  walkerOpt (entry, opts) {
-+    let ignoreFiles = null;
-+
-+    // however, if we have a tree, and we have workspaces, and the directory we're about
-+    // to step into is a workspace, then we _do_ want to respect its package.json
-+    if (this.tree.workspaces) {
-+      const workspaceDirs = [...this.tree.workspaces.values()]
-+        .map((dir) => dir.replace(/\\/g, '/'));
-+
-+      const entryPath = join(this.path, entry).replace(/\\/g, '/');
-+      if (workspaceDirs.includes(entryPath)) {
-+        ignoreFiles = [
-+          defaultRules,
-+          'package.json',
-+          '.npmignore',
-+          '.gitignore',
-+          strictRules,
-+        ];
-+      }
-+    } else {
-+      ignoreFiles = [
-+        defaultRules,
-+        '.npmignore',
-+        '.gitignore',
-+        strictRules,
-+      ];
-+    }
-+
-+    return {
-+      ...super.walkerOpt(entry, opts),
-+      ignoreFiles,
-+      // we map over our own requiredFiles and pass ones that are within this entry
-+      requiredFiles: this.requiredFiles
-+        .map((file) => {
-+          if (relative(file, entry) === '..') {
-+            return relative(entry, file).replace(/\\/g, '/')
-+          }
-+          return false
-+        })
-+        .filter(Boolean),
-+    }
-+  }
-+
-+  // overridden method: we want child walkers to be instances of this class, not ignore-walk
-+  walker (entry, opts, callback) {
-+    new PackWalker(this.tree, this.walkerOpt(entry, opts)).on('done', callback).start();
-+  }
-+
-+  // overridden method: we use a custom sort method to help compressibility
-+  sort (a, b) {
-+    // optimize for compressibility
-+    // extname, then basename, then locale alphabetically
-+    // https://twitter.com/isntitvacant/status/1131094910923231232
-+    const exta = extname(a).toLowerCase();
-+    const extb = extname(b).toLowerCase();
-+    const basea = basename(a).toLowerCase();
-+    const baseb = basename(b).toLowerCase();
-+
-+    return exta.localeCompare(extb, 'en') ||
-+      basea.localeCompare(baseb, 'en') ||
-+      a.localeCompare(b, 'en')
-+  }
-+
-+  // convenience method: this joins the given rules with newlines, appends a trailing newline,
-+  // and calls the internal onReadIgnoreFile method
-+  injectRules (filename, rules, callback = () => {}) {
-+    this.onReadIgnoreFile(filename, `${rules.join('\n')}\n`, callback);
-+  }
-+
-+  // custom method: this is called by addIgnoreFile when we find a package.json, it uses the
-+  // arborist tree to pull both default rules and strict rules for the package
-+  processPackage (callback) {
-+    const {
-+      bin,
-+      browser,
-+      files,
-+      main,
-+    } = this.tree.package;
-+
-+    // rules in these arrays are inverted since they are patterns we want to _not_ ignore
-+    const ignores = [];
-+    const strict = [
-+      ...strictDefaults,
-+      '!/package.json',
-+      '!/readme{,.*[^~$]}',
-+      '!/copying{,.*[^~$]}',
-+      '!/license{,.*[^~$]}',
-+      '!/licence{,.*[^~$]}',
-+      '/.git',
-+      '/node_modules',
-+      '.npmrc',
-+      '/package-lock.json',
-+      '/yarn.lock',
-+      '/pnpm-lock.yaml',
-+    ];
-+
-+    // if we have a files array in our package, we need to pull rules from it
-+    if (files) {
-+      for (let file of files) {
-+        // invert the rule because these are things we want to include
-+        if (file.startsWith('./')) {
-+          file = file.slice(1);
-+        }
-+        if (file.endsWith('/*')) {
-+          file += '*';
-+        }
-+        const inverse = `!${file}`;
-+        try {
-+          // if an entry in the files array is a specific file, then we need to include it as a
-+          // strict requirement for this package. if it's a directory or a pattern, it's a default
-+          // pattern instead. this is ugly, but we have to stat to find out if it's a file
-+          const stat = lstat(join(this.path, file.replace(/^!+/, '')).replace(/\\/g, '/'));
-+          // if we have a file and we know that, it's strictly required
-+          if (stat.isFile()) {
-+            strict.unshift(inverse);
-+            this.requiredFiles.push(file.startsWith('/') ? file.slice(1) : file);
-+          } else if (stat.isDirectory()) {
-+            // otherwise, it's a default ignore, and since we got here we know it's not a pattern
-+            // so we include the directory contents
-+            ignores.push(inverse);
-+            ignores.push(`${inverse}/**`);
-+          }
-+          // if the thing exists, but is neither a file or a directory, we don't want it at all
-+        } catch (err) {
-+          // if lstat throws, then we assume we're looking at a pattern and treat it as a default
-+          ignores.push(inverse);
-+        }
-+      }
-+
-+      // we prepend a '*' to exclude everything, followed by our inverted file rules
-+      // which now mean to include those
-+      this.injectRules('package.json', ['*', ...ignores]);
-+    }
-+
-+    // browser is required
-+    if (browser) {
-+      strict.push(`!/${browser}`);
-+    }
-+
-+    // main is required
-+    if (main) {
-+      strict.push(`!/${main}`);
-+    }
-+
-+    // each bin is required
-+    if (bin) {
-+      for (const key in bin) {
-+        strict.push(`!/${bin[key]}`);
-+      }
-+    }
-+
-+    // and now we add all of the strict rules to our synthetic file
-+    this.injectRules(strictRules, strict, callback);
-+  }
-+
-+  // custom method: after we've finished gathering the files for the root package, we call this
-+  // before emitting the 'done' event in order to gather all of the files for bundled deps
-+  async gatherBundles () {
-+    if (this.seen.has(this.tree)) {
-+      return
-+    }
-+
-+    // add this node to our seen tracker
-+    this.seen.add(this.tree);
-+
-+    // if we're the project root, then we look at our bundleDependencies, otherwise we got here
-+    // because we're a bundled dependency of the root, which means we need to include all prod
-+    // and optional dependencies in the bundle
-+    let toBundle;
-+    if (this.tree.isProjectRoot) {
-+      const { bundleDependencies } = this.tree.package;
-+      toBundle = bundleDependencies || [];
-+    } else {
-+      const { dependencies, optionalDependencies } = this.tree.package;
-+      toBundle = Object.keys(dependencies || {}).concat(Object.keys(optionalDependencies || {}));
-+    }
-+
-+    for (const dep of toBundle) {
-+      const edge = this.tree.edgesOut.get(dep);
-+      // no edgeOut = missing node, so skip it. we can't pack it if it's not here
-+      // we also refuse to pack peer dependencies and dev dependencies
-+      if (!edge || edge.peer || edge.dev) {
-+        continue
-+      }
-+
-+      // get a reference to the node we're bundling
-+      const node = this.tree.edgesOut.get(dep).to;
-+      // if there's no node, this is most likely an optional dependency that hasn't been
-+      // installed. just skip it.
-+      if (!node) {
-+        continue
-+      }
-+      // we use node.path for the path because we want the location the node was linked to,
-+      // not where it actually lives on disk
-+      const path = node.path;
-+      // but link nodes don't have edgesOut, so we need to pass in the target of the node
-+      // in order to make sure we correctly traverse its dependencies
-+      const tree = node.target;
-+
-+      // and start building options to be passed to the walker for this package
-+      const walkerOpts = {
-+        path,
-+        isPackage: true,
-+        ignoreFiles: [],
-+        seen: this.seen, // pass through seen so we can prevent infinite circular loops
-+      };
-+
-+      // if our node is a link, we apply defaultRules. we don't do this for regular bundled
-+      // deps because their .npmignore and .gitignore files are excluded by default and may
-+      // override defaults
-+      if (node.isLink) {
-+        walkerOpts.ignoreFiles.push(defaultRules);
-+      }
-+
-+      // _all_ nodes will follow package.json rules from their package root
-+      walkerOpts.ignoreFiles.push('package.json');
-+
-+      // only link nodes will obey .npmignore or .gitignore
-+      if (node.isLink) {
-+        walkerOpts.ignoreFiles.push('.npmignore');
-+        walkerOpts.ignoreFiles.push('.gitignore');
-+      }
-+
-+      // _all_ nodes follow strict rules
-+      walkerOpts.ignoreFiles.push(strictRules);
-+
-+      // create a walker for this dependency and gather its results
-+      const walker = new PackWalker(tree, walkerOpts);
-+      const bundled = await new Promise((pResolve, pReject) => {
-+        walker.on('error', pReject);
-+        walker.on('done', pResolve);
-+        walker.start();
-+      });
-+
-+      // now we make sure we have our paths correct from the root, and accumulate everything into
-+      // our own result set to deduplicate
-+      const relativeFrom = relative(this.root, walker.path);
-+      for (const file of bundled) {
-+        this.result.add(join(relativeFrom, file).replace(/\\/g, '/'));
-+      }
-+    }
-+  }
-+}
-+
-+const walk = (tree, options, callback) => {
-+  if (typeof options === 'function') {
-+    callback = options;
-+    options = {};
-+  }
-+  const p = new Promise((pResolve, pReject) => {
-+    new PackWalker(tree, { ...options, isPackage: true })
-+      .on('done', pResolve).on('error', pReject).start();
-+  });
-+  return callback ? p.then(res => callback(null, res), callback) : p
-+};
-+
-+var lib = walk;
-+walk.Walker = PackWalker;
-+
-+var packlist = /*@__PURE__*/getDefaultExportFromCjs(lib);
-+
-+const edgesOut = /* @__PURE__ */ new Map();
-+const getNpmPacklist = (absoluteLinkPackagePath, packageJson) => packlist({
-+  path: absoluteLinkPackagePath,
-+  package: packageJson,
-+  // @ts-expect-error outdated types
-+  edgesOut
-+});
-+
-+const cwd = process.cwd();
-+const cwdPath = (filePath) => path$2.relative(cwd, filePath);
-+
-+const getPrettyTime = () => (/* @__PURE__ */ new Date()).toLocaleTimeString(
-+  void 0,
-+  {
-+    hour: "numeric",
-+    minute: "numeric",
-+    second: "numeric",
-+    hour12: true
-+  }
-+);
-+
-+const waitFor = (test, interval, maxTimeout, errorMessage) => new Promise(async (resolve, reject) => {
-+  const startTime = Date.now();
-+  let attempts = 0;
-+  const maxAttempts = Math.floor(maxTimeout / interval);
-+  const attempt = async () => {
-+    attempts++;
-+    try {
-+      const result = await test();
-+      if (result) {
-+        return resolve();
-+      }
-+      throw new Error();
-+    } catch (error) {
-+      const numAttemptsRemaining = maxAttempts - attempts;
-+      console.error(red(`  \u{1F615} Error: ${errorMessage}`), " retrying in", yellow(`${interval}ms`), ".", yellow(`${numAttemptsRemaining} attempts remaining`));
-+    }
-+    if (Date.now() - startTime >= maxTimeout) {
-+      console.error(red(`  \u{1F635} Error: ${errorMessage}. Giving up after ${maxAttempts} attempts`));
-+      return reject();
-+    }
-+    setTimeout(attempt, interval);
-+  };
-+  await attempt();
-+});
-+
-+const hardlinkPackage = async (linkPath, absoluteLinkPackagePath, packageJson, publishFilesPromise = getNpmPacklist(
-+  absoluteLinkPackagePath,
-+  packageJson
-+), interval = 500, maxBuildTime = 3e4) => {
-+  const [oldPublishFiles, publishFiles] = await Promise.all([
-+    getNpmPacklist(
-+      linkPath,
-+      /**
-+       * This is evaluated in the context of the new package.json since that
-+       * defines which files belong to the package.
-+       */
-+      packageJson
-+    ),
-+    publishFilesPromise
-+  ]);
-+  console.log(`Linking ${magenta(packageJson.name)} in publish mode:`);
-+  await Promise.all(publishFiles.map(async (file) => {
-+    const sourcePath = path$2.join(absoluteLinkPackagePath, file);
-+    await waitFor(
-+      async () => await fsExists(sourcePath),
-+      interval,
-+      maxBuildTime,
-+      ""
-+    );
-+  }));
-+  await Promise.all(
-+    publishFiles.map(async (file) => {
-+      const sourcePath = path$2.join(absoluteLinkPackagePath, file);
-+      const targetPath = path$2.join(linkPath, file);
-+      await fs$2.mkdir(
-+        path$2.dirname(targetPath),
-+        { recursive: true }
-+      );
-+      try {
-+        await hardlink(sourcePath, targetPath);
-+      } catch (error) {
-+        console.warn(
-+          `  ${red("\u2716 Failed to link")}`,
-+          cyan(cwdPath(targetPath)),
-+          "\u2192",
-+          cyan(cwdPath(sourcePath)),
-+          error.message ?? error
-+        );
-+        return;
-+      }
-+      const fileIndex = oldPublishFiles.indexOf(file);
-+      if (fileIndex > -1) {
-+        oldPublishFiles.splice(fileIndex, 1);
-+      }
-+      console.log(
-+        `  ${green("\u2714")}`,
-+        cyan(cwdPath(targetPath)),
-+        "\u2192",
-+        cyan(cwdPath(sourcePath))
-+      );
-+    })
-+  );
-+  await Promise.all(
-+    oldPublishFiles.map(async (file) => {
-+      console.log(cyan(`  \u{1F6AE} ${file} no longer in publish list, deleting it. If you did not intend to do this, something probably went wrong. See https://github.com/privatenumber/link?tab=readme-ov-file#publish-mode`));
-+      await fs$2.rm(path$2.join(linkPath, file), {
-+        force: true
-+      });
-+    })
-+  );
-+};
-+
-+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
-+
-+const isValidSetup = async (linkPath, expectedPrefix) => {
-+  const linkPathStat = await fs$2.stat(linkPath).catch(() => null);
-+  if (!linkPathStat?.isDirectory()) {
-+    return false;
-+  }
-+  const linkPathReal = await fs$2.realpath(linkPath);
-+  return linkPathReal.startsWith(expectedPrefix);
-+};
-+const linkPublishMode = async (basePackagePath, linkPackagePath, watchMode, litmus, delay = 2e3, interval = 500, maxBuildTime = 3e4) => {
-+  const absoluteLinkPackagePath = path$2.resolve(basePackagePath, linkPackagePath);
-+  const packageJson = await readPackageJson(absoluteLinkPackagePath);
-+  const expectedPrefix = path$2.join(basePackagePath, "node_modules/");
-+  const linkPath = path$2.join(expectedPrefix, packageJson.name);
-+  if (!await isValidSetup(linkPath, expectedPrefix)) {
-+    console.error(
-+      defaultOutdent`
-+			Error: Package ${magenta(packageJson.name)} is not set up
-+	
-+			${bold("Setup instructions")}
-+			1. In the Dependency package, create a tarball:
-+			  ${dim("$ npm pack")}
-+	
-+			2. In the Consuming package, install the tarball and link the Dependency:
-+			  ${dim("$ npm install --no-save ")}
-+			  ${dim("$ npx link publish ")}
-+	
-+			3. Start developing!
-+	
-+			Learn more: https://npmjs.com/link
-+			`
-+    );
-+    return;
-+  }
-+  const debouncedSleepForDelay = pDebounce(sleep, delay);
-+  const debouncedHardlinkPackage = debounce$1(hardlinkPackage, delay);
-+  await hardlinkPackage(
-+    linkPath,
-+    absoluteLinkPackagePath,
-+    packageJson
-+  );
-+  if (watchMode) {
-+    const globOptions = {
-+      globstar: true,
-+      extended: true
-+    };
-+    const ignoreFiles = [
-+      // Files
-+      "**/{npm-debug.log,*.orig,package-lock.json,yarn.lock,pnpm-lock.yaml}",
-+      // Folders
-+      "**/node_modules/**",
-+      // Hidden files
-+      "**/.{_*,*.swp,DS_Store,gitignore,npmrc,npmignore,lock-wscript,.wafpickle-*}",
-+      // Hidden folders
-+      "**/.{_*,git,svn,hg,CVS}/**"
-+    ].map((glob) => globToRegexp$1(glob, globOptions));
-+    const watcher = fs$2.watch(
-+      absoluteLinkPackagePath,
-+      { recursive: true }
-+    );
-+    for await (const { eventType, filename } of watcher) {
-+      if (!filename) {
-+        continue;
-+      }
-+      const shouldIgnore = ignoreFiles.some((ignoreFile) => ignoreFile.test(filename));
-+      if (shouldIgnore) {
-+        continue;
-+      }
-+      await debouncedSleepForDelay(delay);
-+      if (litmus) {
-+        await waitFor(
-+          async () => fsExists(path$2.join(absoluteLinkPackagePath, litmus)),
-+          interval,
-+          maxBuildTime,
-+          ""
-+        );
-+      }
-+      const publishFiles = await getNpmPacklist(
-+        absoluteLinkPackagePath,
-+        packageJson
-+      );
-+      if (!publishFiles.includes(filename)) {
-+        continue;
-+      }
-+      console.log(`
-+${dim(getPrettyTime())}`, "Detected", yellow(eventType), "in", `${cyan(cwdPath(path$2.join(absoluteLinkPackagePath, filename)))}
-+`);
-+      await debouncedHardlinkPackage(
-+        linkPath,
-+        absoluteLinkPackagePath,
-+        packageJson,
-+        publishFiles
-+      );
-+    }
-+  }
-+};
-+
-+const publishCommand = G({
-+  name: "publish",
-+  parameters: [""],
-+  flags: {
-+    watch: {
-+      type: Boolean,
-+      alias: "w",
-+      description: "Watch for changes in the package and automatically relink"
-+    },
-+    litmus: {
-+      type: String,
-+      alias: "l",
-+      description: "If using the --watch flag, look for this file in the linked package to see if it's ready to re-link"
-+    },
-+    delay: {
-+      type: Number,
-+      alias: "d",
-+      description: "If using the --watch flag without the litmus flag, wait this amount of time (in ms) after detecting changes before refreshing the packlist and re-linking",
-+      default: 2e3
-+    },
-+    interval: {
-+      type: Number,
-+      alias: "i",
-+      description: "If using the --watch flag, poll for completed builds at this frequency (in ms)",
-+      default: 500
-+    },
-+    maxBuildTime: {
-+      type: Number,
-+      alias: "m",
-+      description: "If using the --watch flag, the maximum amount of time to wait for all expected files to appear before re-linking",
-+      default: 3e4
-+    }
-+  },
-+  help: {
-+    description: "Link a package to simulate an environment similar to `npm install`"
-+  }
-+});
-+const publishHandler = async (cwdProjectPath, packagePaths, flags) => {
-+  if (packagePaths.length > 0) {
-+    await Promise.all(
-+      packagePaths.map(
-+        (linkPackagePath) => linkPublishMode(
-+          cwdProjectPath,
-+          linkPackagePath,
-+          flags.watch,
-+          flags.litmus,
-+          flags.delay,
-+          flags.interval,
-+          flags.maxBuildTime
-+        )
-+      )
-+    );
-+  }
-+};
-+
-+(async () => {
-+  const argv = Z({
-+    name: "link",
-+    parameters: ["[package paths...]"],
-+    flags: {
-+      deep: {
-+        type: Boolean,
-+        alias: "d",
-+        description: "Run `npx link` on dependencies if they have a link.config.json"
-+      }
-+    },
-+    help: {
-+      description: "A better `npm link` -- symlink local dependencies to the current project",
-+      render: (nodes, renderers) => {
-+        nodes[0].data = "npx link\n";
-+        nodes.splice(2, 0, {
-+          type: "section",
-+          data: {
-+            title: "Website",
-+            body: "https://www.npmjs.com/package/link"
-+          }
-+        });
-+        return renderers.render(nodes);
-+      }
-+    },
-+    commands: [
-+      publishCommand
-+    ]
-+  });
-+  const cwdProjectPath = await fs$1.realpath(process.cwd());
-+  if (!argv.command) {
-+    const { packagePaths } = argv._;
-+    if (packagePaths.length > 0) {
-+      await Promise.all(
-+        packagePaths.map(
-+          (linkPackagePath) => linkPackage(
-+            cwdProjectPath,
-+            linkPackagePath,
-+            argv.flags
-+          )
-+        )
-+      );
-+      return;
-+    }
-+    const config = await loadConfig(cwdProjectPath);
-+    if (!config) {
-+      console.warn(
-+        defaultOutdent`
- 				Warning: Config file "link.config.json" not found in current directory.
- 							Read the documentation to learn more: https://www.npmjs.com/package/link
--				`),u.showHelp();return}await Vu(e,D,{deep:u.flags.deep})}})().catch(u=>{console.error("Error:",u.message),process.exit(1)});
-+				`
-+      );
-+      argv.showHelp();
-+      return;
-+    }
-+    await linkFromConfig(
-+      cwdProjectPath,
-+      config,
-+      {
-+        deep: argv.flags.deep
-+      }
-+    );
-+  } else if (argv.command === "publish") {
-+    await publishHandler(
-+      cwdProjectPath,
-+      argv._,
-+      argv.flags
-+    );
-+  }
-+})().catch((error) => {
-+  console.error("Error:", error.message);
-+  process.exit(1);
-+});
-diff --git a/node_modules/link/dist/libs/fsevents.node b/node_modules/link/dist/libs/fsevents.node
-new file mode 100755
-index 0000000..1cc3345
-Binary files /dev/null and b/node_modules/link/dist/libs/fsevents.node differ
diff --git a/scripts/checkUnusedGradleDependencies.ts b/scripts/checkUnusedGradleDependencies.ts
new file mode 100644
index 000000000000..526f0dd809f7
--- /dev/null
+++ b/scripts/checkUnusedGradleDependencies.ts
@@ -0,0 +1,179 @@
+#!/usr/bin/env bun
+/**
+ * Fails when a dependency declared in android/app/build.gradle is no longer
+ * reached from the project.
+ *
+ * Android has no committed lockfile and no build failure to warn us: a Gradle
+ * declaration whose last consumer was deleted keeps being resolved, compiled and
+ * packaged. Knip cannot help either — it reads JavaScript and TypeScript. This
+ * check is what notices.
+ *
+ * Every declared coordinate needs an entry in android/app/dependency-usage.json.
+ * `packages` entries are verified against Java and Kotlin imports, `resources`
+ * entries against the XML — a view class in a layout, a style parent, an attr.
+ * `exempt` is for the artifacts that are genuinely needed with nothing in the
+ * project to prove it — a version pin, a BOM, a library React Native reaches
+ * rather than our own code — and each one carries a reason and the date it was
+ * last checked. That date is validated as a real, non-future date and reported
+ * as a warning once it is over a year old; it is not a failure, so an exemption
+ * never breaks the build on a date nobody chose.
+ *
+ * Usage: npx bun scripts/checkUnusedGradleDependencies.ts
+ */
+
+import path from 'path';
+
+import type {GradleUsageEntry} from './gradleUsageShared';
+
+import {MANIFEST_PATH, RELATIVE_BUILD_GRADLE, countReferences, findUnparsedDeclarations, readDeclaredDependencies, readManifest, readResources, readSources} from './gradleUsageShared';
+import {findBlankEntry, validateReviewedDate} from './nativeUsageManifest';
+
+const RELATIVE_MANIFEST = path.relative(path.resolve(__dirname, '..'), MANIFEST_PATH);
+const USAGES = new Set(['packages', 'resources', 'exempt']);
+const MINIMUM_RESOURCE_NAME_LENGTH = 8;
+
+function validateEntry(coordinate: string, entry: GradleUsageEntry): {error?: string; warning?: string} {
+    if (!USAGES.has(entry.usage)) {
+        return {error: `${coordinate}: "usage" must be one of ${[...USAGES].join(', ')}.`};
+    }
+    if (entry.usage === 'packages') {
+        if (!entry.packages.length) {
+            return {error: `${coordinate}: a "packages" entry needs at least one Java package prefix.`};
+        }
+        const blank = findBlankEntry(entry.packages, coordinate, 'packages');
+        if (blank) {
+            return {error: blank};
+        }
+    }
+    if (entry.usage === 'resources') {
+        if (!entry.resources.length) {
+            return {error: `${coordinate}: a "resources" entry needs at least one name to look for in the XML.`};
+        }
+        const blank = findBlankEntry(entry.resources, coordinate, 'resources');
+        if (blank) {
+            return {error: blank};
+        }
+        // An unqualified or short name matches too much of the XML to prove
+        // anything: `name` alone occurs in hundreds of places, so a dead
+        // coordinate could be made permanently green with it.
+        const vague = entry.resources.find((name) => name.trim().length < MINIMUM_RESOURCE_NAME_LENGTH || !name.includes('.'));
+        if (vague) {
+            return {
+                error: `${coordinate}: the "resources" name ${vague} is not specific enough to identify the dependency. Use the fully qualified class or attr name, at least ${MINIMUM_RESOURCE_NAME_LENGTH} characters and containing a dot.`,
+            };
+        }
+    }
+    if (entry.usage === 'exempt') {
+        if (!entry.reason.trim()) {
+            return {error: `${coordinate}: an "exempt" entry needs a "reason" saying what keeps the dependency alive.`};
+        }
+        return validateReviewedDate(entry.reviewed, coordinate);
+    }
+    return {};
+}
+
+/**
+ * Two coordinates whose package prefixes overlap cannot be told apart: whichever
+ * one still has a consumer keeps the other green. That is how a group-wide prefix
+ * such as `com.facebook.react` would "verify" `hermes-android` off imports that
+ * belong to `react-android`. A hand-written prefix this wide is legitimate only
+ * while it is the sole claim on that namespace.
+ */
+function findOverlappingPackages(manifest: Record): string[] {
+    const claims = Object.entries(manifest).flatMap(([coordinate, entry]) => (entry.usage === 'packages' ? entry.packages.map((prefix) => ({coordinate, prefix})) : []));
+    const problems: string[] = [];
+    for (const [index, claim] of claims.entries()) {
+        for (const other of claims.slice(index + 1)) {
+            if (other.coordinate === claim.coordinate) {
+                continue;
+            }
+            const overlaps = claim.prefix === other.prefix || claim.prefix.startsWith(`${other.prefix}.`) || other.prefix.startsWith(`${claim.prefix}.`);
+            if (overlaps) {
+                problems.push(
+                    `${claim.coordinate} and ${other.coordinate} both claim overlapping packages (${claim.prefix} and ${other.prefix}), so an import cannot tell them apart. Narrow one of them, or mark the artifact that does not own the package "exempt" with a reason.`,
+                );
+            }
+        }
+    }
+    return problems;
+}
+
+function main() {
+    let manifest;
+    try {
+        manifest = readManifest();
+    } catch (error) {
+        console.error(`Unused Gradle dependency check failed:\n\n  - ${error instanceof Error ? error.message : String(error)}`);
+        process.exit(1);
+    }
+    const declared = readDeclaredDependencies();
+    const problems: string[] = [];
+    const warnings: string[] = [];
+
+    problems.push(...findOverlappingPackages(manifest));
+
+    for (const unparsed of findUnparsedDeclarations()) {
+        problems.push(`${unparsed}\n    This declaration is in a form the check cannot read, so it would be verified by nothing. Teach scripts/gradleUsageShared.ts to read it.`);
+    }
+
+    // A parser that matches nothing would otherwise report success over an empty
+    // set, which is the one failure mode this check cannot afford.
+    if (!declared.length) {
+        problems.push(`No dependencies were parsed out of ${RELATIVE_BUILD_GRADLE}. Either it declares none, or the parser in scripts/gradleUsageShared.ts no longer understands it.`);
+    }
+
+    for (const coordinate of declared) {
+        if (!(coordinate in manifest)) {
+            problems.push(`${coordinate} is declared in android/app/build.gradle but missing from ${RELATIVE_MANIFEST}. Run \`npx bun scripts/generateGradleDependencyUsage.ts\`.`);
+        }
+    }
+
+    for (const coordinate of Object.keys(manifest)) {
+        if (!declared.includes(coordinate)) {
+            problems.push(`${coordinate} is in ${RELATIVE_MANIFEST} but no longer declared in android/app/build.gradle. Delete the entry.`);
+        }
+    }
+
+    const context = {sources: readSources(), resources: readResources()};
+    for (const coordinate of declared) {
+        const entry = manifest[coordinate];
+        if (!entry) {
+            continue;
+        }
+        const {error, warning} = validateEntry(coordinate, entry);
+        if (warning) {
+            warnings.push(warning);
+        }
+        if (error) {
+            problems.push(error);
+            continue;
+        }
+        if (entry.usage === 'exempt') {
+            continue;
+        }
+        if (countReferences(context, entry) === 0) {
+            const route = entry.usage === 'packages' ? `imports of ${entry.packages.join(', ')}` : `${entry.resources.join(', ')} in the XML`;
+            problems.push(`${coordinate} is declared in android/app/build.gradle but nothing reaches it (looked for ${route}). Delete the dependency, or mark it "exempt" with a reason.`);
+        }
+    }
+
+    if (warnings.length) {
+        console.warn('Unused Gradle dependency check warnings:\n');
+        for (const warning of warnings) {
+            console.warn(`  - ${warning}`);
+        }
+        console.warn('');
+    }
+
+    if (problems.length) {
+        console.error('Unused Gradle dependency check failed:\n');
+        for (const problem of problems) {
+            console.error(`  - ${problem}`);
+        }
+        process.exit(1);
+    }
+
+    console.log(`Unused Gradle dependency check passed: ${declared.length} dependencies declared, all reached or exempt with a reason.`);
+}
+
+main();
diff --git a/scripts/checkUnusedPods.ts b/scripts/checkUnusedPods.ts
new file mode 100644
index 000000000000..49b64ceaf49f
--- /dev/null
+++ b/scripts/checkUnusedPods.ts
@@ -0,0 +1,175 @@
+#!/usr/bin/env bun
+/**
+ * Fails when a pod declared in ios/Podfile is no longer reached from the sources.
+ *
+ * Removing the last call site of a pod does not fail the build — the pod keeps
+ * being fetched, compiled and linked — so nothing tells us a declaration has
+ * outlived its reason. Knip cannot help here either: it reads JavaScript and
+ * TypeScript, and a Podfile is neither.
+ *
+ * Every declared pod needs an entry in ios/pod-usage.json saying how the sources
+ * reach it. `module` and `headers` entries are verified against the sources on
+ * every run. `exempt` entries are for pods that are alive by a route the sources
+ * cannot show — a build phase, a version pin, a linker flag — and they have to
+ * carry a reason and the date it was last checked. That date is validated as a
+ * real, non-future date and reported as a warning once it is over a year old; it
+ * is not a failure, so an exemption never breaks the build on a date nobody
+ * chose.
+ *
+ * The manifest is generated by scripts/generatePodUsage.ts after `pod install`.
+ *
+ * Usage: npx bun scripts/checkUnusedPods.ts
+ */
+
+import path from 'path';
+
+import type {PodUsageEntry} from './podUsageShared';
+
+import {findBlankEntry, validateReviewedDate} from './nativeUsageManifest';
+import {MANIFEST_PATH, countReferences, findUnparsedPodDeclarations, readDeclaredPods, readManifest, readSources} from './podUsageShared';
+
+const RELATIVE_MANIFEST = path.relative(path.resolve(__dirname, '..'), MANIFEST_PATH);
+const USAGES = new Set(['module', 'headers', 'exempt']);
+
+/**
+ * Modules every iOS source imports anyway, so claiming one proves nothing about
+ * a pod.
+ */
+const SYSTEM_MODULES = new Set(['Foundation', 'UIKit', 'SwiftUI', 'Combine', 'CoreGraphics', 'CoreFoundation', 'Swift', 'React', 'ObjectiveC']);
+
+function validateEntry(pod: string, entry: PodUsageEntry): {error?: string; warning?: string} {
+    if (!USAGES.has(entry.usage)) {
+        return {error: `${pod}: "usage" must be one of ${[...USAGES].join(', ')}.`};
+    }
+    if (entry.usage === 'module') {
+        if (!entry.module.trim()) {
+            return {error: `${pod}: a "module" entry needs the module name the pod is imported under.`};
+        }
+        if (SYSTEM_MODULES.has(entry.module.trim())) {
+            return {
+                error: `${pod}: "${entry.module}" is a system module that the sources import regardless of this pod, so it cannot verify it. Use the pod's own module, or mark it "exempt" with a reason.`,
+            };
+        }
+    }
+    if (entry.usage === 'headers') {
+        if (!entry.headers.length) {
+            return {error: `${pod}: a "headers" entry needs at least one public header name.`};
+        }
+        const blank = findBlankEntry(entry.headers, pod, 'headers');
+        if (blank) {
+            return {error: blank};
+        }
+    }
+    if (entry.usage === 'exempt') {
+        if (!entry.reason.trim()) {
+            return {error: `${pod}: an "exempt" entry needs a "reason" saying what keeps the pod alive.`};
+        }
+        return validateReviewedDate(entry.reviewed, pod);
+    }
+    return {};
+}
+
+/**
+ * Two pods claiming the same module or header cannot be told apart: whichever
+ * one still has a call site keeps the other green.
+ */
+function findOverlappingRoutes(manifest: Record): string[] {
+    const claims = Object.entries(manifest).flatMap(([pod, entry]) => {
+        if (entry.usage === 'module') {
+            return [{pod, route: `module ${entry.module}`}];
+        }
+        if (entry.usage === 'headers') {
+            return entry.headers.map((header) => ({pod, route: `header ${header}`}));
+        }
+        return [];
+    });
+    const problems: string[] = [];
+    for (const [index, claim] of claims.entries()) {
+        for (const other of claims.slice(index + 1)) {
+            if (other.pod !== claim.pod && other.route === claim.route) {
+                problems.push(`${claim.pod} and ${other.pod} both claim ${claim.route}, so a reference cannot tell them apart. Mark the one that does not own it "exempt" with a reason.`);
+            }
+        }
+    }
+    return problems;
+}
+
+function main() {
+    let manifest;
+    try {
+        manifest = readManifest();
+    } catch (error) {
+        console.error(`Unused pod check failed:\n\n  - ${error instanceof Error ? error.message : String(error)}`);
+        process.exit(1);
+    }
+    const declared = readDeclaredPods();
+    const problems: string[] = [];
+    const warnings: string[] = [];
+
+    problems.push(...findOverlappingRoutes(manifest));
+
+    for (const unparsed of findUnparsedPodDeclarations()) {
+        problems.push(`${unparsed}\n    This pod declaration is in a form the check cannot read, so it would be verified by nothing. Teach scripts/podUsageShared.ts to read it.`);
+    }
+
+    // A parser that matches nothing would otherwise report success over an empty
+    // set, which is the one failure mode this check cannot afford.
+    if (!declared.length) {
+        problems.push('No pods were parsed out of ios/Podfile. Either the Podfile declares none, or the parser in scripts/podUsageShared.ts no longer understands it.');
+    }
+
+    for (const pod of declared) {
+        if (!(pod in manifest)) {
+            problems.push(`${pod} is declared in ios/Podfile but missing from ${RELATIVE_MANIFEST}. Run \`npx bun scripts/generatePodUsage.ts\` after \`pod install\`.`);
+        }
+    }
+
+    for (const pod of Object.keys(manifest)) {
+        if (!declared.includes(pod)) {
+            problems.push(`${pod} is in ${RELATIVE_MANIFEST} but no longer declared in ios/Podfile. Delete the entry.`);
+        }
+    }
+
+    const sources = readSources();
+    for (const pod of declared) {
+        const entry = manifest[pod];
+        if (!entry) {
+            continue;
+        }
+        const {error, warning} = validateEntry(pod, entry);
+        if (warning) {
+            warnings.push(warning);
+        }
+        if (error) {
+            problems.push(error);
+            continue;
+        }
+        if (entry.usage === 'exempt') {
+            continue;
+        }
+        if (countReferences(sources, entry, pod) === 0) {
+            const route = entry.usage === 'module' ? `import ${entry.module}` : entry.headers.map((header) => `#import "${header}"`).join(' / ');
+            problems.push(`${pod} is declared in ios/Podfile but nothing in ios/ reaches it (looked for ${route}). Delete the pod, or mark it "exempt" with a reason.`);
+        }
+    }
+
+    if (warnings.length) {
+        console.warn('Unused pod check warnings:\n');
+        for (const warning of warnings) {
+            console.warn(`  - ${warning}`);
+        }
+        console.warn('');
+    }
+
+    if (problems.length) {
+        console.error('Unused pod check failed:\n');
+        for (const problem of problems) {
+            console.error(`  - ${problem}`);
+        }
+        process.exit(1);
+    }
+
+    console.log(`Unused pod check passed: ${declared.length} pods declared, all reached or exempt with a reason.`);
+}
+
+main();
diff --git a/scripts/generateGradleDependencyUsage.ts b/scripts/generateGradleDependencyUsage.ts
new file mode 100644
index 000000000000..a741ad0eb4f8
--- /dev/null
+++ b/scripts/generateGradleDependencyUsage.ts
@@ -0,0 +1,108 @@
+#!/usr/bin/env bun
+/**
+ * Proposes android/app/dependency-usage.json entries for the dependencies
+ * declared in android/app/build.gradle.
+ *
+ * A Maven coordinate does not tell you the Java package its classes live in —
+ * `com.github.bumptech.glide:glide` ships `com.bumptech.glide` — so this script
+ * guesses candidate prefixes from the coordinate and keeps the ones the sources
+ * actually import. Anything it cannot resolve is written out as `exempt` with an
+ * empty reason, and the check rejects that until a human either fills in the
+ * reason or replaces the entry with the right package.
+ *
+ * A group-wide prefix is only ever proposed when no other declared coordinate
+ * shares that group. Several artifacts usually do share one, and then the prefix
+ * is satisfied by any sibling's imports: `com.facebook.react:hermes-android`
+ * would be "verified" by `import com.facebook.react.*`, which belongs to
+ * `react-android`, and a BOM would be verified by the artifacts it only aligns
+ * versions for. An entry like that can never fail, so writing one is worse than
+ * writing none.
+ *
+ * Existing entries are preserved: run it after adding a dependency, then review.
+ *
+ * Usage: npx bun scripts/generateGradleDependencyUsage.ts
+ */
+
+import fs from 'fs';
+import path from 'path';
+
+import type {GradleUsageManifest} from './gradleUsageShared';
+
+import {MANIFEST_PATH, countReferences, readDeclaredDependencies, readManifest, readResources, readSources, writeManifest} from './gradleUsageShared';
+
+/**
+ * Package prefixes worth trying for a coordinate, most specific first. A
+ * group-wide prefix is last because it is the weakest: it is right for a group
+ * with one artifact in it, and a free ride for a group with several, which is
+ * what `findAmbiguousPrefixes` then strips out.
+ */
+function candidatePackages(coordinate: string): string[] {
+    const [group, artifact] = coordinate.split(':');
+    const candidates = new Set();
+    const add = (value: string) => {
+        if (!value) {
+            return;
+        }
+        candidates.add(value);
+    };
+
+    add(`${group}.${artifact.replaceAll('-', '.')}`);
+    // JitPack rewrites the group as com.github., while the classes keep
+    // their original package.
+    if (group.startsWith('com.github.')) {
+        const tail = group.slice('com.github.'.length);
+        add(`com.${tail}.${artifact.replaceAll('-', '.')}`);
+        add(`com.${tail}`);
+        add(tail);
+    }
+    add(group);
+    return [...candidates];
+}
+
+/**
+ * Prefixes that more than one declared coordinate would claim. Such a prefix
+ * cannot tell the two apart, so it proves nothing about either.
+ */
+function findAmbiguousPrefixes(coordinates: string[]): Set {
+    const owners = new Map();
+    for (const coordinate of coordinates) {
+        for (const prefix of candidatePackages(coordinate)) {
+            owners.set(prefix, (owners.get(prefix) ?? 0) + 1);
+        }
+    }
+    return new Set([...owners.entries()].filter(([, count]) => count > 1).map(([prefix]) => prefix));
+}
+
+function main() {
+    const context = {sources: readSources(), resources: readResources()};
+    const previous: GradleUsageManifest = fs.existsSync(MANIFEST_PATH) ? readManifest() : {};
+    const manifest: GradleUsageManifest = {};
+    const declared = readDeclaredDependencies();
+    const ambiguous = findAmbiguousPrefixes(declared);
+
+    for (const coordinate of declared) {
+        const existing = previous[coordinate];
+        if (existing && (existing.usage !== 'exempt' || existing.reason)) {
+            manifest[coordinate] = existing;
+            continue;
+        }
+
+        const packages = candidatePackages(coordinate)
+            .filter((prefix) => !ambiguous.has(prefix))
+            .filter((prefix) => countReferences(context, {usage: 'packages', packages: [prefix]}) > 0);
+        manifest[coordinate] = packages.length ? {usage: 'packages', packages} : {usage: 'exempt', reason: '', reviewed: ''};
+    }
+
+    writeManifest(manifest);
+
+    const blank = Object.entries(manifest).filter(([, entry]) => entry.usage === 'exempt' && !entry.reason);
+    console.log(`Wrote ${Object.keys(manifest).length} dependencies to ${path.relative(process.cwd(), MANIFEST_PATH)}.`);
+    if (blank.length) {
+        console.log(`\n${blank.length} dependency/dependencies need a package prefix or a reason:`);
+        for (const [coordinate] of blank) {
+            console.log(`  ${coordinate}`);
+        }
+    }
+}
+
+main();
diff --git a/scripts/generatePodUsage.ts b/scripts/generatePodUsage.ts
new file mode 100644
index 000000000000..a470a01f8f32
--- /dev/null
+++ b/scripts/generatePodUsage.ts
@@ -0,0 +1,109 @@
+#!/usr/bin/env bun
+/**
+ * Regenerates ios/pod-usage.json from the installed Pods directory.
+ *
+ * checkUnusedPods.ts cannot resolve a pod's module name on its own: the name a
+ * pod is imported under is decided by its podspec, not by its Podfile entry
+ * (GzipSwift exports a module called Gzip), and Objective-C pods that ship no
+ * module are reachable only through their public headers. Both live under
+ * ios/Pods, which is gitignored, so they are resolved here once and committed to
+ * the manifest.
+ *
+ * Run this after `pod install` whenever the Podfile changes, then review the
+ * diff — a pod this script cannot find any reference for is written out as
+ * `exempt` with an empty reason, which the check rejects until you fill it in.
+ *
+ * Usage: npx bun scripts/generatePodUsage.ts
+ */
+
+import fs from 'fs';
+import path from 'path';
+
+import type {PodUsageManifest} from './podUsageShared';
+
+import {IOS_DIR, MANIFEST_PATH, countReferences, readDeclaredPods, readManifest, readSources, writeManifest} from './podUsageShared';
+
+const TARGET_SUPPORT_FILES = path.join(IOS_DIR, 'Pods', 'Target Support Files');
+const PUBLIC_HEADERS = path.join(IOS_DIR, 'Pods', 'Headers', 'Public');
+
+function readModuleName(pod: string): string | undefined {
+    const dir = path.join(TARGET_SUPPORT_FILES, pod);
+    if (!fs.existsSync(dir)) {
+        return undefined;
+    }
+    for (const entry of fs.readdirSync(dir)) {
+        if (!entry.endsWith('.modulemap')) {
+            continue;
+        }
+        const match = /^\s*(?:framework\s+)?module\s+([A-Za-z0-9_]+)/m.exec(fs.readFileSync(path.join(dir, entry), 'utf8'));
+        if (match) {
+            return match[1];
+        }
+    }
+    return undefined;
+}
+
+function readPublicHeaders(pod: string): string[] {
+    const dir = path.join(PUBLIC_HEADERS, pod);
+    if (!fs.existsSync(dir)) {
+        return [];
+    }
+    const out: string[] = [];
+    const walk = (current: string) => {
+        for (const entry of fs.readdirSync(current, {withFileTypes: true})) {
+            const full = path.join(current, entry.name);
+            if (entry.isDirectory()) {
+                walk(full);
+            } else if (entry.name.endsWith('.h')) {
+                out.push(entry.name);
+            }
+        }
+    };
+    walk(dir);
+    return out.sort();
+}
+
+function main() {
+    if (!fs.existsSync(TARGET_SUPPORT_FILES)) {
+        console.error(`${TARGET_SUPPORT_FILES} is missing. Run \`bundle exec pod install\` in ios/ first.`);
+        process.exit(1);
+    }
+
+    const sources = readSources();
+    const previous: PodUsageManifest = fs.existsSync(MANIFEST_PATH) ? readManifest() : {};
+    const manifest: PodUsageManifest = {};
+
+    for (const pod of readDeclaredPods()) {
+        const moduleName = readModuleName(pod) ?? pod.replaceAll(/[^A-Za-z0-9_]/g, '_');
+        if (countReferences(sources, {usage: 'module', module: moduleName}) > 0) {
+            manifest[pod] = {usage: 'module', module: moduleName};
+            continue;
+        }
+
+        const headers = readPublicHeaders(pod);
+        if (headers.length && countReferences(sources, {usage: 'headers', headers}, pod) > 0) {
+            manifest[pod] = {usage: 'headers', headers};
+            continue;
+        }
+
+        // Nothing in the sources reaches this pod. It is either dead or alive by a
+        // route the sources do not show — a build phase, a linker flag, another pod
+        // depending on it. Keep whatever reason was already recorded so a
+        // regeneration does not wipe it.
+        const existing = previous[pod];
+        manifest[pod] = existing?.usage === 'exempt' ? existing : {usage: 'exempt', reason: '', reviewed: ''};
+    }
+
+    writeManifest(manifest);
+
+    const blank = Object.entries(manifest).filter(([, entry]) => entry.usage === 'exempt' && !entry.reason);
+    console.log(`Wrote ${Object.keys(manifest).length} pods to ${path.relative(process.cwd(), MANIFEST_PATH)}.`);
+    if (blank.length) {
+        console.log(`\n${blank.length} pod(s) need a reason before the check will pass:`);
+        for (const [pod] of blank) {
+            console.log(`  ${pod}`);
+        }
+    }
+}
+
+main();
diff --git a/scripts/gradleUsageShared.ts b/scripts/gradleUsageShared.ts
new file mode 100644
index 000000000000..12307d251d3b
--- /dev/null
+++ b/scripts/gradleUsageShared.ts
@@ -0,0 +1,302 @@
+/**
+ * Shared between generateGradleDependencyUsage.ts and
+ * checkUnusedGradleDependencies.ts.
+ *
+ * NewDot declares its own Gradle dependencies in android/app/build.gradle,
+ * separately from the ones Mobile-Expensify declares for HybridApp, so both
+ * repositories need this check.
+ *
+ * Two limitations, both deliberate:
+ *
+ * - `buildscript { classpath ... }` coordinates are out of scope. They are Gradle
+ *   plugins, reached through `apply plugin:` and the `plugins` block rather than
+ *   through an import, so neither route this check knows about can verify them.
+ * - The manifest is keyed by `group:artifact` with no configuration or source-set
+ *   dimension, and the sources are read as one corpus. The same coordinate
+ *   declared on two configurations collapses to one entry, and a coordinate is
+ *   proved by an import from any source set.
+ */
+
+import fs from 'fs';
+import path from 'path';
+
+import {flavorForExtension, stripComments} from './nativeSourceComments';
+
+type PackagesEntry = {usage: 'packages'; packages: string[]};
+type ResourcesEntry = {usage: 'resources'; resources: string[]};
+type ExemptEntry = {usage: 'exempt'; reason: string; reviewed: string};
+type GradleUsageEntry = PackagesEntry | ResourcesEntry | ExemptEntry;
+type GradleUsageManifest = Record;
+type SearchContext = {sources: string; resources: string};
+
+const ANDROID_APP_DIR = path.resolve(__dirname, '..', 'android', 'app');
+const MANIFEST_PATH = path.join(ANDROID_APP_DIR, 'dependency-usage.json');
+const BUILD_GRADLE_PATH = path.join(ANDROID_APP_DIR, 'build.gradle');
+const RELATIVE_BUILD_GRADLE = 'android/app/build.gradle';
+
+const SOURCE_EXTENSIONS = new Set(['.java', '.kt']);
+const SKIPPED_DIRECTORIES = new Set(['build', '.gradle', '.git', '.cxx', 'node_modules']);
+
+/**
+ * Gradle names a configuration either on its own (`implementation`), prefixed by
+ * a source set or variant (`testImplementation`, `debugCompileOnly`), or suffixed
+ * for the annotation processors (`kaptTest`). All three forms declare a
+ * dependency that can go stale, so all three have to be read.
+ */
+const BASE_CONFIGURATIONS = [
+    'implementation',
+    'api',
+    'compileOnlyApi',
+    'compileOnly',
+    'runtimeOnly',
+    'annotationProcessor',
+    'kapt',
+    'ksp',
+    'detektPlugins',
+    'coreLibraryDesugaring',
+    'lintChecks',
+    'lintPublish',
+];
+const SUFFIX_CONFIGURATIONS = [
+    'Implementation',
+    'Api',
+    'CompileOnlyApi',
+    'CompileOnly',
+    'RuntimeOnly',
+    'AnnotationProcessor',
+    'CoreLibraryDesugaring',
+    'LintChecks',
+    'LintPublish',
+    'Kapt',
+    'Ksp',
+    'DetektPlugins',
+    'Util',
+];
+const CONFIGURATION = `(?:${BASE_CONFIGURATIONS.join('|')}|[a-zA-Z0-9]+(?:${SUFFIX_CONFIGURATIONS.join('|')})|(?:kapt|ksp)[A-Z][a-zA-Z0-9]*)`;
+const CONFIGURATION_NAME = new RegExp(`^${CONFIGURATION}$`);
+
+/**
+ * Configurations that name something other than an external module dependency,
+ * and so are neither parsed nor reported as unreadable.
+ */
+const IGNORED_CONFIGURATIONS = new Set(['classpath']);
+
+/**
+ * `implementation project(':x')`, `implementation files(...)`: built from this
+ * repository or checked in, so there is no external declaration to go stale.
+ */
+const LOCAL_ARTIFACT = /^\(?\s*(?:project|files|fileTree|gradleApi|localGroovy)\s*\(/;
+
+/**
+ * `implementation jscFlavor`: a Gradle variable resolved elsewhere in the build,
+ * whose value the manifest cannot key on.
+ */
+const GRADLE_VARIABLE = /^\(?\s*[A-Za-z_][A-Za-z0-9_]*\s*\)?$/;
+
+/**
+ * `kapt { ... }`: a configuration block that happens to be named after a
+ * configuration, not a dependency declaration.
+ */
+const isDslBlock = (argument: string) => argument.startsWith('{');
+
+/**
+ * `implementation "group:artifact:version"`, with the optional wrappers Gradle
+ * accepts around it. `\s*` spans newlines, so the coordinate may sit on its own
+ * line below the configuration.
+ */
+const STRING_DECLARATION = new RegExp(`^[ \\t]*${CONFIGURATION}\\s*\\(?\\s*(?:(?:platform|enforcedPlatform)\\s*\\()?\\s*['"]([a-zA-Z0-9_.-]+):([a-zA-Z0-9_.-]+)`, 'gm');
+
+/**
+ * `implementation group: 'g', name: 'a', version: 'v'`.
+ */
+const MAP_DECLARATION = new RegExp(`^[ \\t]*${CONFIGURATION}\\s*\\(?\\s*group\\s*:\\s*['"]([a-zA-Z0-9_.-]+)['"]\\s*,\\s*name\\s*:\\s*['"]([a-zA-Z0-9_.-]+)['"]`, 'gm');
+
+/**
+ * `implementation okhttp`, where `okhttp` was assigned a coordinate literal
+ * earlier in the file. Without resolving these, hoisting a coordinate into a
+ * variable while tidying up versions would take it out of the check's sight.
+ */
+const VARIABLE_DECLARATION = new RegExp(`^[ \\t]*${CONFIGURATION}\\s*\\(?\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*\\)?[ \\t]*$`, 'gm');
+const STRING_CONSTANT = /^[ \t]*(?:def|final\s+String|String)?\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*['"]([a-zA-Z0-9_.:+-]+)['"]/gm;
+const COORDINATE = /^([a-zA-Z0-9_.-]+):([a-zA-Z0-9_.-]+)/;
+
+const readBuildGradle = () => stripComments(fs.readFileSync(BUILD_GRADLE_PATH, 'utf8'), 'c');
+
+/**
+ * Character offsets in `text` where a declaration pattern started matching,
+ * used to tell a line the parser read from one it skipped.
+ */
+function collectDeclarations(text: string): {coordinates: Set; matchedOffsets: Set} {
+    const coordinates = new Set();
+    const matchedOffsets = new Set();
+    for (const pattern of [STRING_DECLARATION, MAP_DECLARATION]) {
+        for (const match of text.matchAll(pattern)) {
+            coordinates.add(`${match.at(1)}:${match.at(2)}`);
+            matchedOffsets.add(match.index ?? 0);
+        }
+    }
+
+    const constants = new Map();
+    for (const match of text.matchAll(STRING_CONSTANT)) {
+        constants.set(match.at(1) ?? '', match.at(2) ?? '');
+    }
+    for (const match of text.matchAll(VARIABLE_DECLARATION)) {
+        const coordinate = COORDINATE.exec(constants.get(match.at(1) ?? '') ?? '');
+        if (coordinate) {
+            coordinates.add(`${coordinate.at(1)}:${coordinate.at(2)}`);
+            matchedOffsets.add(match.index ?? 0);
+        }
+    }
+
+    return {coordinates, matchedOffsets};
+}
+
+/**
+ * Every `group:artifact` coordinate declared in android/app/build.gradle on a
+ * dependency configuration, including the ones named through a variable holding
+ * a coordinate literal. Local artifacts (`files(...)`, `project(...)`) are
+ * skipped: there is no external declaration to go stale.
+ */
+function readDeclaredDependencies(): string[] {
+    return [...collectDeclarations(readBuildGradle()).coordinates].sort();
+}
+
+/**
+ * Lines that declare a dependency in a form this parser cannot read: a version
+ * catalog accessor, an interpolated coordinate, anything new. Without this the
+ * parser would drop them silently and the check would report success over
+ * whatever subset it still understood, which is how a version-catalog migration
+ * turns the whole gate into a no-op.
+ */
+function findUnparsedDeclarations(): string[] {
+    const text = readBuildGradle();
+    const {matchedOffsets} = collectDeclarations(text);
+
+    const matchesPerLine = new Map();
+    for (const offset of matchedOffsets) {
+        const line = text.slice(0, offset).split('\n').length - 1;
+        matchesPerLine.set(line, (matchesPerLine.get(line) ?? 0) + 1);
+    }
+
+    const unparsed: string[] = [];
+    for (const [index, line] of text.split('\n').entries()) {
+        // The separator after the name has to be whitespace or an opening paren.
+        // `implementation.exclude group: ...` and `implementation.transitive =
+        // false` configure a configuration rather than declaring anything.
+        const match = /^[ \t]*([A-Za-z][A-Za-z0-9]*)(?=[\s(]|$)(.*)$/.exec(line);
+        const configuration = match?.at(1);
+        const argument = (match?.at(2) ?? '').trim();
+        // Every coordinate-shaped literal on the line has to have been parsed,
+        // not just the first: the patterns are anchored to the line start, so a
+        // second declaration after a semicolon or a comma would otherwise be
+        // covered by the first one having matched.
+        const literals = (line.match(/['"][a-zA-Z0-9_.-]+:[a-zA-Z0-9_.-]+/g) ?? []).length;
+        const parsed = matchesPerLine.get(index) ?? 0;
+        const readable = (parsed > 0 || LOCAL_ARTIFACT.test(argument) || GRADLE_VARIABLE.test(argument) || isDslBlock(argument)) && literals <= parsed;
+        if (configuration && !IGNORED_CONFIGURATIONS.has(configuration) && CONFIGURATION_NAME.test(configuration) && !readable) {
+            unparsed.push(`${RELATIVE_BUILD_GRADLE}:${index + 1}: ${line.trim()}`);
+        }
+    }
+    return unparsed;
+}
+
+function readFilesWithExtensions(extensions: Set): string {
+    const contents: string[] = [];
+    const walk = (dir: string) => {
+        for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
+            if (entry.isDirectory()) {
+                if (!SKIPPED_DIRECTORIES.has(entry.name)) {
+                    walk(path.join(dir, entry.name));
+                }
+            } else if (extensions.has(path.extname(entry.name))) {
+                const extension = path.extname(entry.name);
+                contents.push(stripComments(fs.readFileSync(path.join(dir, entry.name), 'utf8'), flavorForExtension(extension)));
+            }
+        }
+    };
+    walk(ANDROID_APP_DIR);
+    return contents.join('\n');
+}
+
+const readSources = () => readFilesWithExtensions(SOURCE_EXTENSIONS);
+const readResources = () => readFilesWithExtensions(new Set(['.xml']));
+
+const escapeForRegExp = (value: string) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
+
+/**
+ * How many times the project reaches a dependency, by the route its manifest
+ * entry declares: `packages` through Java and Kotlin imports, `resources`
+ * through anything in the XML — a view class name in a layout, a style parent,
+ * an attr. The `resources` match is bounded on both sides so that a name cannot
+ * be satisfied by being the tail of a longer, unrelated one.
+ */
+function countReferences({sources, resources}: SearchContext, entry: GradleUsageEntry): number {
+    if (entry.usage === 'packages') {
+        return entry.packages.reduce((total, prefix) => total + (sources.match(new RegExp(`^import\\s+(?:static\\s+)?${escapeForRegExp(prefix)}[.;\\s]`, 'gm'))?.length ?? 0), 0);
+    }
+    if (entry.usage === 'resources') {
+        return entry.resources.reduce((total, name) => total + (resources.match(new RegExp(`(? => typeof value === 'object' && value !== null;
+
+/**
+ * A manifest entry is trusted only once its shape is verified; the check reports
+ * the human-facing problems (a missing reason, an unusable date) separately.
+ */
+function isGradleUsageEntry(value: unknown): value is GradleUsageEntry {
+    if (!isRecord(value)) {
+        return false;
+    }
+    if (value.usage === 'packages') {
+        return Array.isArray(value.packages) && value.packages.every((prefix) => typeof prefix === 'string');
+    }
+    if (value.usage === 'resources') {
+        return Array.isArray(value.resources) && value.resources.every((name) => typeof name === 'string');
+    }
+    if (value.usage === 'exempt') {
+        return typeof value.reason === 'string' && typeof value.reviewed === 'string';
+    }
+    return false;
+}
+
+function readManifest(): GradleUsageManifest {
+    const parsed: unknown = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
+    if (!isRecord(parsed)) {
+        throw new Error(`${MANIFEST_PATH} must contain a JSON object.`);
+    }
+    const manifest: GradleUsageManifest = {};
+    for (const [coordinate, entry] of Object.entries(parsed)) {
+        if (!isGradleUsageEntry(entry)) {
+            throw new Error(`${coordinate} in ${MANIFEST_PATH} must be a packages, resources or exempt entry.`);
+        }
+        manifest[coordinate] = entry;
+    }
+    return manifest;
+}
+
+function writeManifest(manifest: GradleUsageManifest) {
+    const sorted = Object.fromEntries(
+        Object.keys(manifest)
+            .sort()
+            .map((key) => [key, manifest[key]]),
+    );
+    fs.writeFileSync(MANIFEST_PATH, `${JSON.stringify(sorted, null, 4)}\n`);
+}
+
+export type {GradleUsageEntry, GradleUsageManifest, SearchContext};
+export {
+    ANDROID_APP_DIR,
+    MANIFEST_PATH,
+    BUILD_GRADLE_PATH,
+    RELATIVE_BUILD_GRADLE,
+    readDeclaredDependencies,
+    findUnparsedDeclarations,
+    readSources,
+    readResources,
+    countReferences,
+    readManifest,
+    writeManifest,
+};
diff --git a/scripts/nativeSourceComments.ts b/scripts/nativeSourceComments.ts
new file mode 100644
index 000000000000..957a146b8464
--- /dev/null
+++ b/scripts/nativeSourceComments.ts
@@ -0,0 +1,224 @@
+/**
+ * Comment stripping for the native dependency checks.
+ *
+ * Both checks decide whether a declaration is alive by matching patterns against
+ * source text. Matching raw text counts a commented-out import as a live
+ * reference, and reads a commented-out declaration as a real one, so every
+ * declaration a developer parks behind a comment while debugging either keeps a
+ * dead dependency green or fails the check for something Gradle never sees.
+ * Stripping comments first is what makes both answers honest.
+ *
+ * Newlines inside a stripped comment are preserved so that line-anchored
+ * patterns keep matching the lines they were written for, and string bodies are
+ * emitted verbatim so that this can never delete code.
+ */
+
+/**
+ * Swift and Kotlin nest block comments; C, C++, Objective-C, Java and Groovy do
+ * not. Treating a nested opener as nesting in the languages that do not would
+ * swallow every line between the comment's real end and the next closer, which
+ * silently shrinks the set of declarations and imports the checks can see.
+ */
+type CommentFlavor = 'c' | 'swift' | 'kotlin' | 'ruby' | 'xml';
+
+const NESTING_FLAVORS = new Set(['swift', 'kotlin']);
+
+/**
+ * Swift has no char literal, so a lone apostrophe there is punctuation rather
+ * than the start of one. Everywhere else `'...'` is a char literal, or in Groovy
+ * a string, and has to be consumed as one.
+ */
+const CHAR_LITERAL_FLAVORS = new Set(['c', 'kotlin']);
+
+/**
+ * The comment flavor a file extension implies. Anything unrecognized is treated
+ * as the non-nesting C family, which is the safe default: it can leave a comment
+ * standing, but it can never discard code.
+ */
+function flavorForExtension(extension: string): CommentFlavor {
+    if (extension === '.swift') {
+        return 'swift';
+    }
+    if (extension === '.kt' || extension === '.kts') {
+        return 'kotlin';
+    }
+    if (extension === '.xml') {
+        return 'xml';
+    }
+    return 'c';
+}
+
+/**
+ * The delimiter that closes a string starting at `index`, and its length, or
+ * undefined when no string starts there. Handles `"`, triple-quoted blocks, and
+ * Swift raw strings such as `#"..."#` and `##"..."##`.
+ */
+function stringOpener(source: string, index: number, flavor: CommentFlavor): {open: string; close: string} | undefined {
+    if (flavor === 'swift') {
+        const raw = /^(#+)"(#*)/.exec(source.slice(index, index + 16));
+        const hashes = raw?.at(1);
+        if (hashes) {
+            const isTriple = source.startsWith(`${hashes}"""`, index);
+            return {open: isTriple ? `${hashes}"""` : `${hashes}"`, close: isTriple ? `"""${hashes}` : `"${hashes}`};
+        }
+    }
+    if (source.startsWith('"""', index)) {
+        return {open: '"""', close: '"""'};
+    }
+    if (source[index] === '"') {
+        return {open: '"', close: '"'};
+    }
+    if (source[index] === "'" && CHAR_LITERAL_FLAVORS.has(flavor)) {
+        return {open: "'", close: "'"};
+    }
+    return undefined;
+}
+
+function stripCFamilyComments(source: string, flavor: CommentFlavor): string {
+    const nested = NESTING_FLAVORS.has(flavor);
+    let output = '';
+    let index = 0;
+    let blockDepth = 0;
+
+    while (index < source.length) {
+        if (blockDepth > 0) {
+            if (nested && source.startsWith('/*', index)) {
+                blockDepth++;
+                index += 2;
+            } else if (source.startsWith('*/', index)) {
+                blockDepth--;
+                index += 2;
+            } else {
+                if (source[index] === '\n') {
+                    output += '\n';
+                }
+                index++;
+            }
+            continue;
+        }
+
+        if (source.startsWith('//', index)) {
+            while (index < source.length && source[index] !== '\n') {
+                index++;
+            }
+            continue;
+        }
+
+        if (source.startsWith('/*', index)) {
+            blockDepth = 1;
+            index += 2;
+            continue;
+        }
+
+        const delimiter = stringOpener(source, index, flavor);
+        if (delimiter) {
+            output += delimiter.open;
+            index += delimiter.open.length;
+            while (index < source.length) {
+                if (source[index] === '\\') {
+                    output += source.slice(index, index + 2);
+                    index += 2;
+                    continue;
+                }
+                if (source.startsWith(delimiter.close, index)) {
+                    // A triple-quoted body ending in a quote puts four or more
+                    // quotes in a row; the closer is the last three of the run.
+                    let run = index;
+                    while (delimiter.close.startsWith('"""') && source[run + delimiter.close.length] === '"') {
+                        output += source[run];
+                        run++;
+                    }
+                    output += source.slice(run, run + delimiter.close.length);
+                    index = run + delimiter.close.length;
+                    break;
+                }
+                // An unterminated char literal is far more likely to be an
+                // apostrophe than a literal, so do not let it swallow the file.
+                if (source[index] === '\n' && delimiter.close === "'") {
+                    break;
+                }
+                output += source[index];
+                index++;
+            }
+            continue;
+        }
+
+        output += source[index];
+        index++;
+    }
+
+    return output;
+}
+
+/**
+ * `#` line comments and `=begin` / `=end` blocks, leaving `#` inside strings and
+ * `#{}` interpolation alone.
+ */
+function stripRubyComments(source: string): string {
+    const lines = source.split('\n');
+    const output: string[] = [];
+    let inBlock = false;
+
+    for (const line of lines) {
+        if (inBlock) {
+            output.push('');
+            if (/^=end\b/.test(line)) {
+                inBlock = false;
+            }
+            continue;
+        }
+        if (/^=begin\b/.test(line)) {
+            inBlock = true;
+            output.push('');
+            continue;
+        }
+
+        let stripped = '';
+        let quote: string | undefined;
+        for (let index = 0; index < line.length; index++) {
+            const character = line[index];
+            if (quote) {
+                stripped += character;
+                if (character === '\\') {
+                    stripped += line[index + 1] ?? '';
+                    index++;
+                } else if (character === quote) {
+                    quote = undefined;
+                }
+                continue;
+            }
+            if (character === '"' || character === "'") {
+                quote = character;
+                stripped += character;
+                continue;
+            }
+            if (character === '#') {
+                break;
+            }
+            stripped += character;
+        }
+        output.push(stripped);
+    }
+
+    return output.join('\n');
+}
+
+function stripXmlComments(source: string): string {
+    // An unterminated opener runs to the end of the file; leaving it in place
+    // would keep whatever was commented out at the end of a layout counting as a
+    // live resource reference.
+    return source.replaceAll(/|[\s\S]*$)/g, (comment) => comment.replaceAll(/[^\n]/g, ''));
+}
+
+function stripComments(source: string, flavor: CommentFlavor): string {
+    if (flavor === 'ruby') {
+        return stripRubyComments(source);
+    }
+    if (flavor === 'xml') {
+        return stripXmlComments(source);
+    }
+    return stripCFamilyComments(source, flavor);
+}
+
+export type {CommentFlavor};
+export {stripComments, flavorForExtension};
diff --git a/scripts/nativeUsageManifest.ts b/scripts/nativeUsageManifest.ts
new file mode 100644
index 000000000000..9e96f7071868
--- /dev/null
+++ b/scripts/nativeUsageManifest.ts
@@ -0,0 +1,62 @@
+/**
+ * Validation shared by the pod and Gradle dependency checks.
+ *
+ * An `exempt` entry is the one route that skips verification entirely, so the
+ * only thing standing behind it is the reason a human wrote and the date they
+ * wrote it. That makes the date worth validating as a date: `9999-99-99` and
+ * `2026-02-30` both pass a `\d{4}-\d{2}-\d{2}` shape test, and a future date is
+ * either a typo or a way to park a review forever.
+ *
+ * Age is reported as a warning rather than a failure on purpose. Failing on it
+ * would turn every exemption into a build that breaks on a date nobody chose,
+ * with no code change to explain it.
+ */
+
+const STALE_AFTER_DAYS = 365;
+const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
+
+type ReviewedDateResult = {error?: string; warning?: string};
+
+/**
+ * Checks `reviewed` is a real calendar date, is not in the future, and says how
+ * old it is.
+ */
+function validateReviewedDate(reviewed: string | undefined, label: string): ReviewedDateResult {
+    const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(reviewed ?? '');
+    if (!match) {
+        return {error: `${label}: "reviewed" must be a date in the YYYY-MM-DD format.`};
+    }
+
+    const [year, month, day] = match.slice(1).map(Number);
+    const parsed = new Date(Date.UTC(year, month - 1, day));
+    if (parsed.getUTCFullYear() !== year || parsed.getUTCMonth() !== month - 1 || parsed.getUTCDate() !== day) {
+        return {error: `${label}: "reviewed" is ${reviewed ?? ''}, which is not a real date.`};
+    }
+
+    // The date is compared against UTC midnight while a developer writes their
+    // own local date, so a day of slack keeps every timezone from UTC-12 to
+    // UTC+14 out of the future.
+    const ageInDays = Math.floor((Date.now() - parsed.getTime()) / MILLISECONDS_PER_DAY);
+    if (ageInDays < -1) {
+        return {error: `${label}: "reviewed" is ${reviewed ?? ''}, which is in the future.`};
+    }
+    if (ageInDays > STALE_AFTER_DAYS) {
+        return {warning: `${label}: last reviewed ${ageInDays} days ago (${reviewed ?? ''}). Confirm the reason still holds and update the date.`};
+    }
+    return {};
+}
+
+/**
+ * Rejects the empty strings a hand-edited manifest can pick up. An empty pattern
+ * matches at every position, so one empty entry would make a dependency
+ * unconditionally reachable.
+ */
+function findBlankEntry(values: string[], label: string, field: string): string | undefined {
+    if (values.some((value) => !value.trim())) {
+        return `${label}: "${field}" must not contain an empty string.`;
+    }
+    return undefined;
+}
+
+export type {ReviewedDateResult};
+export {STALE_AFTER_DAYS, validateReviewedDate, findBlankEntry};
diff --git a/scripts/podUsageShared.ts b/scripts/podUsageShared.ts
new file mode 100644
index 000000000000..c00138ab4333
--- /dev/null
+++ b/scripts/podUsageShared.ts
@@ -0,0 +1,158 @@
+/**
+ * Shared between generatePodUsage.ts and checkUnusedPods.ts.
+ *
+ * NewDot declares its own pods in ios/Podfile, separately from the ones
+ * Mobile-Expensify declares for HybridApp, so both repositories need this check.
+ *
+ * Known limitation, deliberately not covered: the manifest is keyed by pod name
+ * alone, with no target dimension, and the sources are read as one corpus. A pod
+ * declared for one target is therefore proved by an import from another. Fixing
+ * that means resolving Podfile targets against Xcode target membership, which
+ * this check does not attempt.
+ */
+
+import fs from 'fs';
+import path from 'path';
+
+import {flavorForExtension, stripComments} from './nativeSourceComments';
+
+type ModuleEntry = {usage: 'module'; module: string};
+type HeadersEntry = {usage: 'headers'; headers: string[]};
+type ExemptEntry = {usage: 'exempt'; reason: string; reviewed: string};
+type PodUsageEntry = ModuleEntry | HeadersEntry | ExemptEntry;
+type PodUsageManifest = Record;
+
+const IOS_DIR = path.resolve(__dirname, '..', 'ios');
+const MANIFEST_PATH = path.join(IOS_DIR, 'pod-usage.json');
+const PODFILE_PATH = path.join(IOS_DIR, 'Podfile');
+
+const SOURCE_EXTENSIONS = new Set(['.swift', '.m', '.mm', '.h', '.pch', '.c', '.cpp', '.cc', '.hpp']);
+const SKIPPED_DIRECTORIES = new Set(['Pods', 'build', 'DerivedData', '.git', 'node_modules']);
+
+const POD_DECLARATION = /^\s*pod\s+['"]([^'"]+)['"]/;
+
+const readPodfile = () => stripComments(fs.readFileSync(PODFILE_PATH, 'utf8'), 'ruby');
+
+/**
+ * Pod names as declared in the Podfile, deduplicated to the pod itself:
+ * `pod 'MaterialComponents/Tabs'` is one declaration of MaterialComponents.
+ */
+function readDeclaredPods(): string[] {
+    const pods = new Set();
+    for (const line of readPodfile().split('\n')) {
+        const match = POD_DECLARATION.exec(line);
+        if (match) {
+            const [pod] = (match.at(1) ?? '').split('/');
+            pods.add(pod);
+        }
+    }
+    return [...pods].sort();
+}
+
+/**
+ * Lines that declare a pod in a form this parser cannot read, so that a Podfile
+ * refactor fails the check loudly instead of quietly shrinking the set of pods
+ * it verifies.
+ */
+function findUnparsedPodDeclarations(): string[] {
+    const unparsed: string[] = [];
+    for (const [index, line] of readPodfile().split('\n').entries()) {
+        if (/^\s*pod\b/.test(line) && !POD_DECLARATION.test(line)) {
+            unparsed.push(`ios/Podfile:${index + 1}: ${line.trim()}`);
+        }
+    }
+    return unparsed;
+}
+
+function readSources(): string {
+    const contents: string[] = [];
+    const walk = (dir: string) => {
+        for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
+            if (entry.isDirectory()) {
+                if (!SKIPPED_DIRECTORIES.has(entry.name)) {
+                    walk(path.join(dir, entry.name));
+                }
+            } else if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
+                const extension = path.extname(entry.name);
+                contents.push(stripComments(fs.readFileSync(path.join(dir, entry.name), 'utf8'), flavorForExtension(extension)));
+            }
+        }
+    };
+    walk(IOS_DIR);
+    return contents.join('\n');
+}
+
+const escapeForRegExp = (value: string) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
+
+/**
+ * How many times the sources reach a pod, by the route its manifest entry
+ * declares. Every pattern is anchored to the start of a line: an import is the
+ * first thing on its line, and anchoring is what stops a mention inside a string
+ * or a trailing note from counting as a reference.
+ */
+function countReferences(sources: string, entry: PodUsageEntry, pod?: string): number {
+    const patterns: RegExp[] = [];
+    if (entry.usage === 'module') {
+        const module = escapeForRegExp(entry.module);
+        patterns.push(new RegExp(`^\\s*import\\s+${module}\\b`, 'gm'), new RegExp(`^\\s*@import\\s+${module}\\b`, 'gm'), new RegExp(`^\\s*#import\\s*<${module}/`, 'gm'));
+    } else if (entry.usage === 'headers') {
+        // Both `#import "Header.h"` and the canonical framework form
+        // `#import ` reach the same header, but the framework
+        // prefix has to be this pod: a generically named header such as
+        // `Version.h` would otherwise be satisfied by any other pod shipping one.
+        const framework = pod ? `(?:${escapeForRegExp(pod)}/)?` : '';
+        for (const header of entry.headers) {
+            patterns.push(new RegExp(`^\\s*#import\\s*["<]${framework}${escapeForRegExp(header)}[">]`, 'gm'));
+        }
+    }
+    return patterns.reduce((total, pattern) => total + (sources.match(pattern)?.length ?? 0), 0);
+}
+
+const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null;
+
+/**
+ * A manifest entry is trusted only once its shape is verified; the check reports
+ * the human-facing problems (a missing reason, an unusable date) separately.
+ */
+function isPodUsageEntry(value: unknown): value is PodUsageEntry {
+    if (!isRecord(value)) {
+        return false;
+    }
+    if (value.usage === 'module') {
+        return typeof value.module === 'string';
+    }
+    if (value.usage === 'headers') {
+        return Array.isArray(value.headers) && value.headers.every((header) => typeof header === 'string');
+    }
+    if (value.usage === 'exempt') {
+        return typeof value.reason === 'string' && typeof value.reviewed === 'string';
+    }
+    return false;
+}
+
+function readManifest(): PodUsageManifest {
+    const parsed: unknown = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
+    if (!isRecord(parsed)) {
+        throw new Error(`${MANIFEST_PATH} must contain a JSON object.`);
+    }
+    const manifest: PodUsageManifest = {};
+    for (const [pod, entry] of Object.entries(parsed)) {
+        if (!isPodUsageEntry(entry)) {
+            throw new Error(`${pod} in ${MANIFEST_PATH} must be a module, headers or exempt entry.`);
+        }
+        manifest[pod] = entry;
+    }
+    return manifest;
+}
+
+function writeManifest(manifest: PodUsageManifest) {
+    const sorted = Object.fromEntries(
+        Object.keys(manifest)
+            .sort()
+            .map((key) => [key, manifest[key]]),
+    );
+    fs.writeFileSync(MANIFEST_PATH, `${JSON.stringify(sorted, null, 4)}\n`);
+}
+
+export type {PodUsageEntry, PodUsageManifest};
+export {IOS_DIR, MANIFEST_PATH, PODFILE_PATH, readDeclaredPods, findUnparsedPodDeclarations, readSources, countReferences, readManifest, writeManifest};
diff --git a/src/Expensify.tsx b/src/Expensify.tsx
index 41a1d47b7363..ef75ca168830 100644
--- a/src/Expensify.tsx
+++ b/src/Expensify.tsx
@@ -23,6 +23,7 @@ import useDebugShortcut from './hooks/useDebugShortcut';
 import useIsAuthenticated from './hooks/useIsAuthenticated';
 import useLocalize from './hooks/useLocalize';
 import useOnyx from './hooks/useOnyx';
+import useSaveMarketingAttribution from './hooks/useSaveMarketingAttribution';
 import {updateLastRoute} from './libs/actions/App';
 import {initReconnect} from './libs/actions/Reconnect';
 import * as ActiveClientManager from './libs/ActiveClientManager';
@@ -67,6 +68,7 @@ function Expensify() {
     const [updateRequired] = useOnyx(ONYXKEYS.RAM_ONLY_UPDATE_REQUIRED);
     const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH);
     useDebugShortcut();
+    useSaveMarketingAttribution();
 
     useEffect(() => {
         initializeTelemetryTrackers();
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 3c4c056e03ac..4c1b16c744b9 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -75,6 +75,9 @@ const ONYXKEYS = {
     CREDENTIALS: 'credentials',
     STASHED_CREDENTIALS: 'stashedCredentials',
 
+    /** Marketing attribution (UTM params and Google Ads click ID) captured from the landing URL */
+    MARKETING_ATTRIBUTION: 'marketingAttribution',
+
     /** Keeps track if there is modal currently visible or not */
     MODAL: 'modal',
 
@@ -906,6 +909,9 @@ const ONYXKEYS = {
     /** Stores the user's report details columns preference */
     NVP_REPORT_DETAILS_COLUMNS: 'nvp_reportDetailsColumns',
 
+    /** Dragged table column widths, keyed by `columnResizingID` then column key. Per device, since ideal widths depend on window size. */
+    TABLE_COLUMN_WIDTHS: 'tableColumnWidths',
+
     /** Partial transaction data used for MFA authorize transaction preview */
     TRANSACTIONS_PENDING_3DS_REVIEW: 'transactionsPending3DSReview',
 
@@ -1692,6 +1698,7 @@ type OnyxValuesMapping = {
     [ONYXKEYS.CURRENT_DATE]: string;
     [ONYXKEYS.CREDENTIALS]: OnyxTypes.Credentials;
     [ONYXKEYS.STASHED_CREDENTIALS]: OnyxTypes.Credentials;
+    [ONYXKEYS.MARKETING_ATTRIBUTION]: OnyxTypes.StoredMarketingAttribution;
     [ONYXKEYS.MODAL]: OnyxTypes.Modal;
     [ONYXKEYS.IS_OPEN_APP_FAILURE_MODAL_OPEN]: boolean;
     [ONYXKEYS.IS_GPS_IN_PROGRESS_MODAL_OPEN]: boolean;
@@ -1968,6 +1975,7 @@ type OnyxValuesMapping = {
     [ONYXKEYS.NVP_REPORT_LAYOUT_GROUP_BY]: string;
     [ONYXKEYS.NVP_REPORT_LAYOUT_OPTION]: string;
     [ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS]: string[];
+    [ONYXKEYS.TABLE_COLUMN_WIDTHS]: OnyxTypes.TableColumnWidths;
     [ONYXKEYS.HAS_DENIED_CONTACT_IMPORT_PROMPT]: boolean | undefined;
     [ONYXKEYS.PERSONAL_POLICY_ID]: string;
     [ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS]: Record;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 48c0896e20d0..3f7300a0957d 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -2077,8 +2077,17 @@ const DYNAMIC_ROUTES = {
     MONEY_REQUEST_UPGRADE: {
         path: 'money-request-upgrade',
         entryScreens: ['*'],
-        getRoute: (params: {action: IOUAction; iouType: IOUType; transactionID: string; reportID: string; upgradeBackTo?: string; shouldSubmitExpense?: boolean; upgradePath?: string}) => {
-            const {action, iouType, transactionID, reportID, upgradeBackTo, shouldSubmitExpense, upgradePath} = params;
+        getRoute: (params: {
+            action: IOUAction;
+            iouType: IOUType;
+            transactionID: string;
+            reportID: string;
+            upgradeBackTo?: string;
+            shouldSubmitExpense?: boolean;
+            upgradePath?: string;
+            shouldReturnToConfirmation?: boolean;
+        }) => {
+            const {action, iouType, transactionID, reportID, upgradeBackTo, shouldSubmitExpense, upgradePath, shouldReturnToConfirmation} = params;
             return getUrlWithParams('money-request-upgrade', {
                 action,
                 iouType,
@@ -2087,9 +2096,10 @@ const DYNAMIC_ROUTES = {
                 upgradeBackTo,
                 shouldSubmitExpense: shouldSubmitExpense ? 'true' : undefined,
                 upgradePath,
+                shouldReturnToConfirmation: shouldReturnToConfirmation ? 'true' : undefined,
             });
         },
-        queryParams: ['action', 'iouType', 'transactionID', 'reportID', 'upgradeBackTo', 'shouldSubmitExpense', 'upgradePath'],
+        queryParams: ['action', 'iouType', 'transactionID', 'reportID', 'upgradeBackTo', 'shouldSubmitExpense', 'upgradePath', 'shouldReturnToConfirmation'],
     },
 } as const satisfies DynamicRoutes;
 
diff --git a/src/components/ApprovalWorkflowSection.tsx b/src/components/ApprovalWorkflowSection.tsx
index 942e62683cac..c8e9c09800ac 100644
--- a/src/components/ApprovalWorkflowSection.tsx
+++ b/src/components/ApprovalWorkflowSection.tsx
@@ -6,10 +6,11 @@ import useTheme from '@hooks/useTheme';
 import useThemeStyles from '@hooks/useThemeStyles';
 
 import sortAlphabetically from '@libs/sortAlphabetically';
-import {getApprovalLimitDescription} from '@libs/WorkflowUtils';
+import {getApprovalLimitDescription, getNonMemberApproverError} from '@libs/WorkflowUtils';
 
 import CONST from '@src/CONST';
 import type ApprovalWorkflow from '@src/types/onyx/ApprovalWorkflow';
+import type {Approver} from '@src/types/onyx/ApprovalWorkflow';
 
 import {Str} from 'expensify-common';
 import React from 'react';
@@ -111,6 +112,17 @@ function ApprovalWorkflowSection({
         email: m.email,
     }));
     const pressAction = isDisabled ? undefined : onPress;
+
+    // Only shown to someone who can fix it, since the copy asks them to pick a new approver
+    const getApproverError = (approver: Approver) => {
+        const error = isDisabled ? undefined : getNonMemberApproverError(approver, approvalWorkflow.isDefault);
+        return error ? translate(error) : undefined;
+    };
+    const nonMemberApproverError = approvalWorkflow.approvers.map(getApproverError).find(Boolean);
+    const editWorkflowText = translate('workflowsPage.editWorkflowAction');
+
+    // The approver rows aren't exposed to screen readers, so the Edit button reads the error out instead
+    const editButtonAccessibilityLabel = nonMemberApproverError ? `${editWorkflowText}, ${nonMemberApproverError}` : editWorkflowText;
     const accessibilityLabel = translate('workflowsPage.accessibilityLabel', {
         members,
         approvers: approvalWorkflow?.approvers
@@ -210,6 +222,8 @@ function ApprovalWorkflowSection({
                                 }
                                 helperText={getApprovalLimitDescription({approver, currency, translate, formatPhoneNumber, convertToDisplayString})}
                                 helperTextStyle={styles.workflowApprovalLimitText}
+                                brickRoadIndicator={getApproverError(approver) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
+                                errorText={getApproverError(approver)}
                                 sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.WORKFLOWS.APPROVAL_SECTION_APPROVER}
                             />
                         
@@ -221,11 +235,11 @@ function ApprovalWorkflowSection({
                     
                 
             )}
diff --git a/src/components/InitialURLContextProvider.tsx b/src/components/InitialURLContextProvider.tsx
index d4b44a9d1778..74af48f8c47c 100644
--- a/src/components/InitialURLContextProvider.tsx
+++ b/src/components/InitialURLContextProvider.tsx
@@ -1,3 +1,4 @@
+import CONST from '@src/CONST';
 import type {Route} from '@src/ROUTES';
 
 import type {ReactNode} from 'react';
@@ -7,6 +8,7 @@ import {Linking} from 'react-native';
 
 type InitialUrlStateContextType = {
     initialURL: Route | null;
+    isLoadingInitialURL: boolean;
     isAuthenticatedAtStartup: boolean;
 };
 
@@ -23,6 +25,7 @@ const defaultInitialURLActionsContext: InitialUrlActionsContextType = {
 /** Initial url that will be opened when NewDot is embedded into Hybrid App. */
 const InitialURLStateContext = createContext({
     initialURL: null,
+    isLoadingInitialURL: true,
     isAuthenticatedAtStartup: false,
 });
 
@@ -35,21 +38,37 @@ type InitialURLContextProviderProps = {
 
 function InitialURLContextProvider({children}: InitialURLContextProviderProps) {
     const [initialURL, setInitialURL] = useState(null);
+    const [isLoadingInitialURL, setIsLoadingInitialURL] = useState(true);
     const [isAuthenticatedAtStartup, setIsAuthenticatedAtStartup] = useState(false);
 
     useEffect(() => {
-        Linking.getInitialURL().then((initURL) => {
-            if (!initURL) {
-                return;
-            }
-            setInitialURL(initURL as Route);
-        });
+        let timeoutId: ReturnType;
+
+        // Race against a timeout so isLoadingInitialURL doesn't stay stuck if getInitialURL() never resolves
+        Promise.race([
+            Linking.getInitialURL(),
+            new Promise((resolve) => {
+                timeoutId = setTimeout(() => {
+                    resolve(null);
+                }, CONST.TIMING.GET_INITIAL_URL_TIMEOUT);
+            }),
+        ])
+            .then((initURL) => {
+                if (!initURL) {
+                    return;
+                }
+                setInitialURL(initURL as Route);
+            })
+            .finally(() => setIsLoadingInitialURL(false));
+
+        return () => clearTimeout(timeoutId);
     }, []);
 
     // Because of the React Compiler we don't need to memoize it manually
     // eslint-disable-next-line react/jsx-no-constructed-context-values
     const stateContextValue = {
         initialURL,
+        isLoadingInitialURL,
         isAuthenticatedAtStartup,
     };
 
diff --git a/src/components/MoneyRequestConfirmationList/sections/CategoryField.tsx b/src/components/MoneyRequestConfirmationList/sections/CategoryField.tsx
index 3b10795901c6..582240122314 100644
--- a/src/components/MoneyRequestConfirmationList/sections/CategoryField.tsx
+++ b/src/components/MoneyRequestConfirmationList/sections/CategoryField.tsx
@@ -1,28 +1,30 @@
 import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
 import {useConfirmationFields} from '@components/MoneyRequestConfirmationFields/context';
+import usePolicyCategoriesForConfirmation from '@components/MoneyRequestConfirmationList/hooks/usePolicyCategoriesForConfirmation';
 
 import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
 import useLocalize from '@hooks/useLocalize';
-import useOnyx from '@hooks/useOnyx';
 import usePermissions from '@hooks/usePermissions';
 import useThemeStyles from '@hooks/useThemeStyles';
 
 import {getDecodedLeafCategoryName, isCategoryMissing} from '@libs/CategoryUtils';
-import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
 import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
 import Navigation from '@libs/Navigation/Navigation';
+import TransitionTracker from '@libs/Navigation/TransitionTracker';
 import {hasEnabledOptions} from '@libs/OptionsListUtils';
 
 import CONST from '@src/CONST';
 import type {IOUAction, IOUType} from '@src/CONST';
 import type {TranslationPaths} from '@src/languages/types';
-import ONYXKEYS from '@src/ONYXKEYS';
 import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES';
 import type * as OnyxTypes from '@src/types/onyx';
 
 import type {OnyxEntry} from 'react-native-onyx';
 
-import React from 'react';
+import {useFocusEffect, useIsFocused} from '@react-navigation/native';
+import React, {useEffect, useRef} from 'react';
+
+import type {ExpenseFieldDropdownHandle} from './ExpenseFieldDropdown';
 
 import CategoryFieldDropdown from './CategoryFieldDropdown';
 import ExpenseFieldDropdown from './ExpenseFieldDropdown';
@@ -30,7 +32,8 @@ import {useExpenseFormLayout} from './ExpenseFormLayoutContext';
 import {categoryStateSelector} from './selectors';
 import useTransactionSelector from './useTransactionSelector';
 
-const hasEnabledCategoriesSelector = (policyCategories: OnyxEntry) => hasEnabledOptions(Object.values(policyCategories ?? {}));
+/** idle → departing (row pressed) → away (left for the workspace step) → returned (back on the form) → idle */
+type WorkspaceStep = 'idle' | 'departing' | 'away' | 'returned';
 
 type CategoryFieldProps = {
     isCategoryRequired: boolean;
@@ -67,9 +70,15 @@ function CategoryField({
     const styles = useThemeStyles();
     const {translate} = useLocalize();
     const icons = useMemoizedLazyExpensifyIcons(['Sparkles']);
+    const dropdownRef = useRef(null);
+    const isFocused = useIsFocused();
+    // Where the user is in the workspace step the row sent them to. Scoped to that one departure and return: it
+    // disarms when they leave the form again, so a later unrelated focus cannot open the list by itself.
+    const workspaceStepRef = useRef('idle');
 
     const categoryState = useTransactionSelector(transactionID, categoryStateSelector);
-    const [hasEnabledCategories = false] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getNonEmptyStringOnyxID(policy?.id)}`, {selector: hasEnabledCategoriesSelector});
+    const policyCategories = usePolicyCategoriesForConfirmation(policy?.id);
+    const hasEnabledCategories = hasEnabledOptions(Object.values(policyCategories ?? {}));
 
     const shouldDisplayCategoryError = formError === 'violations.categoryOutOfPolicy';
     const iouCategory = categoryState?.category ?? '';
@@ -96,11 +105,67 @@ function CategoryField({
         return '';
     };
 
+    const canSaveFromThisForm = action !== CONST.IOU.ACTION.EDIT || isEditingSplitBill;
+    const canUseAnchoredFieldDropdowns = isBetaEnabled(CONST.BETAS.ANCHORED_FIELD_DROPDOWNS);
+    const isCreatingExpense = action === CONST.IOU.ACTION.CREATE;
+    // While creating, the list loads the categories itself and shows its own empty state, so it never needs the full page.
+    const canShowCategories = isCreatingExpense || hasEnabledCategories;
+    const shouldOpenInDropdown =
+        canUseAnchoredFieldDropdowns && !!transactionID && !!policy && !shouldNavigateToUpgradePath && !shouldSelectPolicy && canShowCategories && canSaveFromThisForm;
+    // The one condition both the row and the effect below open on, so the effect never asks the row to open a
+    // list it would refuse and fall back to the full page for.
+    const canOpenListInPlace = shouldOpenInDropdown && !isReadOnly && !didConfirm;
+    const canOpenInDropdownAfterWorkspaceStep = canUseAnchoredFieldDropdowns && shouldUseDropdownRows && isCreatingExpense;
+
+    // Blurs move the step along: the first is the departure to the workspace step, the next after returning means
+    // the user left the form for something else, and the arm must not survive that.
+    useEffect(() => {
+        if (isFocused) {
+            return;
+        }
+        if (workspaceStepRef.current === 'departing') {
+            workspaceStepRef.current = 'away';
+        } else if (workspaceStepRef.current === 'returned') {
+            workspaceStepRef.current = 'idle';
+        }
+    }, [isFocused]);
+
+    // The row sent the user to create or pick a workspace, and the upgrade step was told to only go back (`shouldReturnToConfirmation`).
+    // Once the form is focused again with a workspace, wait for the RHP to finish closing, then open the list in place.
+    useFocusEffect(() => {
+        if (workspaceStepRef.current === 'away') {
+            workspaceStepRef.current = 'returned';
+        }
+        if (workspaceStepRef.current !== 'returned') {
+            return;
+        }
+
+        // The user backed out without a workspace, so there is still nothing to list.
+        if (shouldNavigateToUpgradePath || shouldSelectPolicy) {
+            workspaceStepRef.current = 'idle';
+            return;
+        }
+
+        if (!canOpenListInPlace) {
+            return;
+        }
+
+        const handle = TransitionTracker.runAfterTransitions({
+            callback: () => {
+                workspaceStepRef.current = 'idle';
+                dropdownRef.current?.open();
+            },
+            waitForUpcomingTransition: 'navigation',
+        });
+        return () => handle.cancel();
+    });
+
     const openCategoryPage = () => {
         if (!transactionID) {
             return;
         }
 
+        workspaceStepRef.current = (shouldNavigateToUpgradePath || (!policy && shouldSelectPolicy)) && canOpenInDropdownAfterWorkspaceStep ? 'departing' : 'idle';
         if (shouldNavigateToUpgradePath) {
             Navigation.navigate(
                 createDynamicRoute(
@@ -119,23 +184,16 @@ function CategoryField({
                             }),
                         ),
                         upgradePath: CONST.UPGRADE_PATHS.CATEGORIES,
+                        shouldReturnToConfirmation: canOpenInDropdownAfterWorkspaceStep,
                     }),
                 ),
             );
         } else if (!policy && shouldSelectPolicy) {
-            Navigation.navigate(
-                ROUTES.SET_DEFAULT_WORKSPACE.getRoute(
-                    createDynamicRoute(
-                        DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({
-                            action,
-                            iouType,
-                            transactionID,
-                            reportID,
-                            reportActionID,
-                        }),
-                    ),
-                ),
-            );
+            // Without `navigateTo`, the page goes back to this form once a workspace is picked, and the list opens here.
+            const categoryStepRoute = canOpenInDropdownAfterWorkspaceStep
+                ? undefined
+                : createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute({action, iouType, transactionID, reportID, reportActionID}));
+            Navigation.navigate(ROUTES.SET_DEFAULT_WORKSPACE.getRoute(categoryStepRoute));
         } else {
             Navigation.navigate(
                 createDynamicRoute(
@@ -151,14 +209,10 @@ function CategoryField({
         }
     };
 
-    const canSaveFromThisForm = action !== CONST.IOU.ACTION.EDIT || isEditingSplitBill;
-    const canUseAnchoredFieldDropdowns = isBetaEnabled(CONST.BETAS.ANCHORED_FIELD_DROPDOWNS);
-    const shouldOpenInDropdown =
-        canUseAnchoredFieldDropdowns && !!transactionID && !!policy && !shouldNavigateToUpgradePath && !shouldSelectPolicy && hasEnabledCategories && canSaveFromThisForm;
-
     if (shouldUseDropdownRows) {
         return (
             
                     !!transactionID && (
                          void;
+
+    /** Lets the field open its list without a press, e.g. once the user is back from a step the press sent them to */
+    ref?: Ref;
+};
+
+type ExpenseFieldDropdownHandle = {
+    /** Opens the list as a press would, falling back to `onPress` when it can't open in place. Does nothing if it is already open. */
+    open: () => void;
 };
 
 /**
@@ -95,7 +103,7 @@ type ExpenseFieldDropdownProps = Omit ({
+        open: () => {
+            if (!shouldOpenInDropdown) {
+                onPress();
+                return;
+            }
+            if (!isVisible) {
+                openDropdown();
+            }
+        },
+    }));
+
     return (
         <>
             ({source: undefined, resolvedImage: undefined});
-    if (isLocalFile && (initialLocalSource.source === undefined || initialLocalSource.resolvedImage !== resolvedReceiptImageStr)) {
+
+    const upgradeCount = useReceiptUpgradeCount(resolvedReceiptImageStr);
+    const [initialLocalSource, setInitialLocalSource] = useState<{source: string | undefined; resolvedImage: string | undefined; upgradeCount: number}>({
+        source: undefined,
+        resolvedImage: undefined,
+        upgradeCount: 0,
+    });
+    if (isLocalFile && (initialLocalSource.source === undefined || initialLocalSource.resolvedImage !== resolvedReceiptImageStr || initialLocalSource.upgradeCount !== upgradeCount)) {
         // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty string fallback is intentional: we want to skip the empty-string case in the OR chain, not treat it as a valid source
-        setInitialLocalSource({source: thumbnailUri || resolvedReceiptImageStr || '', resolvedImage: resolvedReceiptImageStr});
+        setInitialLocalSource({source: thumbnailUri || resolvedReceiptImageStr || '', resolvedImage: resolvedReceiptImageStr, upgradeCount});
     }
 
     // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty string fallback is intentional: we want to skip the empty-string case in the OR chain, not treat it as a valid source
diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx
index fa9f09ab9d60..902069f39815 100644
--- a/src/components/ReportActionItem/MoneyRequestView.tsx
+++ b/src/components/ReportActionItem/MoneyRequestView.tsx
@@ -343,6 +343,7 @@ function MoneyRequestView({
         originalCurrency: transactionOriginalCurrency,
         postedDate: transactionPostedDate,
         convertedAmount: transactionConvertedAmount,
+        convertedTaxAmount: transactionConvertedTaxAmount,
     } = getTransactionDetails(displayTransaction, undefined, undefined, allowNegativeAmount, false) ?? {};
     const transactionAttendees = useAttendees(transaction);
     const isEmptyMerchant = isInvalidMerchantValue(transactionMerchant);
@@ -376,12 +377,9 @@ function MoneyRequestView({
     const shouldShowCard = isFromCardImport && cardProgramName;
 
     const taxRates = policy?.taxRates;
-    const formattedTaxAmount =
-        updatedTransaction?.taxAmount !== undefined
-            ? convertToDisplayString(Math.abs(updatedTransaction?.taxAmount), actualCurrency)
-            : convertToDisplayString(Math.abs(transactionTaxAmount ?? 0), actualCurrency);
+    const formattedTaxAmount = convertToDisplayString(transactionTaxAmount ?? 0, actualCurrency);
     // Skip a zero converted tax (e.g. tax exempt) so we don't render a redundant "Converted 0.00".
-    const formattedConvertedTaxAmount = transaction?.convertedTaxAmount ? convertToDisplayString(Math.abs(transaction.convertedTaxAmount), moneyRequestReport?.currency) : '';
+    const formattedConvertedTaxAmount = transactionConvertedTaxAmount ? convertToDisplayString(transactionConvertedTaxAmount, moneyRequestReport?.currency) : '';
 
     const taxRatesDescription = taxRates?.name;
 
diff --git a/src/components/ReportOrNotFoundGuard.tsx b/src/components/ReportOrNotFoundGuard.tsx
new file mode 100644
index 000000000000..94f9e9bb6d49
--- /dev/null
+++ b/src/components/ReportOrNotFoundGuard.tsx
@@ -0,0 +1,140 @@
+import useOnyx from '@hooks/useOnyx';
+import usePermissions from '@hooks/usePermissions';
+import useReportIsArchived from '@hooks/useReportIsArchived';
+
+import {canAccessReport, hasExpensifyGuidesEmails} from '@libs/ReportUtils';
+
+import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
+import useReportDeepLinkOnOpen from '@pages/inbox/report/useReportDeepLinkOnOpen';
+
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type * as OnyxTypes from '@src/types/onyx';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+
+import type {ReactElement} from 'react';
+import type {OnyxEntry} from 'react-native-onyx';
+
+import {useIsFocused} from '@react-navigation/native';
+import React, {useState} from 'react';
+
+import FullscreenLoadingIndicator from './FullscreenLoadingIndicator';
+
+type ReportOrNotFoundDecision = 'blank' | 'loading' | 'notFound' | 'content';
+
+type ReportOrNotFoundDecisionParams = {
+    shouldRequireReportID: boolean;
+    isReportIdInRoute: boolean;
+    isReportLoaded: boolean;
+    report: OnyxEntry;
+    isLoadingReportData: OnyxEntry;
+    shouldFetchReport: boolean;
+    isDefaultRoomsBetaEnabled: boolean;
+    hasGuidesEmails: boolean;
+    isReportArchived: boolean;
+    isFocused: boolean;
+    hasShownContent: boolean;
+    deleteTransactionNavigateBackUrl: OnyxEntry;
+};
+
+function getReportOrNotFoundDecision({
+    shouldRequireReportID,
+    isReportIdInRoute,
+    isReportLoaded,
+    report,
+    isLoadingReportData,
+    shouldFetchReport,
+    isDefaultRoomsBetaEnabled,
+    hasGuidesEmails,
+    isReportArchived,
+    isFocused,
+    hasShownContent,
+    deleteTransactionNavigateBackUrl,
+}: ReportOrNotFoundDecisionParams): ReportOrNotFoundDecision {
+    if (!shouldRequireReportID && !isReportIdInRoute) {
+        return 'content';
+    }
+
+    const shouldShowFullScreenLoadingIndicator = !isReportLoaded && (isLoadingReportData !== false || shouldFetchReport);
+    const shouldShowNotFoundPage = !isReportLoaded || !canAccessReport(report, isDefaultRoomsBetaEnabled, hasGuidesEmails, isReportArchived);
+
+    // If the content was shown, but it's not anymore, that means the report was deleted, and we are probably navigating out of this screen.
+    // Return null for this case to avoid rendering FullScreenLoadingIndicator or NotFoundPage when animating transition.
+    // We also suppress the NotFound page while a delete-transaction navigation is in flight (e.g. deleting an invoice
+    // navigates back to the invoice room without synchronously removing focus from this details RHP), mirroring ReportNotFoundGuard.
+    if (shouldShowNotFoundPage && hasShownContent && (!isFocused || !!deleteTransactionNavigateBackUrl)) {
+        return 'blank';
+    }
+
+    if (shouldShowFullScreenLoadingIndicator) {
+        return 'loading';
+    }
+
+    if (shouldShowNotFoundPage) {
+        return 'notFound';
+    }
+
+    return 'content';
+}
+
+type ReportOrNotFoundGuardProps = {
+    reportID: string | undefined;
+    shouldRequireReportID?: boolean;
+    children: ReactElement;
+};
+
+function ReportOrNotFoundGuard({reportID, shouldRequireReportID = true, children}: ReportOrNotFoundGuardProps) {
+    const {isBetaEnabled} = usePermissions();
+    const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`);
+    const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`);
+    const [isLoadingReportData] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA);
+    const [deleteTransactionNavigateBackUrl] = useOnyx(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL);
+    const [guideAccountIDs] = useOnyx(ONYXKEYS.DERIVED.GUIDE_ACCOUNT_IDS);
+    const hasGuidesEmails = hasExpensifyGuidesEmails(Object.keys(report?.participants ?? {}).map(Number), guideAccountIDs);
+    const isFocused = useIsFocused();
+    const isReportIdInRoute = !!reportID?.length;
+    const isReportLoaded = !isEmptyObject(report) && !!report?.reportID;
+    const isReportArchived = useReportIsArchived(report?.reportID);
+    // The `isLoadingInitialReportActions` value will become `false` only after the first OpenReport API call is finished (either succeeded or failed)
+    const shouldFetchReport = isReportIdInRoute && reportLoadingState?.isLoadingInitialReportActions !== false;
+
+    useReportDeepLinkOnOpen({reportID, isReportLoaded, shouldFetchReport});
+
+    const [hasShownContent, setHasShownContent] = useState(false);
+    const isDefaultRoomsBetaEnabled = isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS);
+    const decision = getReportOrNotFoundDecision({
+        shouldRequireReportID,
+        isReportIdInRoute,
+        isReportLoaded,
+        report,
+        isLoadingReportData,
+        shouldFetchReport,
+        isDefaultRoomsBetaEnabled,
+        hasGuidesEmails,
+        isReportArchived,
+        isFocused,
+        hasShownContent,
+        deleteTransactionNavigateBackUrl,
+    });
+
+    if (decision === 'content' && !hasShownContent) {
+        setHasShownContent(true);
+    }
+
+    if (decision === 'blank') {
+        return null;
+    }
+
+    if (decision === 'loading') {
+        return ;
+    }
+
+    if (decision === 'notFound') {
+        return ;
+    }
+
+    return children;
+}
+
+export default ReportOrNotFoundGuard;
+export {getReportOrNotFoundDecision};
diff --git a/src/components/SignInButtons/AppleSignIn/index.tsx b/src/components/SignInButtons/AppleSignIn/index.tsx
index 4e5b3c2e62dc..6143d0d78b08 100644
--- a/src/components/SignInButtons/AppleSignIn/index.tsx
+++ b/src/components/SignInButtons/AppleSignIn/index.tsx
@@ -9,8 +9,10 @@ import CONST from '@src/CONST';
 import ONYXKEYS from '@src/ONYXKEYS';
 import type {AppleIDSignInOnFailureEvent, AppleIDSignInOnSuccessEvent} from '@src/types/modules/dom';
 import type Locale from '@src/types/onyx/Locale';
+import type {StoredMarketingAttribution} from '@src/types/onyx/MarketingAttribution';
 
 import type {NativeConfig} from 'react-native-config';
+import type {OnyxEntry} from 'react-native-onyx';
 
 import {useIsFocused} from '@react-navigation/native';
 import React, {useEffect, useState} from 'react';
@@ -53,9 +55,9 @@ const config = {
  * Apple Sign In success and failure listeners.
  */
 
-const successListener = (event: AppleIDSignInOnSuccessEvent, preferredLocale?: Locale) => {
+const successListener = (event: AppleIDSignInOnSuccessEvent, preferredLocale?: Locale, marketingAttribution?: OnyxEntry) => {
     const token = event.detail.authorization.id_token;
-    beginAppleSignIn(token, preferredLocale);
+    beginAppleSignIn(token, preferredLocale, marketingAttribution);
 };
 
 const failureListener = (event: AppleIDSignInOnFailureEvent) => {
@@ -70,6 +72,7 @@ const failureListener = (event: AppleIDSignInOnFailureEvent) => {
  */
 function AppleSignInDiv({isDesktopFlow, onPointerDown}: AppleSignInDivProps) {
     const [preferredLocale] = useOnyx(ONYXKEYS.NVP_PREFERRED_LOCALE);
+    const [marketingAttribution] = useOnyx(ONYXKEYS.MARKETING_ATTRIBUTION);
     useEffect(() => {
         // `init` renders the button, so it must be called after the div is
         // first mounted.
@@ -77,14 +80,17 @@ function AppleSignInDiv({isDesktopFlow, onPointerDown}: AppleSignInDivProps) {
     }, []);
     //  Result listeners need to live within the focused item to avoid duplicate
     //  side effects on success and failure.
+    //  The success listener is kept in a variable so the cleanup removes the same function, otherwise a re-run of this effect would
+    //  leave the previous listener attached and sign in twice.
     React.useEffect(() => {
-        document.addEventListener('AppleIDSignInOnSuccess', (event) => successListener(event, preferredLocale));
+        const onSuccess = (event: AppleIDSignInOnSuccessEvent) => successListener(event, preferredLocale, marketingAttribution);
+        document.addEventListener('AppleIDSignInOnSuccess', onSuccess);
         document.addEventListener('AppleIDSignInOnFailure', failureListener);
         return () => {
-            document.removeEventListener('AppleIDSignInOnSuccess', (event) => successListener(event, preferredLocale));
+            document.removeEventListener('AppleIDSignInOnSuccess', onSuccess);
             document.removeEventListener('AppleIDSignInOnFailure', failureListener);
         };
-    }, [preferredLocale]);
+    }, [preferredLocale, marketingAttribution]);
 
     return isDesktopFlow ? (
         
{ - beginGoogleSignIn(response.credential, preferredLocale); +const signIn = (response: Response, preferredLocale?: Locale, marketingAttribution?: OnyxEntry) => { + beginGoogleSignIn(response.credential, preferredLocale, marketingAttribution); }; /** @@ -38,13 +41,14 @@ function GoogleSignIn({isDesktopFlow = false, onPointerDown}: GoogleSignInProps) const {translate} = useLocalize(); const styles = useThemeStyles(); const [preferredLocale] = useOnyx(ONYXKEYS.NVP_PREFERRED_LOCALE); + const [marketingAttribution] = useOnyx(ONYXKEYS.MARKETING_ATTRIBUTION); const loadScript = useCallback(() => { const google = window.google; if (google) { google.accounts.id.initialize({ // eslint-disable-next-line @typescript-eslint/naming-convention client_id: CONFIG.GOOGLE_SIGN_IN.WEB_CLIENT_ID, - callback: (response) => signIn(response, preferredLocale), + callback: (response) => signIn(response, preferredLocale, marketingAttribution), }); // Apply styles for each button google.accounts.id.renderButton(document.getElementById(mainId), { @@ -61,7 +65,7 @@ function GoogleSignIn({isDesktopFlow = false, onPointerDown}: GoogleSignInProps) width: '300px', }); } - }, [preferredLocale]); + }, [preferredLocale, marketingAttribution]); React.useEffect(() => { const script = document.createElement('script'); diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index 17858dc2cb5a..1e178fda0227 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -7,7 +7,9 @@ import useKeyboardState from '@hooks/useKeyboardState'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; +import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; import useVerticalScrollbarWidth from '@hooks/useVerticalScrollbarWidth'; import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; @@ -16,6 +18,7 @@ import {canMeasureText} from '@libs/measureTextWidth'; import {acquireBackgroundInputFocusSuppression} from '@libs/ModalFocusManager'; import CONST from '@src/CONST'; +import type {ColumnWidthOverrides} from '@src/types/onyx/TableColumnWidths'; import type {FlashListRef} from '@shopify/flash-list'; import type {ReactElement} from 'react'; @@ -30,6 +33,9 @@ import type {TableHeaderProps} from './TableHeader'; import type {TableData, TableHandle, TableMethods, TableProps, TableRow} from './types'; import {getDataVisibleIndices, getListIndex, getTableListMetadata, rendersColumnHeader} from './buildTableListData'; +import getMeasureWidthRef from './columnResize/getMeasureWidthRef'; +import useColumnResize from './columnResize/useColumnResize'; +import useStoredColumnWidths from './columnResize/useStoredColumnWidths'; import useFiltering from './middlewares/filtering'; import useHighlighting from './middlewares/highlight'; import useSearching from './middlewares/searching'; @@ -138,127 +144,12 @@ function createTableHandle; } -/** - * A composable table component that provides filtering, search, and sorting functionality. - * - * This component uses a compositional pattern where the parent `
` component manages - * all state (filtering, searching, sorting) and provides it via context. Child components - * consume this context to render different parts of the table UI. - * - * ## Compositional Pattern - * - * The Table follows a compound component pattern similar to ``, ``, or ``. - * You compose your table UI by nesting the sub-components you need: - * - * - `
` - The parent component that manages state and provides context - * - `` - Renders sortable column headers - * - `` - Renders the data rows using FlashList - * - `` - Renders a search input that filters data - * - * ## Middleware Architecture - * - * Data processing is handled through a pipeline of middleware functions: - * 1. **Filtering** - Applies dropdown filter selections - * 2. **Searching** - Applies search string filtering - * 3. **Sorting** - Sorts data by the active column - * 4. **Selection** - Applies row selection state & provides helpers for selection - * - * Each middleware transforms the data array and passes it to the next. - * - * ## Generic Type Parameters - * - * - `DataType` - The type of items in your data array - * - `ColumnKey` - String literal union of valid column keys (e.g., `'name' | 'date'`) - * - `FilterKey` - String literal union of valid filter keys - * - * @example Basic Usage - * ```tsx - * type Item = { id: string; name: string; category: string }; - * type ColumnKey = 'name' | 'category'; - * - * const columns: Array> = [ - * { key: 'name', label: 'Name' }, - * { key: 'category', label: 'Category' }, - * ]; - * - * - * data={items} - * columns={columns} - * renderItem={({ item }) => } - * keyExtractor={(item) => item.id} - * > - * - * - *
- * ``` - * - * @example With Search and Sorting - * ```tsx - * - * data={items} - * columns={columns} - * renderItem={renderItem} - * keyExtractor={keyExtractor} - * isItemInSearch={(item, searchString) => - * item.name.toLowerCase().includes(searchString.toLowerCase()) - * } - * compareItems={(a, b, { columnKey, order }) => { - * const multiplier = order === 'asc' ? 1 : -1; - * return a[columnKey].localeCompare(b[columnKey]) * multiplier; - * }} - * > - * - * - * - * - * ``` - * - * @example With Filters - * ```tsx - * const filterConfig: FilterConfig = { - * status: { - * filterType: 'singleSelect', - * options: [ - * { label: 'All', value: 'all' }, - * { label: 'Active', value: 'active' }, - * { label: 'Inactive', value: 'inactive' }, - * ], - * default: 'all', - * }, - * }; - * - * - * data={items} - * columns={columns} - * renderItem={renderItem} - * keyExtractor={keyExtractor} - * filters={filterConfig} - * isItemInFilter={(item, filterValues) => { - * if (filterValues.includes('all')) return true; - * return filterValues.includes(item.status); - * }} - * > - * - * - * - * ``` - * - * @example Programmatic Control via Ref - * ```tsx - * const tableRef = useRef>(null); - * - * // Programmatically update sorting - * tableRef.current?.updateSorting({ columnKey: 'name', order: 'desc' }); - * - * // Get current state - * const sorting = tableRef.current?.getActiveSorting(); - * - * - * - *
- * ``` - */ -function Table({ +type TableContentProps = TableProps & { + /** Stored dragged widths for this table's `columnResizingID`. */ + columnWidthOverrides?: ColumnWidthOverrides; +}; + +function TableContent({ ref, title, columns, @@ -278,11 +169,13 @@ function Table) { +}: TableContentProps) { const {translate} = useLocalize(); const isGlobalMobileSelectionEnabled = useMobileSelectionMode(); @@ -304,6 +197,7 @@ function Table({ + const { + gridTemplateColumns: dynamicGridTemplateColumns, + scrollWidth: dynamicScrollWidth, + rowWidth: dynamicRowWidth, + resizableColumnKeys, + resolvedColumnWidths, + dragMinWidths, + } = useDynamicColumnWidths({ columns, data, tableWidth: contentWidth, isEnabled: isDynamicSizingEnabled, // In the wide layout the checkbox column is rendered whenever selection is enabled. hasSelectionColumn: !!selectionEnabled, + isColumnResizingEnabled, + columnWidthOverrides, + }); + + const columnResize = useColumnResize({ + columnResizingID: isColumnResizingEnabled ? columnResizingID : undefined, + resizableColumnKeys, + resolvedColumnWidths, + dragMinWidths, + columnGap: styles.gap3.gap, }); const tableMethods: TableMethods = { @@ -436,7 +351,8 @@ function Table 0; const isColumnHeaderHiddenInNarrowLayout = shouldUseNarrowTableLayout && !title; - const areColumnsScrollable = !!dynamicScrollWidth; + // Resizable tables always get the scroller: a drag can overflow without a render, and moving the header mid-drag would remount the handle. + const hasHorizontalScrollContainer = isColumnResizingEnabled || !!dynamicScrollWidth; const tableListMetadata = useMemo( () => @@ -446,9 +362,9 @@ function Table {renderedChildren} @@ -598,4 +519,151 @@ function Table( + props: TableProps & {columnResizingID: string}, +) { + const columnWidthOverrides = useStoredColumnWidths(props.columnResizingID); + + return ( + + ); +} + +/** + * A composable table component that provides filtering, search, and sorting functionality. + * + * This component uses a compositional pattern where the parent `` component manages + * all state (filtering, searching, sorting) and provides it via context. Child components + * consume this context to render different parts of the table UI. + * + * ## Compositional Pattern + * + * The Table follows a compound component pattern similar to ``, ``, or ``. + * You compose your table UI by nesting the sub-components you need: + * + * - `
` - The parent component that manages state and provides context + * - `` - Renders sortable column headers + * - `` - Renders the data rows using FlashList + * - `` - Renders a search input that filters data + * + * ## Middleware Architecture + * + * Data processing is handled through a pipeline of middleware functions: + * 1. **Filtering** - Applies dropdown filter selections + * 2. **Searching** - Applies search string filtering + * 3. **Sorting** - Sorts data by the active column + * 4. **Selection** - Applies row selection state & provides helpers for selection + * + * Each middleware transforms the data array and passes it to the next. + * + * ## Generic Type Parameters + * + * - `DataType` - The type of items in your data array + * - `ColumnKey` - String literal union of valid column keys (e.g., `'name' | 'date'`) + * - `FilterKey` - String literal union of valid filter keys + * + * @example Basic Usage + * ```tsx + * type Item = { id: string; name: string; category: string }; + * type ColumnKey = 'name' | 'category'; + * + * const columns: Array> = [ + * { key: 'name', label: 'Name' }, + * { key: 'category', label: 'Category' }, + * ]; + * + * + * data={items} + * columns={columns} + * renderItem={({ item }) => } + * keyExtractor={(item) => item.id} + * > + * + * + *
+ * ``` + * + * @example With Search and Sorting + * ```tsx + * + * data={items} + * columns={columns} + * renderItem={renderItem} + * keyExtractor={keyExtractor} + * isItemInSearch={(item, searchString) => + * item.name.toLowerCase().includes(searchString.toLowerCase()) + * } + * compareItems={(a, b, { columnKey, order }) => { + * const multiplier = order === 'asc' ? 1 : -1; + * return a[columnKey].localeCompare(b[columnKey]) * multiplier; + * }} + * > + * + * + * + * + * ``` + * + * @example With Filters + * ```tsx + * const filterConfig: FilterConfig = { + * status: { + * filterType: 'singleSelect', + * options: [ + * { label: 'All', value: 'all' }, + * { label: 'Active', value: 'active' }, + * { label: 'Inactive', value: 'inactive' }, + * ], + * default: 'all', + * }, + * }; + * + * + * data={items} + * columns={columns} + * renderItem={renderItem} + * keyExtractor={keyExtractor} + * filters={filterConfig} + * isItemInFilter={(item, filterValues) => { + * if (filterValues.includes('all')) return true; + * return filterValues.includes(item.status); + * }} + * > + * + * + * + * ``` + * + * @example Programmatic Control via Ref + * ```tsx + * const tableRef = useRef>(null); + * + * // Programmatically update sorting + * tableRef.current?.updateSorting({ columnKey: 'name', order: 'desc' }); + * + * // Get current state + * const sorting = tableRef.current?.getActiveSorting(); + * + * + * + *
+ * ``` + */ +function Table(props: TableProps) { + // Only tables that opted into resizing subscribe to stored widths + if (!props.columnResizingID) { + return ; + } + + return ( + + ); +} + export default Table; diff --git a/src/components/Table/TableContext.tsx b/src/components/Table/TableContext.tsx index 8d78d33f4fd4..c761dfacfafe 100644 --- a/src/components/Table/TableContext.tsx +++ b/src/components/Table/TableContext.tsx @@ -11,6 +11,7 @@ import type {NativeScrollEvent, NativeSyntheticEvent, View} from 'react-native'; import React, {createContext, useContext} from 'react'; import type {TableListMetadata} from './buildTableListData'; +import type {ColumnResizeController} from './columnResize/useColumnResize/types'; import type {FilterConfig} from './middlewares/filtering'; import type {ActiveSorting, SortOrder} from './middlewares/sorting'; import type {TableHeaderProps} from './TableHeader'; @@ -81,8 +82,14 @@ type TableContextValue = { columns: [], dynamicGridTemplateColumns: undefined, scrollWidth: undefined, + rowWidth: undefined, + columnResize: undefined, tableWidth: 0, activeFilters: {}, activeSorting: { diff --git a/src/components/Table/TableHeader.tsx b/src/components/Table/TableHeader.tsx index 10fb6c091dd1..155f6931c021 100644 --- a/src/components/Table/TableHeader.tsx +++ b/src/components/Table/TableHeader.tsx @@ -23,6 +23,8 @@ import {StyleSheet, View} from 'react-native'; import type {TableColumn, TableData} from './types'; import {rendersColumnHeaderInListHeader} from './buildTableListData'; +import ColumnResizeHandle from './columnResize/ColumnResizeHandle'; +import {TABLE_ROW_DATA_SET} from './columnResize/columnWidthExpressions'; import getGridTemplateColumns from './getGridTemplateColumns'; import {getColumnHeaderAccessibilityProps, getRowAccessibilityProps, shouldUseTableSemantics} from './tableAccessibility'; import {useTableContext} from './TableContext'; @@ -87,6 +89,8 @@ function TableHeader(); // Tables inside a narrow pane modal (RHP) opt into keying the header checkbox off the real screen size, since @@ -131,6 +135,8 @@ function TableHeader ); - // Sits in the list header rather than FlashList's sticky-row overlay, so the scroller carries it sideways with the - // columns. Needs an explicit width because the list header stretches to the scrolled content, which would leave the - // background and bottom border short of the columns. That background is what the rows scroll under once it's stuck. - if (rendersColumnHeaderInListHeader(tableListMetadata) && !!scrollWidth) { - return {header}; + // In the list header rather than FlashList's sticky overlay, so it scrolls sideways with the columns. Resizable tables + // size the inner row. The rest need the wrapper at the scroll width, or the background and bottom border fall short of the columns. + if (rendersColumnHeaderInListHeader(tableListMetadata)) { + return {header}; } if (!isStickyListHeader) { @@ -346,6 +356,9 @@ function TableHeaderColumn {sortButton} + + {/* Skipped in the sticky header's hidden twin to avoid duplicate handles. */} + {!isAccessibilityHidden && } ); } diff --git a/src/components/Table/TableRow.tsx b/src/components/Table/TableRow.tsx index 84bfdd52947f..350039b357e0 100644 --- a/src/components/Table/TableRow.tsx +++ b/src/components/Table/TableRow.tsx @@ -10,6 +10,7 @@ import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useLayoutSpacing from '@hooks/useLayoutSpacing'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -26,6 +27,7 @@ import {View} from 'react-native'; import Animated from 'react-native-reanimated'; import {rendersColumnHeader} from './buildTableListData'; +import {TABLE_ROW_DATA_SET} from './columnResize/columnWidthExpressions'; import getGridTemplateColumns from './getGridTemplateColumns'; import {assignCellColumnIndexes, getCellAccessibilityProps, getRowAccessibilityProps, shouldUseTableSemantics} from './tableAccessibility'; import {useTableContext, useTableRowSemanticID} from './TableContext'; @@ -81,6 +83,7 @@ export default function TableRow({ const theme = useTheme(); const styles = useThemeStyles(); const {pageGutterMargin} = useLayoutSpacing(); + const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, shouldUseNarrowLayout, isInNarrowPaneModal} = useResponsiveLayout(); @@ -96,6 +99,7 @@ export default function TableRow({ dynamicGridTemplateColumns, listProps, shouldFooterRenderAsLastRow, + rowWidth, } = useTableContext(); const semanticRowID = useTableRowSemanticID(); @@ -169,6 +173,8 @@ export default function TableRow({ const tableRowPressableStyles = [ pageGutterMargin, + // The list sizes rows from a measurement, so without this the background wouldn't follow a drag. + !!rowWidth && StyleUtils.getWidthStyle(rowWidth), isGroupHeader ? styles.hoveredComponentBG : styles.highlightBG, styles.userSelectNone, !isFirstRow && styles.borderTop, @@ -327,6 +333,7 @@ export default function TableRow({ onHoverIn={enableHoverStyle} pressDimmingValue={!interactive ? undefined : 1} role={interactive ? CONST.ROLE.BUTTON : CONST.ROLE.PRESENTATION} + dataSet={TABLE_ROW_DATA_SET} {...getRowAccessibilityProps(isTableSemanticsEnabled, rowIndex, false, semanticTableHasHeader)} onMouseDown={(e) => { captureEditingOnMouseDown(); diff --git a/src/components/Table/TableSemanticContainer.tsx b/src/components/Table/TableSemanticContainer.tsx index ac67d5af0713..370ead5a94ce 100644 --- a/src/components/Table/TableSemanticContainer.tsx +++ b/src/components/Table/TableSemanticContainer.tsx @@ -10,6 +10,7 @@ import type {LayoutChangeEvent} from 'react-native'; import React from 'react'; import {View} from 'react-native'; +import ColumnResizeScope from './columnResize/ColumnResizeScope'; import {getTableContainerAccessibilityProps} from './tableAccessibility'; import TableBody from './TableBody'; import TableHeader from './TableHeader'; @@ -49,8 +50,9 @@ type TableSemanticContainerProps = { * The width the rows need when the columns don't fit, which scrolls the header/body run horizontally as one so the * header stays aligned with its rows. Set only for tables whose filter bar isn't in the list. The others are * scrolled by the list itself (see `TableBody`). + * Resizable tables always pass a CSS expression, so a drag past the edge scrolls without a re-render. */ - scrollWidth: number | undefined; + scrollWidth: number | string | undefined; /** * Measures the width the table's columns have to share. This node is the right thing to measure because it keeps the @@ -59,6 +61,12 @@ type TableSemanticContainerProps = { */ onLayout: ((event: LayoutChangeEvent) => void) | undefined; + /** Measures the same node as `onLayout` as soon as it mounts, ahead of the first paint. */ + measureWidthRef: ((node: unknown) => void) | undefined; + + /** Receives the element the resizable columns' widths are written on. Set only when column resizing is enabled. */ + onScopeElement: ((element: HTMLElement | null) => void) | undefined; + /** Table children — expected to contain a contiguous `TableHeader`/`TableBody` run. */ children: React.ReactNode; }; @@ -84,6 +92,8 @@ function TableSemanticContainer({ hasHeaderRow, scrollWidth, onLayout, + measureWidthRef, + onScopeElement, children, }: TableSemanticContainerProps) { const styles = useThemeStyles(); @@ -121,6 +131,7 @@ function TableSemanticContainer({ // it doesn't. Either way the measured node keeps the table's own width rather than growing with the // content, so measuring it can't feed back into the widths it produced. onLayout={scrollWidth ? undefined : onLayout} + ref={scrollWidth ? undefined : measureWidthRef} {...getTableContainerAccessibilityProps(isEnabled, title, rowCount, columnCount, hasHeaderRow)} > {rowGroup} @@ -130,20 +141,26 @@ function TableSemanticContainer({ // The columns don't fit, so the header and the body scroll horizontally as one and stay aligned. The content // container carries the width they need, and the rows fill it, matching how the Search table scrolls. renderedChildren.push( - scrollWidth ? ( - - {rowGroupContainer} - - ) : ( - rowGroupContainer - ), + // Wraps the scroller too, so one width write resizes its content along with the header and rows. + + {scrollWidth ? ( + + {rowGroupContainer} + + ) : ( + rowGroupContainer + )} + , ); rowGroup = []; }; diff --git a/src/components/Table/buildTableListData.ts b/src/components/Table/buildTableListData.ts index 142218eead98..335ec82dc223 100644 --- a/src/components/Table/buildTableListData.ts +++ b/src/components/Table/buildTableListData.ts @@ -31,10 +31,10 @@ type ColumnHeaderPlacementParams = { isColumnHeaderHiddenInNarrowLayout: boolean; /** - * Whether the columns are wider than the table and so have to scroll. FlashList's sticky-row overlay can't follow - * them, so the column header moves into the list header instead (see `TableBody`). + * Whether the columns sit in a horizontal scroller because they overflow or are resizable. FlashList's sticky-row overlay + * can't follow them sideways, so the column header moves into the list header instead (see `TableBody`). */ - areColumnsScrollable: boolean; + hasHorizontalScrollContainer: boolean; }; type TableListMetadataParams = Omit & { @@ -47,7 +47,7 @@ function getColumnHeaderPlacement({ hasPageHeader, hasRows, isColumnHeaderHiddenInNarrowLayout, - areColumnsScrollable, + hasHorizontalScrollContainer, }: ColumnHeaderPlacementParams): ColumnHeaderPlacement { if (!hasColumnHeaderElement) { return CONST.TABLES.COLUMN_HEADER_PLACEMENT.NONE; @@ -63,7 +63,7 @@ function getColumnHeaderPlacement({ return CONST.TABLES.COLUMN_HEADER_PLACEMENT.OUTSIDE_LIST; } - return areColumnsScrollable ? CONST.TABLES.COLUMN_HEADER_PLACEMENT.LIST_HEADER : CONST.TABLES.COLUMN_HEADER_PLACEMENT.STICKY_ROW; + return hasHorizontalScrollContainer ? CONST.TABLES.COLUMN_HEADER_PLACEMENT.LIST_HEADER : CONST.TABLES.COLUMN_HEADER_PLACEMENT.STICKY_ROW; } /** Whether the table has a column header at all, wherever it ends up. Drives `aria-rowindex`/`aria-rowcount`. */ diff --git a/src/components/Table/calculateDynamicColumnWidths.ts b/src/components/Table/calculateDynamicColumnWidths.ts index a8b4ce119e51..1a80327f3a88 100644 --- a/src/components/Table/calculateDynamicColumnWidths.ts +++ b/src/components/Table/calculateDynamicColumnWidths.ts @@ -118,6 +118,20 @@ function distributeAvailableWidth(desiredWidths: number[], maxWidths: number[], return widths; } +/** Equal whole-px widths summing exactly to `availableWidth`. The remainder goes to the first column. */ +function distributeEqualWidths(columnCount: number, availableWidth: number): number[] { + if (columnCount <= 0) { + return []; + } + + const equalWidth = Math.floor(availableWidth / columnCount); + const widths = Array.from({length: columnCount}, () => equalWidth); + + widths[0] += availableWidth - equalWidth * columnCount; + + return widths; +} + /** * Resolves the widths of a table's dynamically sized columns from what their content needs and how much room the table * has, implementing the three behaviors from https://github.com/Expensify/App/issues/96510: @@ -212,4 +226,5 @@ function calculateDynamicColumnWidths(constraints: DynamicColumnConstraints[], a } export default calculateDynamicColumnWidths; +export {distributeEqualWidths}; export type {DynamicColumnConstraints}; diff --git a/src/components/Table/columnResize/ColumnResizeHandle/index.native.tsx b/src/components/Table/columnResize/ColumnResizeHandle/index.native.tsx new file mode 100644 index 000000000000..fc32fccde041 --- /dev/null +++ b/src/components/Table/columnResize/ColumnResizeHandle/index.native.tsx @@ -0,0 +1,9 @@ +import type {ColumnResizeHandleProps} from '@components/Table/columnResize/types'; + +/** No-op on native. Keeps the DOM-only implementation out of the native bundle. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- the props exist only to match the web signature +function ColumnResizeHandle(props: ColumnResizeHandleProps) { + return null; +} + +export default ColumnResizeHandle; diff --git a/src/components/Table/columnResize/ColumnResizeHandle/index.tsx b/src/components/Table/columnResize/ColumnResizeHandle/index.tsx new file mode 100644 index 000000000000..d127af019355 --- /dev/null +++ b/src/components/Table/columnResize/ColumnResizeHandle/index.tsx @@ -0,0 +1,47 @@ +import {RESIZE_INDICATOR_HEIGHT_VARIABLE, RESIZE_INDICATOR_OPACITY_VARIABLE, RESIZE_INDICATOR_TOP_VARIABLE} from '@components/Table/columnResize/columnWidthExpressions'; +import type {ColumnResizeHandleProps} from '@components/Table/columnResize/types'; +import {useTableContext} from '@components/Table/TableContext'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +// A DOM `div` style, which the React Native style system can't type because of the custom property expressions. +const INDICATOR_STYLE: React.CSSProperties = { + position: 'absolute', + // Spans heading row to lowest row, which the handle's box doesn't match, so both are measured. + top: `var(${RESIZE_INDICATOR_TOP_VARIABLE}, 0px)`, + height: `var(${RESIZE_INDICATOR_HEIGHT_VARIABLE}, 100%)`, + left: '50%', + marginLeft: -CONST.TABLES.COLUMN_RESIZE.INDICATOR_WIDTH / 2, + width: CONST.TABLES.COLUMN_RESIZE.INDICATOR_WIDTH, + opacity: `var(${RESIZE_INDICATOR_OPACITY_VARIABLE}, 0)`, + pointerEvents: 'none', +}; + +/** + * Drag the strip over a column's right edge. Carries the indicator line, so it moves with the column through drags and scrolls. + * A plain `div` because it relies on DOM pointer capture. + */ +function ColumnResizeHandle({columnKey}: ColumnResizeHandleProps) { + const styles = useThemeStyles(); + const {columnResize} = useTableContext(); + const handleProps = columnResize?.getHandleProps(columnKey); + + if (!handleProps) { + return null; + } + + return ( +
+
+
+ ); +} + +export default ColumnResizeHandle; diff --git a/src/components/Table/columnResize/ColumnResizeScope/index.native.tsx b/src/components/Table/columnResize/ColumnResizeScope/index.native.tsx new file mode 100644 index 000000000000..7ac59df76547 --- /dev/null +++ b/src/components/Table/columnResize/ColumnResizeScope/index.native.tsx @@ -0,0 +1,8 @@ +import type {ColumnResizeScopeProps} from '@components/Table/columnResize/types'; + +/** No-op on native. */ +function ColumnResizeScope({children}: ColumnResizeScopeProps) { + return children; +} + +export default ColumnResizeScope; diff --git a/src/components/Table/columnResize/ColumnResizeScope/index.tsx b/src/components/Table/columnResize/ColumnResizeScope/index.tsx new file mode 100644 index 000000000000..15bc844d4719 --- /dev/null +++ b/src/components/Table/columnResize/ColumnResizeScope/index.tsx @@ -0,0 +1,26 @@ +import type {ColumnResizeScopeProps} from '@components/Table/columnResize/types'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +/** Element the column widths are written on, so the header and rows below it inherit them. */ +function ColumnResizeScope({onScopeElement, children}: ColumnResizeScopeProps) { + const styles = useThemeStyles(); + + if (!onScopeElement) { + return children; + } + + return ( +
+ {children} +
+ ); +} + +export default ColumnResizeScope; diff --git a/src/components/Table/columnResize/applyColumnWidthOverrides.ts b/src/components/Table/columnResize/applyColumnWidthOverrides.ts new file mode 100644 index 000000000000..8760523dd51d --- /dev/null +++ b/src/components/Table/columnResize/applyColumnWidthOverrides.ts @@ -0,0 +1,70 @@ +/** Merges stored drag widths into a table's resolved column widths, and decides which column edges can be dragged. */ +import type {ColumnWidthOverrides} from '@src/types/onyx/TableColumnWidths'; + +import {getColumnWidthValue} from './columnWidthExpressions'; + +/** What resizing needs to know about a column, whatever lays the columns out. */ +type ColumnWidthOverrideColumn = { + /** Key a stored width is filed under. */ + key: string; + + /** Headless columns hold fixed-size content like an icon, checkbox or arrow, so they get no edge. */ + label: string; + + /** Columns with a declared width aren't resizable. */ + hasDeclaredWidth: boolean; +}; + +type ApplyColumnWidthOverridesParams = { + /** Every column, in render order. */ + columns: ColumnWidthOverrideColumn[]; + + /** Widths before stored overrides are applied. */ + baseColumnWidths: Record; + + columnWidthOverrides: ColumnWidthOverrides | undefined; +}; + +type AppliedColumnWidthOverrides = { + /** Widths after stored overrides, which drags start from. */ + columnWidths: Record; + + /** Each column's custom property CSS value with `columnWidths` as the fallback, in render order. */ + columnWidthValues: string[]; + + /** Keys of the columns whose right edge the user can drag, in render order. */ + resizableColumnKeys: string[]; +}; + +/** Whole px, deliberately not clamped: stored widths may legitimately fall outside drag bounds. */ +function getStoredColumnWidth(width: number): number { + return Math.max(Math.round(width), 0); +} + +/** Layout-agnostic, so it serves grid tracks and flex basis alike. */ +function applyColumnWidthOverrides({columns, baseColumnWidths, columnWidthOverrides}: ApplyColumnWidthOverridesParams): AppliedColumnWidthOverrides { + const columnWidths = {...baseColumnWidths}; + const resizableColumnKeys: string[] = []; + + for (const column of columns) { + // Only headed, content-sized columns drag. Columns with a declared width, like a switch, status or count, hold fixed-size content. + if (!column.label || column.hasDeclaredWidth) { + continue; + } + + const overriddenWidth = columnWidthOverrides?.[column.key]; + + if (overriddenWidth !== undefined) { + columnWidths[column.key] = getStoredColumnWidth(overriddenWidth); + } + + resizableColumnKeys.push(column.key); + } + + const columnWidthValues = columns.map((column) => getColumnWidthValue(column.key, columnWidths[column.key] ?? 0)); + + return {columnWidths, columnWidthValues, resizableColumnKeys}; +} + +export default applyColumnWidthOverrides; +export type {ColumnWidthOverrideColumn}; diff --git a/src/components/Table/columnResize/columnWidthExpressions.ts b/src/components/Table/columnResize/columnWidthExpressions.ts new file mode 100644 index 000000000000..e5374af6acbd --- /dev/null +++ b/src/components/Table/columnResize/columnWidthExpressions.ts @@ -0,0 +1,54 @@ +/** CSS custom properties resizable tables read widths from, so a drag repaints without a React render. */ + +/** Prefix of the custom property a resizable column reads its width from. */ +const COLUMN_WIDTH_VARIABLE_PREFIX = '--table-column-width-'; + +/** Top of a handle's line: the heading row's top, relative to the handle. */ +const RESIZE_INDICATOR_TOP_VARIABLE = '--table-resize-indicator-top'; + +/** Height of a handle's line: heading row's top to the lowest drawn row's bottom. */ +const RESIZE_INDICATOR_HEIGHT_VARIABLE = '--table-resize-indicator-height'; + +/** Opacity of a handle's line, set on that handle so hovering never re-renders the table. */ +const RESIZE_INDICATOR_OPACITY_VARIABLE = '--table-resize-indicator-opacity'; + +/** Marks the header row and the data rows, so the line can find where the table's rows start and end. */ +const TABLE_ROW_DATA_SET = {tableRow: true}; + +const TABLE_ROW_SELECTOR = '[data-table-row]'; + +/** Column keys may hold characters a CSS custom property name can't, so those are replaced. */ +function getColumnWidthVariableName(columnKey: string): string { + return `${COLUMN_WIDTH_VARIABLE_PREFIX}${columnKey.replaceAll(/[^\w-]/g, '_')}`; +} + +/** A column's width custom property, falling back to its resolved width. */ +function getColumnWidthValue(columnKey: string, resolvedWidth: number): string { + return `var(${getColumnWidthVariableName(columnKey)}, ${resolvedWidth}px)`; +} + +/** Track that grows into the row's leftover width, keeping trailing headless columns pinned right. */ +function getGrowableColumnTrack(widthValue: string): string { + return `minmax(${widthValue}, 1fr)`; +} + +/** CSS sum of the columns plus chrome, floored at `floor` so the table stays full-width and overflow scrolls. */ +function getColumnsWidthExpression(columnWidthValues: string[], chromeWidth: number, floor: string): string { + if (columnWidthValues.length === 0) { + return `max(${floor}, ${chromeWidth}px)`; + } + + return `max(${floor}, calc(${columnWidthValues.join(' + ')} + ${chromeWidth}px))`; +} + +export { + RESIZE_INDICATOR_HEIGHT_VARIABLE, + RESIZE_INDICATOR_OPACITY_VARIABLE, + RESIZE_INDICATOR_TOP_VARIABLE, + TABLE_ROW_DATA_SET, + TABLE_ROW_SELECTOR, + getColumnWidthValue, + getColumnWidthVariableName, + getColumnsWidthExpression, + getGrowableColumnTrack, +}; diff --git a/src/components/Table/columnResize/getDraggedColumnWidth.ts b/src/components/Table/columnResize/getDraggedColumnWidth.ts new file mode 100644 index 000000000000..4128f1a66be7 --- /dev/null +++ b/src/components/Table/columnResize/getDraggedColumnWidth.ts @@ -0,0 +1,9 @@ +/** Turns pointer travel into a column width, kept above the drag floor. */ +import CONST from '@src/CONST'; + +/** The width a drag puts the column at: its start width plus the pointer's travel, no narrower than the floor. */ +function getDraggedColumnWidth(startWidth: number, startClientX: number, clientX: number, minWidth: number = CONST.TABLES.COLUMN_RESIZE.MIN_WIDTH): number { + return Math.max(Math.round(startWidth + (clientX - startClientX)), minWidth); +} + +export default getDraggedColumnWidth; diff --git a/src/components/Table/columnResize/getMeasureWidthRef/index.native.ts b/src/components/Table/columnResize/getMeasureWidthRef/index.native.ts new file mode 100644 index 000000000000..e5be93594611 --- /dev/null +++ b/src/components/Table/columnResize/getMeasureWidthRef/index.native.ts @@ -0,0 +1,7 @@ +/** Native columns are never measured, so there is nothing to read ahead of paint. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- the parameter exists only to match the web signature +function getMeasureWidthRef(onWidth: (width: number) => void): ((node: unknown) => void) | undefined { + return undefined; +} + +export default getMeasureWidthRef; diff --git a/src/components/Table/columnResize/getMeasureWidthRef/index.ts b/src/components/Table/columnResize/getMeasureWidthRef/index.ts new file mode 100644 index 000000000000..4f260c3e4f42 --- /dev/null +++ b/src/components/Table/columnResize/getMeasureWidthRef/index.ts @@ -0,0 +1,16 @@ +/** + * Reads a node's width as it mounts. Set in the commit, the width reaches the first paint, while `onLayout` only reports + * it a frame later, after the columns already painted at their static widths. + */ +function getMeasureWidthRef(onWidth: (width: number) => void): ((node: unknown) => void) | undefined { + return (node) => { + if (!(node instanceof HTMLElement)) { + return; + } + + // `offsetWidth`, like react-native-web's `onLayout`, so the layout event that follows matches and doesn't re-render. + onWidth(node.offsetWidth); + }; +} + +export default getMeasureWidthRef; diff --git a/src/components/Table/columnResize/getResizableColumnLayout.ts b/src/components/Table/columnResize/getResizableColumnLayout.ts new file mode 100644 index 000000000000..3822c9980c4b --- /dev/null +++ b/src/components/Table/columnResize/getResizableColumnLayout.ts @@ -0,0 +1,119 @@ +/** Lays a resizable table's columns out as custom property expressions, so a drag repaints the header, rows and scroller without a render. */ +import type {TableColumn, TableData} from '@components/Table/types'; + +import CONST from '@src/CONST'; +import type {ColumnWidthOverrides} from '@src/types/onyx/TableColumnWidths'; + +import applyColumnWidthOverrides from './applyColumnWidthOverrides'; +import {getColumnsWidthExpression, getGrowableColumnTrack} from './columnWidthExpressions'; + +/** Row space that isn't column width, so it can be added back around the column sum. */ +type RowChromeWidths = { + /** Leading checkbox column, `0` when selection is off. */ + selectionColumnWidth: number; + + /** Every gap between columns, the selection column included. */ + totalGapWidth: number; + + /** Outer margin on both sides, which the rows keep inside the scrolled content. */ + rowMarginWidth: number; + + /** Inner padding on both sides. */ + rowPaddingWidth: number; +}; + +type GetResizableColumnLayoutParams = { + /** Every column, in render order. */ + columns: Array>; + + /** Widths before stored overrides are applied, fixed columns included. */ + resolvedColumnWidths: Record; + + columnWidthOverrides: ColumnWidthOverrides | undefined; + + /** Measured width of the area the table renders into. */ + tableWidth: number; + + rowChromeWidths: RowChromeWidths; +}; + +type ResizableColumnLayout = { + /** Grid tracks reading each column's width custom property. */ + gridTemplateColumns: string[]; + + /** Scroller content width, floored at the table so it stays full-width. */ + scrollWidth: string; + + /** Row box width, which is `scrollWidth` minus the outer margin. */ + rowWidth: string; + + /** Keys of the columns whose right edge the user can drag, in render order. */ + resizableColumnKeys: string[]; + + /** Width drags start from: stored overrides applied, and the growable column at its painted width. */ + resolvedColumnWidths: Record; + + /** The narrowest width a drag may take a column to, for columns tighter than the default drag bound. */ + dragMinWidths: Record; +}; + +function getColumnsWidthSum(columns: Array>, columnWidths: Record): number { + return columns.reduce((total, column) => total + (columnWidths[column.key] ?? 0), 0); +} + +function getResizableColumnLayout({ + columns, + resolvedColumnWidths, + columnWidthOverrides, + tableWidth, + rowChromeWidths: {selectionColumnWidth, totalGapWidth, rowMarginWidth, rowPaddingWidth}, +}: GetResizableColumnLayoutParams): ResizableColumnLayout { + // Absorbs leftover width, so trailing headless columns like an arrow, menu or icon keep their size and stay pinned right. + const growableColumnKey = columns.findLast((column) => !!column.label)?.key; + + const {columnWidths, columnWidthValues, resizableColumnKeys} = applyColumnWidthOverrides({ + columns: columns.map((column) => ({ + key: column.key, + label: column.label, + hasDeclaredWidth: typeof column.width === 'number', + })), + baseColumnWidths: resolvedColumnWidths, + columnWidthOverrides, + }); + + // Row width sums the widths, not the tracks, so the growable track only grows into real leftover room. + const gridTemplateColumns = columnWidthValues.map((widthValue, index) => (columns.at(index)?.key === growableColumnKey ? getGrowableColumnTrack(widthValue) : widthValue)); + + const rowWidthValues = selectionColumnWidth > 0 ? [`${selectionColumnWidth}px`, ...columnWidthValues] : columnWidthValues; + + const rowContentWidth = getColumnsWidthSum(columns, columnWidths) + selectionColumnWidth + totalGapWidth + rowPaddingWidth; + const overflowWidth = rowContentWidth - (tableWidth - rowMarginWidth); + const dragStartWidths = {...columnWidths}; + const dragMinWidths: Record = {}; + + if (growableColumnKey && resizableColumnKeys.includes(growableColumnKey)) { + // Drags start from the painted track, which includes the leftover it grew into, so the first pixels of travel aren't dead. + const paintedWidth = (columnWidths[growableColumnKey] ?? 0) + Math.max(-overflowWidth, 0); + dragStartWidths[growableColumnKey] = paintedWidth; + + // Shrinking may only take back the overflow. Anything more would stretch the trailing headless columns or leave + // empty room at the row's end. Rounded up so the row never ends a fraction of a pixel short of the table. + const minWidth = Math.max(Math.ceil(paintedWidth - Math.max(overflowWidth, 0)), CONST.TABLES.COLUMN_RESIZE.MIN_WIDTH); + + // A width already below the drag bound stays put rather than jumping wider. + dragMinWidths[growableColumnKey] = Math.min(minWidth, paintedWidth); + } + + // Scroll at the live column sum, so a drag that widens a column past the table's edge starts scrolling mid-drag. + return { + gridTemplateColumns, + scrollWidth: getColumnsWidthExpression(rowWidthValues, totalGapWidth + rowMarginWidth + rowPaddingWidth, '100%'), + // Without the outer margin. Floored at px because `100%` resolves against the list cell, which includes the margin. + rowWidth: getColumnsWidthExpression(rowWidthValues, totalGapWidth + rowPaddingWidth, `${tableWidth - rowMarginWidth}px`), + resizableColumnKeys, + resolvedColumnWidths: dragStartWidths, + dragMinWidths, + }; +} + +export default getResizableColumnLayout; diff --git a/src/components/Table/columnResize/types.ts b/src/components/Table/columnResize/types.ts new file mode 100644 index 000000000000..65b17946b4cf --- /dev/null +++ b/src/components/Table/columnResize/types.ts @@ -0,0 +1,13 @@ +import type ChildrenProps from '@src/types/utils/ChildrenProps'; + +type ColumnResizeHandleProps = { + /** The column this handle resizes. Its edge is the right edge of the cell the handle renders in. */ + columnKey: string; +}; + +type ColumnResizeScopeProps = ChildrenProps & { + /** Receives the element the columns' width custom properties are written on. Omitted when the table isn't resizable. */ + onScopeElement?: (element: HTMLElement | null) => void; +}; + +export type {ColumnResizeHandleProps, ColumnResizeScopeProps}; diff --git a/src/components/Table/columnResize/useColumnResize/index.native.ts b/src/components/Table/columnResize/useColumnResize/index.native.ts new file mode 100644 index 000000000000..4d1f7d7f345b --- /dev/null +++ b/src/components/Table/columnResize/useColumnResize/index.native.ts @@ -0,0 +1,9 @@ +import type {ColumnResizeController, UseColumnResizeParams} from './types'; + +/** Column resizing is web-only. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- the parameter exists only to match the web signature +function useColumnResize(params: UseColumnResizeParams): ColumnResizeController | undefined { + return undefined; +} + +export default useColumnResize; diff --git a/src/components/Table/columnResize/useColumnResize/index.ts b/src/components/Table/columnResize/useColumnResize/index.ts new file mode 100644 index 000000000000..12d6048dc236 --- /dev/null +++ b/src/components/Table/columnResize/useColumnResize/index.ts @@ -0,0 +1,166 @@ +/** + * Web column resizing: dragging a column's right edge sets its width. Widths live in CSS custom properties so React + * doesn't render mid-drag. Only the dragged column's final width is stored in Onyx. + */ +import getDraggedColumnWidth from '@components/Table/columnResize/getDraggedColumnWidth'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import {setTableColumnWidth} from '@libs/actions/TableColumnWidths'; + +import CONST from '@src/CONST'; + +import type React from 'react'; + +import {useEffect, useRef} from 'react'; + +import type {ColumnResizeController, ColumnResizeHandleDOMProps, UseColumnResizeParams} from './types'; + +import useLiveColumnWidths from './useLiveColumnWidths'; +import useResizeIndicator from './useResizeIndicator'; + +/** A DOM `div` style, which the React Native style system can't type */ +function getHandleStyle(columnGap: number): React.CSSProperties { + return { + position: 'absolute', + top: 0, + bottom: 0, + right: -(columnGap / 2 + CONST.TABLES.COLUMN_RESIZE.HANDLE_HIT_WIDTH / 2), + width: CONST.TABLES.COLUMN_RESIZE.HANDLE_HIT_WIDTH, + // Otherwise a touch drag on the handle is taken over by the table's own horizontal scrolling. + touchAction: 'none', + }; +} + +type Drag = { + columnKey: string; + + /** Where the pointer went down. */ + startClientX: number; + + /** The column's width when the drag started. */ + startWidth: number; +}; + +function useColumnResize({columnResizingID, resizableColumnKeys, resolvedColumnWidths, dragMinWidths, columnGap}: UseColumnResizeParams): ColumnResizeController | undefined { + const styles = useThemeStyles(); + const dragRef = useRef(null); + const {scopeElementRef, setScopeElement, writeColumnWidth, readColumnWidth, clearLiveWidths} = useLiveColumnWidths({resolvedColumnWidths, dragRef}); + const {revealIndicator, hideIndicator} = useResizeIndicator(scopeElementRef); + + const resetDrag = () => { + dragRef.current = null; + document.body.style.cursor = ''; + }; + + // Shared by pointerup, lost capture and cancel, so every way a drag can end keeps the width the user sees. + const endDrag = (drag: Drag) => { + resetDrag(); + hideIndicator(); + + const width = readColumnWidth(drag.columnKey) ?? drag.startWidth; + + // Nothing gets stored, so no render follows to clear the live widths. + if (!columnResizingID || width === drag.startWidth) { + clearLiveWidths(); + return; + } + + setTableColumnWidth(columnResizingID, drag.columnKey, width); + }; + + const handlePointerDown = (columnKey: string, event: React.PointerEvent) => { + // Secondary buttons open context menus rather than dragging. + if (event.button !== 0) { + return; + } + + // Keeps the drag from selecting the header's labels, and from reaching the column's sort button underneath. + event.preventDefault(); + event.stopPropagation(); + + // Keeps the drag on this handle once the pointer moves off it. + event.currentTarget.setPointerCapture(event.pointerId); + + revealIndicator(event.currentTarget); + + dragRef.current = { + columnKey, + startClientX: event.clientX, + startWidth: readColumnWidth(columnKey) ?? 0, + }; + // On the whole page mid-drag, so the cursor doesn't flicker once the pointer outruns the handle. + document.body.style.cursor = styles.cursorColResize.cursor ?? ''; + }; + + const handlePointerMove = (event: React.PointerEvent) => { + const drag = dragRef.current; + + if (!drag) { + return; + } + + // The line rides the handle, so it follows the clamped width, not the pointer. + writeColumnWidth(drag.columnKey, getDraggedColumnWidth(drag.startWidth, drag.startClientX, event.clientX, dragMinWidths?.[drag.columnKey])); + }; + + const handlePointerUp = (event: React.PointerEvent) => { + const drag = dragRef.current; + + if (!drag) { + return; + } + + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + endDrag(drag); + }; + + /** Ends a drag whose pointer capture the browser reclaimed. Also fires after a normal pointerup, when it's a no-op. */ + const handleLostPointerCapture = () => { + const drag = dragRef.current; + + if (!drag) { + return; + } + + endDrag(drag); + }; + + // Unmounting mid-drag would otherwise leave the resize cursor on the document and a dangling drag. + useEffect( + () => () => { + if (!dragRef.current) { + return; + } + + resetDrag(); + }, + [], + ); + + if (!columnResizingID) { + return undefined; + } + + const getHandleProps = (columnKey: string): ColumnResizeHandleDOMProps | undefined => { + if (!resizableColumnKeys.includes(columnKey)) { + return undefined; + } + + return { + style: {...getHandleStyle(columnGap), ...styles.cursorColResize}, + onPointerDown: (event) => handlePointerDown(columnKey, event), + onPointerMove: handlePointerMove, + onPointerUp: handlePointerUp, + onPointerCancel: handleLostPointerCapture, + onLostPointerCapture: handleLostPointerCapture, + }; + }; + + return {setScopeElement, getHandleProps}; +} + +export default useColumnResize; diff --git a/src/components/Table/columnResize/useColumnResize/types.ts b/src/components/Table/columnResize/useColumnResize/types.ts new file mode 100644 index 000000000000..59dcab67581a --- /dev/null +++ b/src/components/Table/columnResize/useColumnResize/types.ts @@ -0,0 +1,30 @@ +import type React from 'react'; + +type UseColumnResizeParams = { + /** Key the column widths persist under. `undefined` disables resizing on native, in narrow layouts and for tables that didn't opt in. */ + columnResizingID: string | undefined; + + /** Keys of the columns whose right edge the user can drag. */ + resizableColumnKeys: string[]; + + /** Each column's resolved width, which a drag starts from. A new object means React rendered new widths. */ + resolvedColumnWidths: Record; + + /** The narrowest width a drag may take a column to, for columns tighter than the default drag bound. */ + dragMinWidths?: Record; + + /** Gap between columns, so handles can be centred in it. */ + columnGap: number; +}; + +type ColumnResizeHandleDOMProps = React.HTMLAttributes; + +type ColumnResizeController = { + /** Element holding the width custom properties. The header and rows inherit them, so one write repaints all. */ + setScopeElement: (element: HTMLElement | null) => void; + + /** Position and handlers for a column's handle, or `undefined` when its edge doesn't drag. */ + getHandleProps: (columnKey: string) => ColumnResizeHandleDOMProps | undefined; +}; + +export type {ColumnResizeController, ColumnResizeHandleDOMProps, UseColumnResizeParams}; diff --git a/src/components/Table/columnResize/useColumnResize/useLiveColumnWidths.ts b/src/components/Table/columnResize/useColumnResize/useLiveColumnWidths.ts new file mode 100644 index 000000000000..74242b3233ec --- /dev/null +++ b/src/components/Table/columnResize/useColumnResize/useLiveColumnWidths.ts @@ -0,0 +1,71 @@ +/** Widths painted mid-drag straight onto the DOM, ahead of React, and handed back once React renders them. */ +import {getColumnWidthVariableName} from '@components/Table/columnResize/columnWidthExpressions'; + +import type {RefObject} from 'react'; + +import {useLayoutEffect, useRef} from 'react'; + +type UseLiveColumnWidthsParams = { + /** What a column paints while it has no live width. A new object means React rendered new widths, so live widths are cleared. */ + resolvedColumnWidths: Record; + + /** Active drag. Live widths survive it so a width stored elsewhere can't yank the column. */ + dragRef: RefObject; +}; + +type LiveColumnWidths = { + /** Element holding the width custom properties. */ + scopeElementRef: RefObject; + + setScopeElement: (element: HTMLElement | null) => void; + + writeColumnWidth: (columnKey: string, width: number) => void; + + /** Currently painted width, ahead of React mid-gesture. `undefined` rather than zero, so a column never collapses. */ + readColumnWidth: (columnKey: string) => number | undefined; + + /** Hands every column back to React's fallback. */ + clearLiveWidths: () => void; +}; + +/** Widths written mid-gesture as CSS custom properties. None at rest, so they never need syncing with resolved widths. */ +function useLiveColumnWidths({resolvedColumnWidths, dragRef}: UseLiveColumnWidthsParams): LiveColumnWidths { + const scopeElementRef = useRef(null); + const liveWidthsRef = useRef>({}); + + const setScopeElement = (element: HTMLElement | null) => { + scopeElementRef.current = element; + + // The properties live on the element, so a new one starts out with none of them. + liveWidthsRef.current = {}; + }; + + const writeColumnWidth = (columnKey: string, width: number) => { + scopeElementRef.current?.style.setProperty(getColumnWidthVariableName(columnKey), `${width}px`); + liveWidthsRef.current[columnKey] = width; + }; + + const readColumnWidth = (columnKey: string): number | undefined => liveWidthsRef.current[columnKey] ?? resolvedColumnWidths[columnKey]; + + const clearLiveWidths = () => { + for (const columnKey of Object.keys(liveWidthsRef.current)) { + scopeElementRef.current?.style.removeProperty(getColumnWidthVariableName(columnKey)); + } + + liveWidthsRef.current = {}; + }; + + // React now renders the committed width as the fallback, so drop live widths before paint. + useLayoutEffect(() => { + // The drag's own commit clears later. + if (dragRef.current) { + return; + } + + clearLiveWidths(); + }, [resolvedColumnWidths, dragRef]); + + return {scopeElementRef, setScopeElement, writeColumnWidth, readColumnWidth, clearLiveWidths}; +} + +export default useLiveColumnWidths; diff --git a/src/components/Table/columnResize/useColumnResize/useResizeIndicator.ts b/src/components/Table/columnResize/useColumnResize/useResizeIndicator.ts new file mode 100644 index 000000000000..c8446a1041d3 --- /dev/null +++ b/src/components/Table/columnResize/useColumnResize/useResizeIndicator.ts @@ -0,0 +1,58 @@ +/** The line marking a dragged column edge, toggled through custom properties so a drag never re-renders the table. */ +import {RESIZE_INDICATOR_HEIGHT_VARIABLE, RESIZE_INDICATOR_OPACITY_VARIABLE, RESIZE_INDICATOR_TOP_VARIABLE, TABLE_ROW_SELECTOR} from '@components/Table/columnResize/columnWidthExpressions'; + +import type {RefObject} from 'react'; + +import {useRef} from 'react'; + +const INDICATOR_OPACITY = { + VISIBLE: '1', + HIDDEN: '0', +} as const; + +type ResizeIndicator = { + revealIndicator: (handleElement: HTMLElement) => void; + + hideIndicator: () => void; +}; + +/** Measured once per drag. The line lives in the handle, so drags and scrolls carry it. */ +function drawIndicatorAtHandle(scopeElement: HTMLElement | null, handleElement: HTMLElement) { + const handleRect = handleElement.getBoundingClientRect(); + const headerRowTop = (handleElement.closest(TABLE_ROW_SELECTOR) ?? handleElement).getBoundingClientRect().top; + let lowestRowBottom = handleRect.bottom; + + for (const row of scopeElement?.querySelectorAll(TABLE_ROW_SELECTOR) ?? []) { + // Skip the list's hidden twins, but not rows hidden by an outer modal's aria-hidden. + const hiddenAncestor = row.closest('[aria-hidden="true"]'); + + if (hiddenAncestor && scopeElement?.contains(hiddenAncestor)) { + continue; + } + + lowestRowBottom = Math.max(lowestRowBottom, row.getBoundingClientRect().bottom); + } + + handleElement.style.setProperty(RESIZE_INDICATOR_TOP_VARIABLE, `${headerRowTop - handleRect.top}px`); + handleElement.style.setProperty(RESIZE_INDICATOR_HEIGHT_VARIABLE, `${lowestRowBottom - headerRowTop}px`); + handleElement.style.setProperty(RESIZE_INDICATOR_OPACITY_VARIABLE, INDICATOR_OPACITY.VISIBLE); +} + +function useResizeIndicator(scopeElementRef: RefObject): ResizeIndicator { + // Lost pointer capture hands over no element, so the dragged handle is remembered to hide its line. + const activeHandleElementRef = useRef(null); + + const revealIndicator = (handleElement: HTMLElement) => { + activeHandleElementRef.current = handleElement; + drawIndicatorAtHandle(scopeElementRef.current, handleElement); + }; + + const hideIndicator = () => { + activeHandleElementRef.current?.style.setProperty(RESIZE_INDICATOR_OPACITY_VARIABLE, INDICATOR_OPACITY.HIDDEN); + activeHandleElementRef.current = null; + }; + + return {revealIndicator, hideIndicator}; +} + +export default useResizeIndicator; diff --git a/src/components/Table/columnResize/useStoredColumnWidths/index.native.ts b/src/components/Table/columnResize/useStoredColumnWidths/index.native.ts new file mode 100644 index 000000000000..a8b30cf1ee6d --- /dev/null +++ b/src/components/Table/columnResize/useStoredColumnWidths/index.native.ts @@ -0,0 +1,9 @@ +import type {ColumnWidthOverrides} from '@src/types/onyx/TableColumnWidths'; + +/** Column resizing is web-only, so native never subscribes to stored widths. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- the parameter exists only to match the web signature +function useStoredColumnWidths(columnResizingID: string): ColumnWidthOverrides | undefined { + return undefined; +} + +export default useStoredColumnWidths; diff --git a/src/components/Table/columnResize/useStoredColumnWidths/index.ts b/src/components/Table/columnResize/useStoredColumnWidths/index.ts new file mode 100644 index 000000000000..878a9f95a9b7 --- /dev/null +++ b/src/components/Table/columnResize/useStoredColumnWidths/index.ts @@ -0,0 +1,13 @@ +import useOnyx from '@hooks/useOnyx'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import {tableColumnWidthsSelector} from '@src/selectors/TableColumnWidths'; +import type {ColumnWidthOverrides} from '@src/types/onyx/TableColumnWidths'; + +/** Widths the user dragged in tables sharing this `columnResizingID`. */ +function useStoredColumnWidths(columnResizingID: string): ColumnWidthOverrides | undefined { + const [columnWidthOverrides] = useOnyx(ONYXKEYS.TABLE_COLUMN_WIDTHS, {selector: tableColumnWidthsSelector(columnResizingID)}); + return columnWidthOverrides; +} + +export default useStoredColumnWidths; diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index 32b8f6f054b3..89afc020c4f2 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -223,6 +223,12 @@ type TableProps; diff --git a/src/components/Table/useDynamicColumnWidths.ts b/src/components/Table/useDynamicColumnWidths.ts index 57bfe5bd622e..561480acdc5c 100644 --- a/src/components/Table/useDynamicColumnWidths.ts +++ b/src/components/Table/useDynamicColumnWidths.ts @@ -8,13 +8,15 @@ import {fontScale} from '@styles/typography'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import type {ColumnWidthOverrides} from '@src/types/onyx/TableColumnWidths'; import type {StyleProp, ViewStyle} from 'react-native'; import type {DynamicColumnConstraints} from './calculateDynamicColumnWidths'; import type {TableColumn, TableData} from './types'; -import calculateDynamicColumnWidths from './calculateDynamicColumnWidths'; +import calculateDynamicColumnWidths, {distributeEqualWidths} from './calculateDynamicColumnWidths'; +import getResizableColumnLayout from './columnResize/getResizableColumnLayout'; const {MIN_FREE_TEXT_COLUMN_WIDTH} = CONST.TABLES.DYNAMIC_COLUMNS; @@ -36,6 +38,32 @@ type UseDynamicColumnWidthsParams; + + /** Narrowest width a drag may take a column to, for columns tighter than the default drag bound. */ + dragMinWidths: Record; }; /** @@ -122,11 +150,20 @@ function useDynamicColumnWidths): {gridTemplateColumns: string[] | undefined; scrollWidth: number | undefined} { + isColumnResizingEnabled, + columnWidthOverrides, +}: UseDynamicColumnWidthsParams): UseDynamicColumnWidthsResult { const styles = useThemeStyles(); const {values} = useLayoutSpacing(); - const noDynamicWidths = {gridTemplateColumns: undefined, scrollWidth: undefined}; + const noDynamicWidths: UseDynamicColumnWidthsResult = { + gridTemplateColumns: undefined, + scrollWidth: undefined, + rowWidth: undefined, + resizableColumnKeys: [], + resolvedColumnWidths: {}, + dragMinWidths: {}, + }; // Checked before anything else, so native never walks the data to gather text that it can't measure anyway. if (!isEnabled || tableWidth <= 0 || !canMeasureText()) { @@ -134,6 +171,7 @@ function useDynamicColumnWidths> = []; + const fixedColumnWidths = new Map(); let fixedColumnsWidth = 0; for (const column of columns) { @@ -144,6 +182,7 @@ function useDynamicColumnWidths 0 ? widths : distributeEqualWidths(dynamicColumns.length, availableWidth); + // Keyed by column rather than tracked with a running index, so the tracks can be built without mutating a counter // from inside the mapping callback (which the React Compiler can't compile). - const widthByColumnKey = new Map(); + const resolvedColumnWidths: Record = {}; for (const [index, column] of dynamicColumns.entries()) { - widthByColumnKey.set(column.key, widths.at(index) ?? 0); + resolvedColumnWidths[column.key] = resolvedWidths.at(index) ?? 0; } - const gridTemplateColumns = columns.map((column) => (typeof column.width === 'number' ? `${column.width}px` : `${widthByColumnKey.get(column.key) ?? 0}px`)); - - if (!shouldScrollHorizontally) { - return {gridTemplateColumns, scrollWidth: undefined}; + for (const [columnKey, fixedWidth] of fixedColumnWidths) { + resolvedColumnWidths[columnKey] = fixedWidth; } - // The rows are wider than the table, so the caller scrolls them horizontally at exactly the width they need. This - // adds back all of the row chrome subtracted above, margin included: the rows keep their horizontal margin inside - // the scrolled content, so leaving it out would make the content container too narrow and clip the rows' trailing - // edge at the end of the scroll. - const scrollWidth = widths.reduce((total, width) => total + width, 0) + fixedColumnsWidth + selectionColumnWidth + totalGapWidth + rowChromeWidth; + if (!isColumnResizingEnabled) { + const gridTemplateColumns = columns.map((column) => `${resolvedColumnWidths[column.key] ?? 0}px`); + + if (!shouldScrollHorizontally) { + return {...noDynamicWidths, gridTemplateColumns, resolvedColumnWidths}; + } + + // Rows overflow, so scroll at exactly their width. Margin is included because rows keep it inside the scrolled + // content, and without it their trailing edge gets clipped. + const scrollWidth = resolvedWidths.reduce((total, width) => total + width, 0) + fixedColumnsWidth + selectionColumnWidth + totalGapWidth + rowChromeWidth; + + return {...noDynamicWidths, gridTemplateColumns, scrollWidth, resolvedColumnWidths}; + } - return {gridTemplateColumns, scrollWidth}; + return getResizableColumnLayout({ + columns, + resolvedColumnWidths, + columnWidthOverrides, + tableWidth, + rowChromeWidths: {selectionColumnWidth, totalGapWidth, rowMarginWidth, rowPaddingWidth}, + }); } export default useDynamicColumnWidths; diff --git a/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx b/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx index c0c84e65fab4..e25a303d90a5 100644 --- a/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx +++ b/src/components/Tables/WorkspaceCompanyCardsTable/index.tsx @@ -500,6 +500,7 @@ function WorkspaceCompanyCardsTable({ isItemInSearch={isItemInSearch} isItemInFilter={isItemInFilter} shouldUseDynamicColumns + columnResizingID={CONST.TABLES.COLUMN_RESIZING_IDS.WORKSPACE_COMPANY_CARDS} initialSortColumn="member" selectionEnabled={showTableControls} selectedKeys={validSelectedCardKeys} diff --git a/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx b/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx index 1c0117903eab..cbebbde00fef 100644 --- a/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx +++ b/src/components/Tables/WorkspaceExpensifyCardsTable/index.tsx @@ -24,6 +24,7 @@ import WorkspaceCardListLabels from '@pages/workspace/expensifyCard/WorkspaceCar import {fontScale} from '@styles/typography'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; import type {Card, PersonalDetails, PersonalDetailsList, Policy} from '@src/types/onyx'; import type {CardLimitType} from '@src/types/onyx/Card'; import type ExpensifyCardSettings from '@src/types/onyx/ExpensifyCardSettings'; @@ -328,6 +329,7 @@ export default function WorkspaceExpensifyCardsTable({ compareItems={compareItems} isItemInSearch={isItemInSearch} shouldUseDynamicColumns + columnResizingID={CONST.TABLES.COLUMN_RESIZING_IDS.WORKSPACE_EXPENSIFY_CARDS} initialSortColumn="name" narrowLayoutSortColumn="name" title={translate('workspace.common.expensifyCard')} diff --git a/src/components/Tables/WorkspaceListTable/WorkspaceRowBrickRoadIndicator.tsx b/src/components/Tables/WorkspaceListTable/WorkspaceRowBrickRoadIndicator.tsx index a7a78ad6d77c..84be594a3182 100644 --- a/src/components/Tables/WorkspaceListTable/WorkspaceRowBrickRoadIndicator.tsx +++ b/src/components/Tables/WorkspaceListTable/WorkspaceRowBrickRoadIndicator.tsx @@ -1,5 +1,6 @@ import Icon from '@components/Icon'; +import useHasApprovalWorkflowWithNonMemberApprover from '@hooks/useHasApprovalWorkflowWithNonMemberApprover'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useOnyx from '@hooks/useOnyx'; import useTheme from '@hooks/useTheme'; @@ -55,10 +56,11 @@ function WorkspaceRowBrickRoadIndicator({policyID}: WorkspaceRowBrickRoadIndicat const [hasPolicyErrors] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {selector: createPolicyErrorsSelector(connectionSyncProgress)}); const [isHRCompleteSetupNeeded] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {selector: isMergeHRCompleteSetupNeededSelector}); const [isQBORefreshTokenExpiringSoon] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {selector: isQBORefreshTokenExpiringSoonSelector}); + const hasApprovalWorkflowWithNonMemberApprover = useHasApprovalWorkflowWithNonMemberApprover(policyID); // All of the error sources resolve to ERROR or undefined. The HR setup check and the QBO token expiry // warning are the only INFO sources, and every error takes precedence over them. - const hasError = !!hasReimbursementAccountErrors || !!hasCardFeedErrors || !!hasPolicyErrors; + const hasError = !!hasReimbursementAccountErrors || !!hasCardFeedErrors || !!hasPolicyErrors || hasApprovalWorkflowWithNonMemberApprover; if (!hasError && !isHRCompleteSetupNeeded && !isQBORefreshTokenExpiringSoon) { return null; diff --git a/src/components/Tables/WorkspaceMembersTable/index.tsx b/src/components/Tables/WorkspaceMembersTable/index.tsx index e32550d081a4..91ecfb28a192 100644 --- a/src/components/Tables/WorkspaceMembersTable/index.tsx +++ b/src/components/Tables/WorkspaceMembersTable/index.tsx @@ -306,6 +306,7 @@ export default function WorkspaceMembersTable({ return ( ) => hasApprovalWorkflowWithNonMemberApprover({policy, currentUserLogin, rules, isMultipleApproversBetaEnabled}); + const [hasNonMemberApprover] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {selector: policySelector}); + + return !!hasNonMemberApprover; +} + +export default useHasApprovalWorkflowWithNonMemberApprover; diff --git a/src/hooks/useLocalReceiptThumbnail.ts b/src/hooks/useLocalReceiptThumbnail.ts index 54b87caa45aa..04205a816098 100644 --- a/src/hooks/useLocalReceiptThumbnail.ts +++ b/src/hooks/useLocalReceiptThumbnail.ts @@ -2,6 +2,8 @@ import {generateThumbnail} from '@pages/iou/request/step/IOURequestStepScan/crop import {useEffect, useRef, useState, useTransition} from 'react'; +import useReceiptUpgradeCount from './useReceiptUpgradeCount'; + const thumbnailCache = new Map(); /** Track how many mounted hook instances reference each sourceUri */ const thumbnailRefCount = new Map(); @@ -42,10 +44,13 @@ function useLocalReceiptThumbnail(sourceUri: string | undefined, isLocalFile: bo const [, startTransition] = useTransition(); const retainedUriRef = useRef(undefined); + const upgradeCount = useReceiptUpgradeCount(sourceUri); + // Resolve cached thumbnails synchronously during render (fast path) const cachedUri = sourceUri ? thumbnailCache.get(sourceUri) : undefined; const resultForCurrentSource = asyncResult?.source === sourceUri ? asyncResult : undefined; - const thumbnailUri = cachedUri ?? resultForCurrentSource?.uri; + const resolvedUri = cachedUri ?? resultForCurrentSource?.uri; + const thumbnailUri = resolvedUri && upgradeCount > 0 ? `${resolvedUri.split('#').at(0) ?? resolvedUri}#upgraded${upgradeCount}` : resolvedUri; const shouldGenerate = !!sourceUri && isLocalFile && !cachedUri; const isGenerating = shouldGenerate && !resultForCurrentSource?.done; diff --git a/src/hooks/usePolicyIndicatorChecks.ts b/src/hooks/usePolicyIndicatorChecks.ts index 50b686f25642..ed4ff9fb379f 100644 --- a/src/hooks/usePolicyIndicatorChecks.ts +++ b/src/hooks/usePolicyIndicatorChecks.ts @@ -10,15 +10,17 @@ import { shouldShowPolicyError, shouldShowSyncError, } from '@libs/PolicyUtils'; +import {getApprovalWorkflowRulesForPolicy, hasApprovalWorkflowWithNonMemberApprover} from '@libs/WorkflowUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy} from '@src/types/onyx'; import type IndicatorStatus from '@src/types/utils/IndicatorStatus'; -import {accountIDSelector} from '@selectors/Session'; +import {accountIDSelector, emailSelector} from '@selectors/Session'; import useOnyx from './useOnyx'; +import usePermissions from './usePermissions'; import usePoliciesWithCardFeedErrors from './usePoliciesWithCardFeedErrors'; type PolicyIndicatorChecksResult = { @@ -41,6 +43,10 @@ function usePolicyIndicatorChecks(): PolicyIndicatorChecksResult { const [allDomains] = useOnyx(ONYXKEYS.COLLECTION.DOMAIN); const [allDomainPendingActions] = useOnyx(ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS); const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); + const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: emailSelector}); + const {isBetaEnabled} = usePermissions(); + const isMultipleApproversBetaEnabled = isBetaEnabled(CONST.BETAS.MULTIPLE_APPROVERS); + const [allRules] = useOnyx(ONYXKEYS.COLLECTION.RULE); const hasPendingDomainAdminRequests = Object.entries(allDomains ?? {}).some(([key, domain]) => hasPendingDomainAdminRequestsToReview(domain, currentUserAccountID, allDomainPendingActions?.[key.replace(ONYXKEYS.COLLECTION.DOMAIN, ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS)]), @@ -75,6 +81,18 @@ function usePolicyIndicatorChecks(): PolicyIndicatorChecksResult { ), ), ], + [ + CONST.INDICATOR_STATUS.HAS_APPROVAL_WORKFLOW_NON_MEMBER_APPROVER, + cleanPolicies.find((cleanPolicy) => + hasApprovalWorkflowWithNonMemberApprover({ + policy: cleanPolicy, + currentUserLogin, + // Rules only route the workflows under the beta, so they aren't used otherwise + rules: isMultipleApproversBetaEnabled ? getApprovalWorkflowRulesForPolicy(allRules, cleanPolicy?.id) : undefined, + isMultipleApproversBetaEnabled, + }), + ), + ], ]; const policyInfoChecks: Array<[IndicatorStatus, Policy | undefined]> = [ [CONST.INDICATOR_STATUS.HAS_MERGE_HR_SETUP_NEEDED, cleanPolicies.find((policy) => isPolicyAdmin(policy) && isMergeHRCompleteSetupNeeded(policy))], diff --git a/src/hooks/useReceiptUpgradeCount.ts b/src/hooks/useReceiptUpgradeCount.ts new file mode 100644 index 000000000000..5c4bf0a9363b --- /dev/null +++ b/src/hooks/useReceiptUpgradeCount.ts @@ -0,0 +1,16 @@ +import {getUpgradeCount, subscribe} from '@libs/ReceiptStorage/receiptUpgrades'; + +import {useSyncExternalStore} from 'react'; + +function toUpgradeKey(sourceUri: unknown): string { + if (typeof sourceUri !== 'string') { + return ''; + } + return sourceUri.split('/').pop()?.split('#').at(0)?.split('?').at(0) ?? ''; +} + +function useReceiptUpgradeCount(sourceUri: string | number | undefined): number { + return useSyncExternalStore(subscribe, () => getUpgradeCount(toUpgradeKey(sourceUri))); +} + +export default useReceiptUpgradeCount; diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index 09bf411c31b8..d570e8522a44 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -5,7 +5,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import REPORT_LINK_ROUTE_PARAMS from '@libs/Navigation/reportLinkRouteParams'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; -import {isReportPreviewAction} from '@libs/ReportActionsUtils'; +import {getVisibleReportActionErrors, isReportPreviewAction} from '@libs/ReportActionsUtils'; import {getReportLastVisibleActionCreated, shouldReportAlignToTop} from '@libs/ReportUtils'; import type {ReportsSplitNavigatorParamList} from '@navigation/types'; @@ -21,6 +21,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; +import {isEmptyValueObject} from '@src/types/utils/EmptyObject'; import type {ViewableItemsChanged} from '@src/types/utils/ReactNativeCompat'; import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; @@ -347,7 +348,8 @@ function useReportActionsScroll({ return () => clearTimeout(timer); }, [actionIdToHighlight]); - const lastIOUActionWithError = sortedVisibleReportActions.find((action) => action.errors); + // Only errors the user can actually see should pull the list down to them. + const lastIOUActionWithError = sortedVisibleReportActions.find((action) => !isEmptyValueObject(getVisibleReportActionErrors(action))); const prevLastIOUActionWithError = usePrevious(lastIOUActionWithError); // Scroll to the bottom when a new errored action appears, so the user sees the failed money request. Re-checked diff --git a/src/hooks/useSaveMarketingAttribution.ts b/src/hooks/useSaveMarketingAttribution.ts new file mode 100644 index 000000000000..5712f7bd9ff2 --- /dev/null +++ b/src/hooks/useSaveMarketingAttribution.ts @@ -0,0 +1,32 @@ +import {captureMarketingAttributionFromURL, saveMarketingAttribution} from '@libs/actions/MarketingAttribution'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import {hasNonAnonymousSessionSelector} from '@src/selectors/Session'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +import {useEffect, useRef, useState} from 'react'; + +import useOnyx from './useOnyx'; + +/** + * Reads marketing attribution from the landing URL on the first render, and saves it once the session has loaded, + * skipping it when the user is already signed in. Anonymous sessions (used to view public rooms) still save it, since those users + * can sign up from the room. Used in Expensify.tsx, which renders before NavigationRoot, + * so the query string is read before the router can strip it. + */ +function useSaveMarketingAttribution() { + const [capturedAttribution] = useState(captureMarketingAttributionFromURL); + const [hasNonAnonymousSession, sessionMetadata] = useOnyx(ONYXKEYS.SESSION, {selector: hasNonAnonymousSessionSelector}); + const isSessionLoading = isLoadingOnyxValue(sessionMetadata); + const hasHandledAttribution = useRef(false); + + useEffect(() => { + if (isSessionLoading || hasHandledAttribution.current) { + return; + } + hasHandledAttribution.current = true; + saveMarketingAttribution(capturedAttribution, !!hasNonAnonymousSession); + }, [isSessionLoading, hasNonAnonymousSession, capturedAttribution]); +} + +export default useSaveMarketingAttribution; diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 44161a56b19e..635fa6c4e26e 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -1426,9 +1426,12 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const deleteModalTitle = isDeletingOnlyExpenses ? getDeleteExpenseTitle(translate, expenseCount === 1 ? firstTransaction : undefined, expenseCount) : translate('iou.deleteReport', {count: deleteCount}); - const deleteModalPrompt = isDeletingOnlyExpenses - ? getDeleteConfirmationPrompt(translate, expenseCount === 1 ? firstTransaction : undefined, expenseCount, hasSomePendingExpenses) - : translate('iou.deleteReportConfirmation', {count: deleteCount}); + let deleteModalPrompt = translate('iou.deleteReportConfirmation', {count: deleteCount}); + if (isDeletingOnlyExpenses) { + deleteModalPrompt = getDeleteConfirmationPrompt(translate, expenseCount === 1 ? firstTransaction : undefined, expenseCount, hasSomePendingExpenses); + } else if (isExpenseReportType && deleteCount === 1) { + deleteModalPrompt = translate('iou.deleteExpenseReportConfirmation'); + } const handleDeleteSelectedTransactions = useCallback(async () => { if (!hash) { diff --git a/src/libs/API/index.ts b/src/libs/API/index.ts index 14c224d8b036..5230eef14d96 100644 --- a/src/libs/API/index.ts +++ b/src/libs/API/index.ts @@ -186,8 +186,8 @@ function read(command: TComm // Apply optimistic updates of read requests immediately const request = prepareRequest(command, CONST.API_REQUEST_TYPE.READ, apiCommandParameters, onyxData); - // Sign in with shortLivedAuthToken command shouldn't be blocked by write commands - if (command === READ_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN || command === READ_COMMANDS.SIGN_IN_WITH_SUPPORT_AUTH_TOKEN) { + // Sign in with supportAuthToken command shouldn't be blocked by write commands + if (command === READ_COMMANDS.SIGN_IN_WITH_SUPPORT_AUTH_TOKEN) { processRequest(request, CONST.API_REQUEST_TYPE.READ); return; } diff --git a/src/libs/API/parameters/BeginAppleSignInParams.ts b/src/libs/API/parameters/BeginAppleSignInParams.ts index 1fd844c2ab03..f4d610f78845 100644 --- a/src/libs/API/parameters/BeginAppleSignInParams.ts +++ b/src/libs/API/parameters/BeginAppleSignInParams.ts @@ -1,9 +1,10 @@ import type Locale from '@src/types/onyx/Locale'; +import type MarketingAttribution from '@src/types/onyx/MarketingAttribution'; type BeginAppleSignInParams = { idToken: string | undefined | null; preferredLocale: Locale | null; deviceInfo: string; -}; +} & MarketingAttribution; export default BeginAppleSignInParams; diff --git a/src/libs/API/parameters/BeginGoogleSignInParams.ts b/src/libs/API/parameters/BeginGoogleSignInParams.ts index 5aa6775d0cc3..f6161d78f8b4 100644 --- a/src/libs/API/parameters/BeginGoogleSignInParams.ts +++ b/src/libs/API/parameters/BeginGoogleSignInParams.ts @@ -1,9 +1,10 @@ import type Locale from '@src/types/onyx/Locale'; +import type MarketingAttribution from '@src/types/onyx/MarketingAttribution'; type BeginGoogleSignInParams = { token: string | null; preferredLocale: Locale | null; deviceInfo: string; -}; +} & MarketingAttribution; export default BeginGoogleSignInParams; diff --git a/src/libs/API/parameters/SignInWithShortLivedAuthTokenParams.ts b/src/libs/API/parameters/SignInWithShortLivedAuthTokenParams.ts index 65f099a21632..fd6e287a5736 100644 --- a/src/libs/API/parameters/SignInWithShortLivedAuthTokenParams.ts +++ b/src/libs/API/parameters/SignInWithShortLivedAuthTokenParams.ts @@ -13,6 +13,11 @@ type SignInWithShortLivedAuthTokenParams = { */ authMethod?: ValueOf; deviceInfo: string; + /** + * The device's current session `authToken`, sent only when the device is already authenticated. + * Omit when the device has no current session. + */ + currentAuthToken?: string; }; export default SignInWithShortLivedAuthTokenParams; diff --git a/src/libs/API/parameters/SignUpUserParams.ts b/src/libs/API/parameters/SignUpUserParams.ts index 908467252b1e..a854586628de 100644 --- a/src/libs/API/parameters/SignUpUserParams.ts +++ b/src/libs/API/parameters/SignUpUserParams.ts @@ -1,10 +1,11 @@ import type Locale from '@src/types/onyx/Locale'; +import type MarketingAttribution from '@src/types/onyx/MarketingAttribution'; type SignUpUserParams = { email?: string; preferredLocale: Locale | null; hasSMSMarketingConsent?: boolean; deviceInfo: string; -}; +} & MarketingAttribution; export default SignUpUserParams; diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 2d8ec5e3f3c6..29f3bdb82b79 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -122,7 +122,12 @@ const COMPANY_CARD_BANK_ICON_NAMES = [ type CompanyCardBankIconName = TupleToUnion; type CompanyCardBankIcons = Record; -const CUSTOM_FEED_PREFIXES = [CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD, CONST.COMPANY_CARD.FEED_BANK_NAME.VISA, CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX]; +const CUSTOM_FEED_PREFIXES = [ + CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD, + CONST.COMPANY_CARD.FEED_BANK_NAME.VISA, + CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK, + CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX, +]; type CardConnectionStatusDisplay = { statusKey: TranslationPaths; @@ -149,6 +154,7 @@ type CardConnectionStatusDisplayParams = { const feedNamesMapping = { [CONST.COMPANY_CARD.FEED_BANK_NAME.CSV]: CONST.COMPANY_CARDS.CARD_TYPE_NAMES.CSV, + [CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK]: CONST.COMPANY_CARDS.CARD_TYPE_NAMES.MOCK_COMMERCIAL, [CONST.COMPANY_CARD.FEED_BANK_NAME.VISA]: CONST.COMPANY_CARDS.CARD_TYPE_NAMES.VISA, [CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD]: CONST.COMPANY_CARDS.CARD_TYPE_NAMES.MASTERCARD, [CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX]: CONST.COMPANY_CARDS.BANKS.AMEX, @@ -1041,6 +1047,7 @@ const ANZ_NZ_COMMERCIAL_FEED_DISPLAY_NAME = 'ANZ NZ'; const COMMERCIAL_FEED_DISPLAY_BASES = [ {base: ANZ_NZ_COMMERCIAL_FEED_BASE, displayName: ANZ_NZ_COMMERCIAL_FEED_DISPLAY_NAME, shouldHideOne: false}, + {base: CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK, displayName: getBankName(CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK), shouldHideOne: true}, {base: CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX, displayName: getBankName(CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX), shouldHideOne: true}, {base: CONST.COMPANY_CARD.FEED_BANK_NAME.VISA, displayName: getBankName(CONST.COMPANY_CARD.FEED_BANK_NAME.VISA), shouldHideOne: true}, {base: CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD, displayName: getBankName(CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD), shouldHideOne: true}, @@ -1611,7 +1618,13 @@ function isSmartLimitEnabled(cardsList: CardList) { return hasAssignedCardMatching(cardsList, (card) => card.nameValuePairs?.limitType === CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART); } -const CUSTOM_FEEDS = [CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD, CONST.COMPANY_CARD.FEED_BANK_NAME.VISA, CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX, CONST.COMPANY_CARD.FEED_BANK_NAME.CSV]; +const CUSTOM_FEEDS = [ + CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD, + CONST.COMPANY_CARD.FEED_BANK_NAME.VISA, + CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK, + CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX, + CONST.COMPANY_CARD.FEED_BANK_NAME.CSV, +]; function collectUsedCSVFeedSlotNumbersFromCompanyCards(companyCards: CompanyFeeds | undefined, csvPrefix: string): number[] { const numbers: number[] = []; diff --git a/src/libs/ExportOnyxState/masking.ts b/src/libs/ExportOnyxState/masking.ts index 47e3d71a2f83..b1912a70125c 100644 --- a/src/libs/ExportOnyxState/masking.ts +++ b/src/libs/ExportOnyxState/masking.ts @@ -369,6 +369,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED, ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING, ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL, + ONYXKEYS.TABLE_COLUMN_WIDTHS, ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_SNAPSHOT_HASH, ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, ONYXKEYS.TRAVEL_BILLING_STATEMENT, @@ -459,6 +460,7 @@ const onyxKeysToMaskFragileData = new Set([ ONYXKEYS.LAST_ROUTE, ONYXKEYS.LAST_VISITED_PATH, ONYXKEYS.LOGINS, + ONYXKEYS.MARKETING_ATTRIBUTION, ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS, ONYXKEYS.NEW_GROUP_CHAT_DRAFT, ONYXKEYS.NVP_BILLING_FUND_ID, diff --git a/src/libs/Middleware/SaveResponseInOnyx.ts b/src/libs/Middleware/SaveResponseInOnyx.ts index 1a423540de20..a8798425cd7f 100644 --- a/src/libs/Middleware/SaveResponseInOnyx.ts +++ b/src/libs/Middleware/SaveResponseInOnyx.ts @@ -22,7 +22,7 @@ const requestsToIgnoreLastUpdateID = new Set([ ]); // A request belongs here when its successData/finallyData is what unblocks authentication, because parking that leaves the client unable to reauthenticate. -const requestsToApplyWithoutAdvancingLastUpdateID = new Set([READ_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN, READ_COMMANDS.SIGN_IN_WITH_SUPPORT_AUTH_TOKEN]); +const requestsToApplyWithoutAdvancingLastUpdateID = new Set([SIDE_EFFECT_REQUEST_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN, READ_COMMANDS.SIGN_IN_WITH_SUPPORT_AUTH_TOKEN]); const SaveResponseInOnyx: Middleware = (requestResponse: Promise | void>, request: OnyxRequest) => requestResponse.then((response = {}) => { diff --git a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx index 0250e3d1e4b9..d4b35b30d570 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx @@ -169,8 +169,10 @@ function AuthScreensInitHandler() { const isLoggingInAsNewUser = !!session?.email && SessionUtils.isLoggingInAsNewUser(currentUrl, session.email); // Sign out the current user if we're transitioning with a different user const isTransitioning = currentUrl.includes(ROUTES.TRANSITION_BETWEEN_APPS); - const isSupportalTransition = currentUrl.includes('authTokenType=support'); - if (isLoggingInAsNewUser && isTransitioning) { + const isSupportalTransition = getSearchParamFromUrl(currentUrl, 'authTokenType') === CONST.AUTH_TOKEN_TYPES.SUPPORT; + + // A non-supportal account switch waits for the user to confirm in LogOutPreviousUserPage instead of signing out here. + if (isLoggingInAsNewUser && isTransitioning && isSupportalTransition) { Log.info('[AuthScreensInitHandler] Signing out for a transition to another user', false, { isLinkNamingDelegator: SessionUtils.isLoggingInAsDelegate(currentUrl), isDelegateSession: Session.isDelegateSession(session), diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 3814b475e959..29ef730b798e 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2666,6 +2666,7 @@ type MoneyRequestNavigatorParamList = { upgradeBackTo?: Routes; upgradePath?: ValueOf; shouldSubmitExpense?: boolean; + shouldReturnToConfirmation?: boolean; }; [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_DESTINATION]: { action: IOUAction; diff --git a/src/libs/ReceiptStorage/index.native.ts b/src/libs/ReceiptStorage/index.native.ts index f735b5bebfd4..51234e482575 100644 --- a/src/libs/ReceiptStorage/index.native.ts +++ b/src/libs/ReceiptStorage/index.native.ts @@ -1,6 +1,8 @@ +import {checkFileExistsWithReason} from '@libs/fileDownload/checkFileExists'; import {cleanFileName, isLocalFile} from '@libs/fileDownload/FileUtils'; import fileURIToPath from '@libs/fileURIToPath'; import getReceiptsUploadFolderPath from '@libs/getReceiptsUploadFolderPath'; +import Log from '@libs/Log'; import {rand64} from '@libs/NumberUtils'; import RNFS from 'react-native-fs'; @@ -8,11 +10,31 @@ import RNFS from 'react-native-fs'; import type ReceiptStorage from './types'; import {toDurableName} from './durableFolder'; +import {claimForRead} from './receiptUpgrades'; // A durable name is the bare filename inside the receipts folder. Never store a full path: iOS moves // the app data container on most upgrades, so an absolute path stored before the upgrade names a // directory the device no longer has, even though iOS carried the file itself across. +const SWEEP_IDLE_TIMEOUT_MS = 10000; + +// Specific enough that no adopted file carries them: `adopt` keeps the picked file's extension, so a plain +// `.backup` attachment would otherwise look like a swap leftover to the sweep. +const STAGED_SUFFIX = '.receipt-swap-staged'; +const BACKUP_SUFFIX = '.receipt-swap-backup'; + +// RNFS `readDir` on the receipts folder costs ~1ms per file on Android and blocks every other native call +// while it runs, so the sweep lists this folder of pending swaps instead. +const PENDING_SWAPS_FOLDER = '.pending-receipt-swaps'; + +const swapsInFlight = new Map>(); + +const committedSwaps = new Map>(); + +async function waitForCommittedSwap(target: string) { + await committedSwaps.get(target)?.catch(() => {}); +} + async function verify(dir: string, name: string): Promise { if (!name || !(await RNFS.exists(`${dir}/${name}`))) { throw new Error('[ReceiptStorage] file is not in durable storage'); @@ -50,6 +72,194 @@ const adopt: ReceiptStorage['adopt'] = async (uriOrPath, fileName) => { return verify(dir, uniqueName); }; +const discard: ReceiptStorage['discard'] = async (uriOrPath) => { + const path = fileURIToPath(uriOrPath); + if (!(await RNFS.exists(path))) { + return; + } + await RNFS.unlink(path); +}; + +async function discardLeftover(path: string): Promise { + try { + await discard(path); + return true; + } catch (error) { + Log.warn('[ReceiptStorage] could not delete a leftover receipt copy', {path, error: error instanceof Error ? error.message : String(error)}); + return false; + } +} + +async function restoreInterruptedSwap(target: string, {shouldWaitForRunningSwap = true} = {}): Promise { + if (await RNFS.exists(target)) { + return true; + } + + if (shouldWaitForRunningSwap && swapsInFlight.has(target)) { + await swapsInFlight.get(target)?.catch(() => {}); + return RNFS.exists(target); + } + + const backupPath = `${target}${BACKUP_SUFFIX}`; + if (!(await RNFS.exists(backupPath))) { + return RNFS.exists(target); + } + + try { + await RNFS.moveFile(backupPath, target); + } catch (error) { + Log.warn('[ReceiptStorage] could not put back a receipt left by an interrupted swap', {error: error instanceof Error ? error.message : String(error)}); + return RNFS.exists(target); + } + + Log.info('[ReceiptStorage] put back a receipt left by an interrupted swap'); + return true; +} + +async function swapFiles(dir: string, durableName: string, target: string, uriOrPath: string, shouldAbort?: () => boolean): Promise { + // Neither platform can move a file onto one that already exists. NSFileManager refuses and Android's + // `renameTo` is unreliable, so stage the new bytes beside the receipt, then swap them in with two + // renames inside the directory. The original keeps a second name until the swap succeeds. + const stagedPath = `${target}${STAGED_SUFFIX}`; + const backupPath = `${target}${BACKUP_SUFFIX}`; + // An attempt that died mid-swap would leave these occupied, and iOS refuses an occupied destination. + await discard(stagedPath); + await discard(backupPath); + await RNFS.moveFile(fileURIToPath(uriOrPath), stagedPath); + + if (shouldAbort?.()) { + await discardLeftover(stagedPath); + throw new Error('[ReceiptStorage] an upload claimed the receipt, so it was left as it stands'); + } + + let releaseCommitted: () => void = () => {}; + committedSwaps.set( + target, + new Promise((resolve) => { + releaseCommitted = resolve; + }), + ); + + try { + await RNFS.moveFile(target, backupPath); + await RNFS.moveFile(stagedPath, target); + } catch (error) { + await discardLeftover(stagedPath); + + if (await RNFS.exists(target)) { + throw error; + } + + try { + await RNFS.moveFile(backupPath, target); + } catch (restoreError) { + const reason = restoreError instanceof Error ? restoreError.message : String(restoreError); + throw new Error(`[ReceiptStorage] could not restore the receipt, it is left at ${backupPath}: ${reason}`); + } + + throw error; + } finally { + committedSwaps.delete(target); + releaseCommitted(); + } + + await discardLeftover(backupPath); + + return verify(dir, durableName); +} + +async function markSwapStarted(dir: string, durableName: string) { + const pendingSwapsDir = `${dir}/${PENDING_SWAPS_FOLDER}`; + await RNFS.mkdir(pendingSwapsDir); + await RNFS.writeFile(`${pendingSwapsDir}/${durableName}`, '', 'utf8'); +} + +async function markSwapFinished(dir: string, durableName: string, target: string) { + try { + const [isInPlace, hasStaged, hasBackup] = await Promise.all([RNFS.exists(target), RNFS.exists(`${target}${STAGED_SUFFIX}`), RNFS.exists(`${target}${BACKUP_SUFFIX}`)]); + if (!isInPlace || hasStaged || hasBackup) { + return; + } + await discard(`${dir}/${PENDING_SWAPS_FOLDER}/${durableName}`); + } catch (error) { + Log.warn('[ReceiptStorage] could not clear a finished swap from the pending swaps', {error: error instanceof Error ? error.message : String(error)}); + } +} + +async function swapIntoPlace(dir: string, durableName: string, target: string, uriOrPath: string, shouldAbort?: () => boolean): Promise { + await markSwapStarted(dir, durableName); + try { + return await swapFiles(dir, durableName, target, uriOrPath, shouldAbort); + } finally { + await markSwapFinished(dir, durableName, target); + } +} + +async function sweepInterruptedSwaps(dir: string) { + const pendingSwapsDir = `${dir}/${PENDING_SWAPS_FOLDER}`; + if (!(await RNFS.exists(pendingSwapsDir))) { + return; + } + + const names = (await RNFS.readDir(pendingSwapsDir)).map((entry) => entry.name); + + await Promise.all( + names.map(async (name) => { + const target = `${dir}/${name}`; + const isRestored = await restoreInterruptedSwap(target, {shouldWaitForRunningSwap: false}); + const isStagedGone = await discardLeftover(`${target}${STAGED_SUFFIX}`); + + if (!isRestored) { + if (isStagedGone && !(await RNFS.exists(`${target}${BACKUP_SUFFIX}`))) { + await discardLeftover(`${pendingSwapsDir}/${name}`); + } + return; + } + const isBackupGone = await discardLeftover(`${target}${BACKUP_SUFFIX}`); + + if (isStagedGone && isBackupGone) { + await discardLeftover(`${pendingSwapsDir}/${name}`); + } + }), + ); +} + +let leftoversSwept: Promise | undefined; + +function whenLeftoversSwept(dir: string): Promise { + leftoversSwept ??= sweepInterruptedSwaps(dir).catch((error: unknown) => { + Log.warn('[ReceiptStorage] could not sweep leftover receipt copies', {error: error instanceof Error ? error.message : String(error)}); + }); + return leftoversSwept; +} + +const overwrite: ReceiptStorage['overwrite'] = async (durableName, uriOrPath, shouldAbort) => { + const dir = getReceiptsUploadFolderPath(); + if (!dir) { + throw new Error('[ReceiptStorage] no receipts folder on this platform'); + } + + const target = `${dir}/${durableName}`; + + if (swapsInFlight.has(target)) { + throw new Error('[ReceiptStorage] a swap is already running for this receipt'); + } + + const swap = (async () => { + await whenLeftoversSwept(dir); + await restoreInterruptedSwap(target, {shouldWaitForRunningSwap: false}); + await verify(dir, durableName); + return swapIntoPlace(dir, durableName, target, uriOrPath, shouldAbort); + })(); + swapsInFlight.set(target, swap); + + try { + return await swap; + } finally { + swapsInFlight.delete(target); + } +}; + const toLocalUri: ReceiptStorage['toLocalUri'] = (durableName) => `file://${getReceiptsUploadFolderPath()}/${durableName}`; const retain: ReceiptStorage['retain'] = () => {}; @@ -65,6 +275,81 @@ const resolve: ReceiptStorage['resolve'] = (source) => { return durableName ? toLocalUri(durableName) : source; }; -const receiptStorage: ReceiptStorage = {adopt, toLocalUri, retain, resolve}; +const settle: ReceiptStorage['settle'] = async (durableName) => { + if (!durableName) { + return; + } + + claimForRead(durableName); + + const dir = getReceiptsUploadFolderPath(); + if (!dir) { + return; + } + + await waitForCommittedSwap(`${dir}/${durableName}`); +}; + +const locate: ReceiptStorage['locate'] = async (source) => { + const uri = resolve(source); + if (!uri) { + return undefined; + } + + if (isLocalFile(uri)) { + const dir = getReceiptsUploadFolderPath(); + const target = fileURIToPath(uri); + + if (dir) { + whenLeftoversSwept(dir).catch(() => {}); + } + + await settle(target.split('/').pop() ?? ''); + } + + if ((await checkFileExistsWithReason(uri)).exists) { + return uri; + } + + if (!isLocalFile(uri) || !(await restoreInterruptedSwap(fileURIToPath(uri)))) { + return undefined; + } + + return uri; +}; + +const recheckAfterSwap: ReceiptStorage['recheckAfterSwap'] = async (source) => { + const uri = resolve(source); + if (!uri || !isLocalFile(uri)) { + return false; + } + + const target = fileURIToPath(uri); + try { + await swapsInFlight.get(target)?.catch(() => {}); + return await restoreInterruptedSwap(target); + } catch (error) { + Log.warn('[ReceiptStorage] could not recheck a receipt after a failed read', {error: error instanceof Error ? error.message : String(error)}); + return false; + } +}; + +const sweepLeftovers: ReceiptStorage['sweepLeftovers'] = async () => { + try { + const dir = getReceiptsUploadFolderPath(); + if (!dir) { + return; + } + + await new Promise((onIdle) => { + requestIdleCallback(() => onIdle(), {timeout: SWEEP_IDLE_TIMEOUT_MS}); + }); + await whenLeftoversSwept(dir); + } catch (error) { + Log.warn('[ReceiptStorage] could not sweep leftover receipt copies at startup', {error: error instanceof Error ? error.message : String(error)}); + } +}; + +const receiptStorage: ReceiptStorage = {adopt, overwrite, discard, locate, settle, recheckAfterSwap, toLocalUri, retain, resolve, sweepLeftovers}; export default receiptStorage; diff --git a/src/libs/ReceiptStorage/index.ts b/src/libs/ReceiptStorage/index.ts index 8fa98e6d5d15..943d672b93a1 100644 --- a/src/libs/ReceiptStorage/index.ts +++ b/src/libs/ReceiptStorage/index.ts @@ -34,9 +34,15 @@ const resolve: ReceiptStorage['resolve'] = (source) => { const receiptStorage: ReceiptStorage = { adopt: (uriOrPath) => Promise.resolve(uriOrPath), + overwrite: (durableName) => Promise.resolve(durableName), + discard: () => Promise.resolve(), + locate: (source) => Promise.resolve(resolve(source)), + settle: () => Promise.resolve(), + recheckAfterSwap: () => Promise.resolve(false), toLocalUri: (durableName) => durableName, retain, resolve, + sweepLeftovers: () => Promise.resolve(), }; export default receiptStorage; diff --git a/src/libs/ReceiptStorage/receiptUpgrades.ts b/src/libs/ReceiptStorage/receiptUpgrades.ts new file mode 100644 index 000000000000..f93b8fef6aac --- /dev/null +++ b/src/libs/ReceiptStorage/receiptUpgrades.ts @@ -0,0 +1,83 @@ +/** + * Tracks in-flight receipt upgrades and how many times each receipt has been replaced, so views can + * re-decode a file whose bytes changed under an unchanged path and uploads can claim a receipt. + */ +import Log from '@libs/Log'; + +const MAX_TRACKED_UPGRADE_COUNTS = 50; + +type PendingUpgrade = { + isClaimed: boolean; +}; + +const upgradesInFlight = new Map(); + +const upgradeCounts = new Map(); + +const listeners = new Set<() => void>(); + +function notify() { + for (const listener of listeners) { + listener(); + } +} + +function start(durableName: string) { + if (upgradesInFlight.has(durableName)) { + return; + } + + upgradesInFlight.set(durableName, {isClaimed: false}); + notify(); +} + +function finish(durableName: string) { + const upgrade = upgradesInFlight.get(durableName); + if (!upgrade) { + return; + } + + upgradesInFlight.delete(durableName); + notify(); +} + +function recordUpgrade(durableName: string) { + upgradeCounts.set(durableName, (upgradeCounts.get(durableName) ?? 0) + 1); + + while (upgradeCounts.size > MAX_TRACKED_UPGRADE_COUNTS) { + const oldest = upgradeCounts.keys().next().value; + if (oldest === undefined) { + break; + } + upgradeCounts.delete(oldest); + } + + notify(); +} + +function claimForRead(durableName: string) { + const upgrade = upgradesInFlight.get(durableName); + if (!upgrade || upgrade.isClaimed) { + return; + } + + upgrade.isClaimed = true; + Log.info('[ReceiptUpgrades] a reader claimed the receipt, so the upgrade will keep the snapshot', false, {durableName}); +} + +function getUpgradeCount(durableName: string): number { + return upgradeCounts.get(durableName) ?? 0; +} + +function isClaimedForRead(durableName: string): boolean { + return upgradesInFlight.get(durableName)?.isClaimed ?? false; +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export {start, finish, recordUpgrade, claimForRead, isClaimedForRead, getUpgradeCount, subscribe}; diff --git a/src/libs/ReceiptStorage/types.ts b/src/libs/ReceiptStorage/types.ts index affc64c59a53..b2e27a910951 100644 --- a/src/libs/ReceiptStorage/types.ts +++ b/src/libs/ReceiptStorage/types.ts @@ -5,6 +5,24 @@ type ReceiptStorage = { /** Moves a file into the receipts folder and returns its durable name. Rejects when the file did not land. */ adopt: (uriOrPath: string, fileName?: string) => Promise; + /** + * Replaces a receipt's bytes under the same durable name, putting the original back if the swap fails. + * Gives up before the first rename when `shouldAbort` returns true, e.g. because an upload claimed the receipt. + */ + overwrite: (durableName: string, uriOrPath: string, shouldAbort?: () => boolean) => Promise; + + /** Deletes a file if it exists. */ + discard: (uriOrPath: string) => Promise; + + /** Resolves a stored source to a file that exists, waiting for a committed swap and restoring an interrupted one. */ + locate: (source: ReceiptSource | null | undefined) => Promise; + + /** Claims a receipt for reading, so an upgrade still preparing keeps the snapshot, and waits for a swap already renaming. */ + settle: (durableName: string) => Promise; + + /** After a failed read, waits for a running swap and restores an interrupted one. Resolves true when the file is back. */ + recheckAfterSwap: (source: ReceiptSource | null | undefined) => Promise; + /** Valid for this launch only, so never store the result. */ toLocalUri: (durableName: string) => string; @@ -16,6 +34,9 @@ type ReceiptStorage = { /** Re-roots a stored source onto the current folder. A remote source passes through unchanged. */ resolve: (source: ReceiptSource | null | undefined) => string | undefined; + + /** Deletes copies an interrupted swap left in the receipts folder, once the app is idle after startup. */ + sweepLeftovers: () => Promise; }; export default ReceiptStorage; diff --git a/src/libs/SessionUtils.ts b/src/libs/SessionUtils.ts index b90b19acbbd1..16470257cf32 100644 --- a/src/libs/SessionUtils.ts +++ b/src/libs/SessionUtils.ts @@ -3,6 +3,8 @@ import ONYXKEYS from '@src/ONYXKEYS'; import Onyx from 'react-native-onyx'; +import StringUtils from './StringUtils'; + const NEW_PARTNER_USER_ID_PREFIX = 'expensify.cash-'; /** @@ -11,7 +13,7 @@ const NEW_PARTNER_USER_ID_PREFIX = 'expensify.cash-'; function isLoggingInAsNewUser(transitionURL?: string, sessionEmail?: string): boolean { // The OldDot mobile app does not URL encode the parameters, but OldDot web // does. We don't want to deploy OldDot mobile again, so as a work around we - // compare the session email to both the decoded and raw email from the transition link. + // fall back from URLSearchParams to a regex-based lookup if the email doesn't match. const params = new URLSearchParams(transitionURL); const paramsEmail = params.get('email'); const delegatorEmail = params.get('delegatorEmail'); @@ -23,18 +25,14 @@ function isLoggingInAsNewUser(transitionURL?: string, sessionEmail?: string): bo return false; } - // If they do not match it might be due to encoding, so check the raw value - // Capture the un-encoded text in the email param - const emailParamRegex = /[?&]email=([^&]*)/g; - const matches = emailParamRegex.exec(transitionURL ?? ''); - const linkedEmail = matches?.[1] ?? null; + // If URLSearchParams didn't find it (e.g. transitionURL is a full URL which + // mangles the first query-param key), fall back to regex + const linkedEmail = getEmailFromTransitionURL(transitionURL) ?? null; if (linkedEmail === sessionEmail) { return false; } - // If URLSearchParams didn't find it (e.g. transitionURL is a full URL which - // mangles the first query-param key), fall back to regex const linkedDelegatorEmail = getDelegatorEmailFromURL(transitionURL) ?? null; return linkedEmail !== sessionEmail && linkedDelegatorEmail !== sessionEmail; @@ -66,6 +64,38 @@ function getDelegatorEmailFromURL(url?: string): string | undefined { return delegatorMatches?.[1]; } +/** + * Looks for *email* param in given URL using regex + */ +function getEmailFromTransitionURL(url?: string): string | undefined { + if (!url) { + return undefined; + } + + const [urlWithoutHash] = url.split('#', 2); + const queryIndex = urlWithoutHash.indexOf('?'); + if (queryIndex === -1) { + return undefined; + } + + const queryString = urlWithoutHash.slice(queryIndex + 1); + const match = queryString.match(/(?:^|&)email=([^&]*)/); + const email = match?.[1]; + + if (!email) { + return undefined; + } + + let decodedEmail: string; + try { + decodedEmail = decodeURIComponent(email); + } catch { + decodedEmail = email; + } + + return StringUtils.normalize(decodedEmail); +} + let loggedInDuringSession: boolean | undefined; // To tell if the user logged in during this session we will check the value of session.authToken once when the app's JS inits. When the user logs out @@ -126,4 +156,13 @@ function isAgentEmail(email?: string): boolean { return AGENT_EMAIL_REGEX.test(email); } -export {isLoggingInAsNewUser, didUserLogInDuringSession, resetDidUserLogInDuringSession, checkIfShouldUseNewPartnerName, getPartnerCredentials, isLoggingInAsDelegate, isAgentEmail}; +export { + isLoggingInAsNewUser, + didUserLogInDuringSession, + resetDidUserLogInDuringSession, + checkIfShouldUseNewPartnerName, + getPartnerCredentials, + isLoggingInAsDelegate, + isAgentEmail, + getEmailFromTransitionURL, +}; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index d11bf75f32f0..5d204cf44bd8 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -533,19 +533,34 @@ function getAmount(transaction: OnyxInputOrEntry, isFromExpenseRepo } /** - * Return the tax amount field from the transaction. + * Convert a stored tax amount to the sign we display. */ -function getTaxAmount(transaction: OnyxInputOrEntry, isFromExpenseReport: boolean): number { +function normalizeTaxAmountSign(rawAmount: number | undefined, isFromExpenseReport: boolean): number { // IOU requests cannot have negative values but they can be stored as negative values, let's return absolute value if (!isFromExpenseReport) { - return Math.abs(transaction?.taxAmount ?? 0); + return Math.abs(rawAmount ?? 0); } // To avoid -0 being shown, lets only change the sign if the value is other than 0. - const amount = transaction?.taxAmount ?? 0; + const amount = rawAmount ?? 0; return amount ? -amount : 0; } +/** + * Return the tax amount field from the transaction. + */ +function getTaxAmount(transaction: OnyxInputOrEntry, isFromExpenseReport: boolean): number { + return normalizeTaxAmountSign(transaction?.taxAmount, isFromExpenseReport); +} + +/** + * Return the converted tax amount field from the transaction. + * It follows the same sign convention as `getTaxAmount`. + */ +function getConvertedTaxAmount(transaction: OnyxInputOrEntry, isFromExpenseReport: boolean): number { + return normalizeTaxAmountSign(transaction?.convertedTaxAmount, isFromExpenseReport); +} + /** * Return the tax code from the transaction. */ @@ -1977,6 +1992,7 @@ export { getMCCForDisplay, hasDisplayableMCC, getConvertedAmount, + getConvertedTaxAmount, isTimeRequest, getExpenseTypeTranslationKey, getDetailedExpenseTypeTranslationKey, diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index ad90888495d6..3ff72bf38f01 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -3,6 +3,7 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; @@ -25,10 +26,10 @@ import type {ValueOf} from 'type-fest'; import {Str} from 'expensify-common'; import {isBankAccountPartiallySetup} from './BankAccountUtils'; -import {getConnectedHRProvider, getHRAdvancedModeFinalApprover, getHRFinalApprover, isAnyHRConnected, isAnyHRReadOnlyWorkflowMode} from './merge/HRUtils'; +import {getConnectedHRProvider, getHRAdvancedModeFinalApprover, getHRFinalApprover, isAnyHRConnected, isHRAdvancedMode, isAnyHRReadOnlyWorkflowMode} from './merge/HRUtils'; import {getConnectedATSProvider, isAnyRecruitingReadOnlyWorkflowMode} from './merge/RecruitingUtils'; import {rand64} from './NumberUtils'; -import {getDefaultApprover, isExpensifyTeam, shouldFilterExpensifyTeam} from './PolicyUtils'; +import {canMemberWrite, getDefaultApprover, isExpensifyTeam, isNonMemberApprover, isSubmitPolicy, shouldFilterExpensifyTeam, shouldHideDynamicExternalWorkflowPeople} from './PolicyUtils'; import {fromIndexMap, isApprovalWorkflowRule, isRuleFilterComparison, toIndexMap} from './RuleUtils'; const INITIAL_APPROVAL_WORKFLOW: ApprovalWorkflowOnyx = { @@ -86,6 +87,12 @@ type GetApproversParams = { * Email of the first approver */ firstEmail: string; + + /** + * Policy the chain belongs to. When passed, an approver who is no longer on the workspace is kept at the end of + * the chain, flagged, instead of the chain silently stopping before them. + */ + policy?: OnyxEntry; }; /** Resolve the display name for an over-limit forwarder email, falling back to the email itself */ @@ -97,7 +104,7 @@ function getOverLimitForwardsToDisplayName(overLimitForwardsTo: string | undefin } /** Get the list of approvers for a given email */ -function calculateApprovers({employees, firstEmail, personalDetailsByEmail}: GetApproversParams): Approver[] { +function calculateApprovers({employees, firstEmail, personalDetailsByEmail, policy}: GetApproversParams): Approver[] { const approvers: Approver[] = []; // Keep track of approver emails to detect circular references const currentApproverEmails = new Set(); @@ -105,6 +112,17 @@ function calculateApprovers({employees, firstEmail, personalDetailsByEmail}: Get let nextEmail: string | undefined = firstEmail; while (nextEmail) { if (!employees[nextEmail]) { + // Reports still route to an approver who left the workspace, so show them flagged for the admin to replace + if (policy && isNonMemberApprover(policy, nextEmail)) { + approvers.push({ + email: nextEmail, + forwardsTo: undefined, + avatar: personalDetailsByEmail[nextEmail]?.avatar, + displayName: personalDetailsByEmail[nextEmail]?.displayName ?? nextEmail, + isCircularReference: false, + isNotWorkspaceMember: true, + }); + } break; } @@ -119,6 +137,7 @@ function calculateApprovers({employees, firstEmail, personalDetailsByEmail}: Get approvalLimit: employee.approvalLimit, overLimitForwardsTo: employee.overLimitForwardsTo, overLimitForwardsToDisplayName: getOverLimitForwardsToDisplayName(employee.overLimitForwardsTo, personalDetailsByEmail), + ...(policy && isNonMemberApprover(policy, employee.overLimitForwardsTo) ? {isOverLimitForwardsToNotWorkspaceMember: true} : {}), pendingAction: employee.pendingAction, errors: employee.errors, }); @@ -230,7 +249,9 @@ function convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, fir availableMembers.push(member); } - if (!submitsTo || (!employees[submitsTo] && !hrAdvancedModeFinalApproverEmail)) { + // A member who submits to someone no longer on the workspace is still shown under that approver, flagged, + // so the admin can see the broken workflow and fix it instead of it being hidden. + if (!submitsTo) { continue; } @@ -238,7 +259,9 @@ function convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, fir const effectiveSubmitsTo = shouldFilterOutExpensifyTeam && employees[submitsTo] ? (findFirstNonExpensifyApprover(employees, submitsTo) ?? submitsTo) : submitsTo; if (!approvalWorkflows[effectiveSubmitsTo]) { - let approvers = calculateApprovers({employees, firstEmail: effectiveSubmitsTo, personalDetailsByEmail}); + let approvers = calculateApprovers({employees, firstEmail: effectiveSubmitsTo, personalDetailsByEmail, policy}); + // The first approver isn't on employeeList but isn't flagged either, e.g. the owner or an HR advanced mode + // manager, so still show them as the approver if (approvers.length === 0) { approvers = [ { @@ -251,7 +274,9 @@ function convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, fir ]; } if (shouldFilterOutExpensifyTeam) { - approvers = approvers.filter((approver) => !isExpensifyTeam(approver.email)); + // Keep a non-member approver even when it is an Expensify team email, so the workflow never ends up + // without approvers and the admin can still see and fix it. + approvers = approvers.filter((approver) => !!approver.isNotWorkspaceMember || !isExpensifyTeam(approver.email)); } if (hrAdvancedModeFinalApproverEmail) { const lastApprover = approvers.at(-1); @@ -314,7 +339,7 @@ function convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, fir if (firstWorkflow && !firstWorkflow.isDefault) { sortedApprovalWorkflows.unshift({ members: [], - approvers: calculateApprovers({employees, firstEmail: defaultApprover, personalDetailsByEmail}), + approvers: calculateApprovers({employees, firstEmail: defaultApprover, personalDetailsByEmail, policy}), isDefault: true, }); } @@ -367,11 +392,15 @@ type UpdateWorkflowDataOnApproverRemovalParams = { /** * The email of the approver being removed */ - removedApprover: PersonalDetails; + removedApproverEmail: string; /** * The email of the workspace owner */ - ownerDetails: PersonalDetails; + ownerEmail: string; + /** + * Personal details of the workspace owner, used for the owner's avatar and display name when they replace the removed approver + */ + ownerDetails?: PersonalDetails | null; }; type UpdateWorkflowDataOnApproverRemovalResult = Array< @@ -407,6 +436,11 @@ function convertApprovalWorkflowToPolicyEmployees({ const pendingAction = type === CONST.APPROVAL_WORKFLOW.TYPE.CREATE ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE; for (const [index, approver] of approvalWorkflow.approvers.entries()) { + // A non-member approver has no employee entry to update, and sending one would add a fake employee + if (approver.isNotWorkspaceMember) { + continue; + } + const nextApprover = approvalWorkflow.approvers.at(index + 1); const forwardsTo = type === CONST.APPROVAL_WORKFLOW.TYPE.REMOVE ? '' : (nextApprover?.email ?? ''); const approvalLimit = type === CONST.APPROVAL_WORKFLOW.TYPE.REMOVE ? null : approver.approvalLimit; @@ -471,7 +505,11 @@ function convertApprovalWorkflowToPolicyEmployees({ // For each approver to remove, we update the employee list with forwardsTo set to '' // which will reset the forwardsTo on the backend. if (approversToRemove) { - for (const {email} of approversToRemove) { + for (const {email, isNotWorkspaceMember} of approversToRemove) { + if (isNotWorkspaceMember) { + continue; + } + updatedEmployeeList[email] = { ...(updatedEmployeeList[email] ? updatedEmployeeList[email] : {email}), forwardsTo: '', @@ -483,12 +521,15 @@ function convertApprovalWorkflowToPolicyEmployees({ return updatedEmployeeList; } -function updateWorkflowDataOnApproverRemoval({approvalWorkflows, removedApprover, ownerDetails}: UpdateWorkflowDataOnApproverRemovalParams): UpdateWorkflowDataOnApproverRemovalResult { +function updateWorkflowDataOnApproverRemoval({ + approvalWorkflows, + removedApproverEmail, + ownerEmail, + ownerDetails, +}: UpdateWorkflowDataOnApproverRemovalParams): UpdateWorkflowDataOnApproverRemovalResult { const defaultWorkflow = approvalWorkflows.find((workflow) => workflow.isDefault); - const removedApproverEmail = removedApprover.login; - const ownerEmail = ownerDetails.login; - const ownerAvatar = ownerDetails.avatar ?? ''; - const ownerDisplayName = ownerDetails.displayName ?? ''; + const ownerAvatar = ownerDetails?.avatar ?? ''; + const ownerDisplayName = ownerDetails?.displayName ?? ownerEmail; return approvalWorkflows.flatMap((workflow) => { // Drop any workflow that has no approvers. There is nothing to update on it, and passing it to @@ -513,7 +554,7 @@ function updateWorkflowDataOnApproverRemoval({approvalWorkflows, removedApprover ...currentApprover, avatar: ownerAvatar, displayName: ownerDisplayName, - email: ownerEmail ?? '', + email: ownerEmail, }, ], }; @@ -575,10 +616,10 @@ function updateWorkflowDataOnApproverRemoval({approvalWorkflows, removedApprover }); const newApprover = { - email: ownerEmail ?? '', + email: ownerEmail, forwardsTo: undefined, - avatar: ownerDetails?.avatar ?? '', - displayName: ownerDetails?.displayName ?? '', + avatar: ownerAvatar, + displayName: ownerDisplayName, isCircularReference: workflow.approvers.at(removedApproverIndex)?.isCircularReference, }; @@ -618,7 +659,7 @@ function updateWorkflowDataOnApproverRemoval({approvalWorkflows, removedApprover ...currentApprover, avatar: ownerAvatar, displayName: ownerDisplayName, - email: ownerEmail ?? '', + email: ownerEmail, }, ], }; @@ -664,10 +705,10 @@ function updateWorkflowDataOnApproverRemoval({approvalWorkflows, removedApprover }); const newApprover = { - email: ownerEmail ?? '', + email: ownerEmail, forwardsTo: undefined, - avatar: ownerDetails?.avatar ?? '', - displayName: ownerDetails?.displayName ?? '', + avatar: ownerAvatar, + displayName: ownerDisplayName, isCircularReference: workflow.approvers.at(removedApproverIndex)?.isCircularReference, }; @@ -1635,19 +1676,23 @@ function convertApprovalWorkflowRulesToWorkflows({ availableMembers.push(member); } + // Mirrors the legacy builder: a submitter whose approver is no longer on the workspace is still shown, flagged. const hasInitialRule = !!resolveFirstApprover(email, rules, {}); - if (!hasInitialRule && (!submitsTo || (!employees[submitsTo] && !hrAdvancedModeFinalApproverEmail))) { - // Mirrors the existing legacy filter: skip submitters whose first approver isn't reachable. + if (!hasInitialRule && !submitsTo) { continue; } - let chain = buildApproverChainFromRules({submitter: email, rules, employees, personalDetailsByEmail}); + let chain = buildApproverChainFromRules({submitter: email, rules, employees, personalDetailsByEmail}).map((approver) => ({ + ...approver, + ...(isNonMemberApprover(policy, approver.email) ? {isNotWorkspaceMember: true} : {}), + ...(isNonMemberApprover(policy, approver.overLimitForwardsTo) ? {isOverLimitForwardsToNotWorkspaceMember: true} : {}), + })); if (chain.length === 0) { continue; } if (shouldFilterOutExpensifyTeam) { - chain = chain.filter((approver) => !isExpensifyTeam(approver.email)); + chain = chain.filter((approver) => !!approver.isNotWorkspaceMember || !isExpensifyTeam(approver.email)); } if (hrAdvancedModeFinalApproverEmail) { const last = chain.at(-1); @@ -1752,7 +1797,7 @@ function convertApprovalWorkflowRulesToWorkflows({ if (firstWorkflow && !firstWorkflow.isDefault) { sortedApprovalWorkflows.unshift({ members: [], - approvers: calculateApprovers({employees, firstEmail: defaultApprover, personalDetailsByEmail}), + approvers: calculateApprovers({employees, firstEmail: defaultApprover, personalDetailsByEmail, policy}), isDefault: true, }); } @@ -1762,6 +1807,81 @@ function convertApprovalWorkflowRulesToWorkflows({ return {approvalWorkflows: sortedApprovalWorkflows, usedApproverEmails: [...usedApproverEmails], availableMembers}; } +type HasApprovalWorkflowWithNonMemberApproverParams = { + /** Policy whose approval workflows are checked */ + policy: OnyxEntry; + + /** Current user's login, used to check they can edit approvals and whether Expensify team members are filtered out */ + currentUserLogin: string | undefined; + + /** The policy's approval-workflow rules keyed by ruleID, which route the workflows under the `MULTIPLE_APPROVERS` beta */ + rules?: Record; + + /** Whether the `MULTIPLE_APPROVERS` beta is enabled, which builds the workflows from rules */ + isMultipleApproversBetaEnabled: boolean; +}; + +/** + * Whether the Workflows page shows the current user a workflow they can fix whose approver is no longer on the workspace. + * The workflows are built with the same builder and approval-mode filter as the page, so the red dot shows exactly + * when a flagged card does. + */ +function hasApprovalWorkflowWithNonMemberApprover({policy, currentUserLogin, rules, isMultipleApproversBetaEnabled}: HasApprovalWorkflowWithNonMemberApproverParams): boolean { + // Only flag workflows the page shows with an Edit button: approvals are on, an integration doesn't own the + // workflows, a Dynamic External Workflow doesn't hide them, and the user can edit approvals + const areApprovalsEnabled = !!policy?.approvalMode && policy.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL && !isSubmitPolicy(policy); + if ( + !areApprovalsEnabled || + isApprovalWorkflowLockedByIntegration(policy) || + shouldHideDynamicExternalWorkflowPeople(policy) || + !canMemberWrite(policy, currentUserLogin ?? '', CONST.POLICY.POLICY_FEATURE.WORKFLOWS_APPROVALS) + ) { + return false; + } + + // Without the beta, an approver is only flagged when the default approver or some employee's `submitsTo`, + // `forwardsTo` or `overLimitForwardsTo` points at a non-member, so skip building the workflows when none does + if (!isMultipleApproversBetaEnabled) { + const hasNonMemberRoute = + isNonMemberApprover(policy, getHRFinalApprover(policy) ?? getDefaultApprover(policy)) || + Object.values(policy?.employeeList ?? {}).some( + (employee) => + isNonMemberApprover(policy, employee.submitsTo) || isNonMemberApprover(policy, employee.forwardsTo) || isNonMemberApprover(policy, employee.overLimitForwardsTo), + ); + if (!hasNonMemberRoute) { + return false; + } + } + + // Only the approver flags are read, so personal details and sorting are skipped + const params = {policy, personalDetails: {}, localeCompare: () => 0, currentUserLogin, rules}; + const {approvalWorkflows} = isMultipleApproversBetaEnabled ? convertApprovalWorkflowRulesToWorkflows(params) : convertPolicyEmployeesToApprovalWorkflows(params); + + // Same approval-mode filter as the Approvals tab: only the advanced modes show workflows beyond the default one + const showsAllWorkflows = + isMultipleApproversBetaEnabled || + policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.ADVANCED || + policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.DYNAMICEXTERNAL || + isHRAdvancedMode(policy); + const shownApprovalWorkflows = showsAllWorkflows ? approvalWorkflows : approvalWorkflows.filter((workflow) => workflow.isDefault); + + return shownApprovalWorkflows.some((workflow) => workflow.approvers.some((approver) => !!getNonMemberApproverError(approver, workflow.isDefault))); +} + +/** + * The error for an approver who is no longer on the workspace, or whose additional approver for reports over the limit + * isn't, or undefined when neither is. The default workflow can't be deleted, so its copy only asks for a new approver. + */ +function getNonMemberApproverError(approver: Approver | undefined, isDefaultWorkflow: boolean | undefined): TranslationPaths | undefined { + if (approver?.isNotWorkspaceMember) { + return isDefaultWorkflow ? 'workflowsPage.defaultWorkflowApproverNotWorkspaceMember' : 'workflowsPage.approverNotWorkspaceMember'; + } + if (approver?.isOverLimitForwardsToNotWorkspaceMember) { + return 'workflowsPage.overLimitApproverNotWorkspaceMember'; + } + return undefined; +} + export { addMembersToRule, applyApprovalWorkflowRulesDiff, @@ -1776,11 +1896,13 @@ export { getApprovalLimitDescription, getApprovalWorkflowRulesForPolicy, getApprovalWorkflowSource, + getNonMemberApproverError, filterRulesForPolicy, getRulesSubmitterToFirstApprover, getRulesSubmitterToWorkflowKey, getWorkflowMemberEmails, hasApprovalWorkflowRules, + hasApprovalWorkflowWithNonMemberApprover, hasRuleBasedDefaultWorkflow, includesEveryWorkspaceMember, isApprovalWorkflowLockedByIntegration, diff --git a/src/libs/actions/IOU/PayMoneyRequest.ts b/src/libs/actions/IOU/PayMoneyRequest.ts index 56f7675d202f..93ba0d50cf95 100644 --- a/src/libs/actions/IOU/PayMoneyRequest.ts +++ b/src/libs/actions/IOU/PayMoneyRequest.ts @@ -404,7 +404,8 @@ function getPayMoneyRequestParams({ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, value: { [optimisticIOUReportAction.reportActionID]: { - errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.other', 0), + // Same key as the preview copy below, so dismissing this one clears that one too. + errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.other', CONST.IOU.PAY_FAILURE_PREVIEW_ERROR_KEY), }, }, }, @@ -423,7 +424,8 @@ function getPayMoneyRequestParams({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, value: { - [optimisticReportPreviewAction.reportActionID]: optimisticReportPreviewAction, + // Clear only our own previous failure so a retry does not keep showing a stale RBR. + [optimisticReportPreviewAction.reportActionID]: {...optimisticReportPreviewAction, errors: {[CONST.IOU.PAY_FAILURE_PREVIEW_ERROR_KEY]: null}}, }, }); onyxData.failureData?.push({ @@ -432,6 +434,9 @@ function getPayMoneyRequestParams({ value: { [optimisticReportPreviewAction.reportActionID]: { created: optimisticReportPreviewAction.created, + // The error above sits in the expense report, which the payer cannot reach once it is deleted. + // Mirror it here. Only shown once the preview is deleted, see getVisibleReportActionErrors. + errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.other', CONST.IOU.PAY_FAILURE_PREVIEW_ERROR_KEY), }, }, }); diff --git a/src/libs/actions/IOU/Receipt.ts b/src/libs/actions/IOU/Receipt.ts index 7cde573f9bd2..4fc0390f436a 100644 --- a/src/libs/actions/IOU/Receipt.ts +++ b/src/libs/actions/IOU/Receipt.ts @@ -458,6 +458,22 @@ function setMoneyRequestReceipt( }); } +function readReceiptFile(receiptFilename: string, receiptPath: ReceiptSource, receiptType: string | undefined, onSuccess: (file: File) => void, onFailure: () => void) { + const path = ReceiptStorage.resolve(receiptPath) ?? receiptPath.toString(); + let didFail = false; + const markFailed = () => { + didFail = true; + }; + + return readFileAsync(path, receiptFilename, onSuccess, markFailed, receiptType).then(() => { + if (!didFail) { + return; + } + + return ReceiptStorage.recheckAfterSwap(receiptPath).then((isPresent) => (isPresent ? readFileAsync(path, receiptFilename, onSuccess, onFailure, receiptType) : onFailure())); + }); +} + // eslint-disable-next-line rulesdir/no-negated-variables function navigateToStartStepIfScanFileCannotBeRead( receiptFilename: string | undefined, @@ -493,7 +509,7 @@ function navigateToStartStepIfScanFileCannotBeRead( } navigateToStartMoneyRequestStep(requestType, iouType, transactionID, reportID); }; - readFileAsync(ReceiptStorage.resolve(receiptPath) ?? receiptPath.toString(), receiptFilename, onSuccess, onFailure, receiptType); + return readReceiptFile(receiptFilename, receiptPath, receiptType, onSuccess, onFailure); } function checkIfLocalFileIsAccessible( @@ -508,7 +524,7 @@ function checkIfLocalFileIsAccessible( return Promise.resolve(); } - return readFileAsync(ReceiptStorage.resolve(receiptPath) ?? receiptPath.toString(), receiptFilename, onSuccess, onFailure, receiptType); + return readReceiptFile(receiptFilename, receiptPath, receiptType, onSuccess, onFailure); } function clearReceiptUploadError({ diff --git a/src/libs/actions/MarketingAttribution.ts b/src/libs/actions/MarketingAttribution.ts new file mode 100644 index 000000000000..c9007998394f --- /dev/null +++ b/src/libs/actions/MarketingAttribution.ts @@ -0,0 +1,73 @@ +import {getSearchParamFromUrl} from '@libs/Url'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type MarketingAttribution from '@src/types/onyx/MarketingAttribution'; +import type {StoredMarketingAttribution} from '@src/types/onyx/MarketingAttribution'; + +import Onyx from 'react-native-onyx'; + +/** + * The UTM params, Google Ads device and network, and Google Ads click IDs we capture from the landing URL, keyed by their backend request + * param names. UTM params aren't present on new.expensify.com URLs today, but we capture them + * anyway so attribution works automatically if that changes. + */ +const MARKETING_PARAM_KEYS: Array = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'device', 'network', 'gclid', 'gbraid', 'wbraid']; + +/** + * Reads marketing attribution (UTM params, Google Ads device and network, and Google Ads click IDs) from the landing URL. + * This only finds values on web, where ad traffic lands. It has no side effects, and returns undefined when the URL carries none of the params. + */ +function captureMarketingAttributionFromURL(): MarketingAttribution | undefined { + if (typeof window === 'undefined' || !window.location) { + return undefined; + } + + const captured: MarketingAttribution = {}; + for (const key of MARKETING_PARAM_KEYS) { + const value = getSearchParamFromUrl(window.location.href, key); + if (value) { + captured[key] = value; + } + } + + return Object.keys(captured).length > 0 ? captured : undefined; +} + +/** + * Persists the attribution read from the landing URL to Onyx so it can be sent during signup. + * Does nothing when the user is already signed in to a real account, since attribution only matters for signup. + * + * We replace the whole stored attribution instead of merging into it, so values from different ad clicks + * (e.g. a Google gclid and later Reddit UTMs) never get combined. A page load without any of the params + * keeps the stored attribution. + */ +function saveMarketingAttribution(captured: MarketingAttribution | undefined, hasSession: boolean) { + if (!captured || hasSession) { + return; + } + + Onyx.set(ONYXKEYS.MARKETING_ATTRIBUTION, {...captured, capturedAt: Date.now()}); +} + +/** + * Returns the stored attribution to send with a signup request, without the local capture time. + * Google Ads only accepts click ID conversions for 90 days, so attribution older than that is cleared and ignored. + * Values stored without a capture time are treated as expired, because we can't tell how old they are and crediting a + * signup to a stale ad is worse than missing one. + */ +function getFreshMarketingAttribution(stored: StoredMarketingAttribution | undefined | null): MarketingAttribution | undefined { + if (!stored) { + return undefined; + } + + const {capturedAt, ...attribution} = stored; + if (typeof capturedAt !== 'number' || Date.now() - capturedAt > CONST.MARKETING_ATTRIBUTION_MAX_AGE_MS) { + Onyx.set(ONYXKEYS.MARKETING_ATTRIBUTION, null); + return undefined; + } + + return Object.keys(attribution).length > 0 ? attribution : undefined; +} + +export {captureMarketingAttributionFromURL, saveMarketingAttribution, getFreshMarketingAttribution}; diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index fc9c159271fe..be1cca52ba01 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -349,6 +349,12 @@ type OpenReportActionParams = { isNewThread?: boolean; + /** + * Remove the optimistically created report if the create fails, rather than leaving a `createChat` error on it. + * Set it for reports the app creates on the user's behalf, where a "Fix" badge is not actionable. + */ + shouldRemoveOptimisticReportOnFailure?: boolean; + /** The transaction object for legacy transactions that don't have a transaction thread or money request preview yet */ transaction?: Transaction; @@ -1696,6 +1702,7 @@ function openReport(params: OpenReportActionParams) { isFromDeepLink = false, personalDetails, isNewThread = false, + shouldRemoveOptimisticReportOnFailure = false, transaction, transactionViolations, parentReportID, @@ -1720,6 +1727,10 @@ function openReport(params: OpenReportActionParams) { const participantAccountIDList = participants.map((p) => p.accountID).filter((id): id is number => id !== undefined); const existingReportName = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.reportName; const isCreatingNewReport = !isEmptyObject(newReportObject); + + // `failureData` runs after the response's own `onyxData`, so nulling these keys also clears what the server wrote. + const shouldRollBackOptimisticReport = isCreatingNewReport && shouldRemoveOptimisticReportOnFailure; + let shouldClearManualUnreadMarker = false; if (!shouldKeepManualUnreadMarker) { // True only on a genuine return trip: `flagReportNavigatedAway` sets it on blur/unmount, so it is false on the @@ -1830,7 +1841,7 @@ function openReport(params: OpenReportActionParams) { }, ]; - if (isNewThread) { + if (isNewThread && !shouldRollBackOptimisticReport) { failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, @@ -1952,6 +1963,15 @@ function openReport(params: OpenReportActionParams) { }, }); + // The preview action exists only to point at the thread, so it goes too, or its `childReportID` dangles. + if (shouldRollBackOptimisticReport) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionParentReportID}`, + value: {[iouReportActionID]: null}, + }); + } + parameters.moneyRequestPreviewReportActionID = iouReportActionID; // Log how often the legacy transaction fallback path is taken @@ -2080,7 +2100,7 @@ function openReport(params: OpenReportActionParams) { failureData.push(PersonalDetailsUtils.buildPersonalDetailsUpdate(settledPersonalDetails)); } - if (!isNewThread) { + if (!isNewThread && !shouldRollBackOptimisticReport) { failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, @@ -2092,11 +2112,34 @@ function openReport(params: OpenReportActionParams) { }); } - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, - value: {[optimisticCreatedAction.reportActionID]: {pendingAction: null}}, - }); + if (shouldRollBackOptimisticReport) { + // `SidebarUtils` force-displays any report carrying errors, so keeping this one leaves a dead LHN row. + failureData.push( + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: null, + }, + { + // Remove only the action we created. Nulling the whole key would take anything the user queued in + // the thread with it, e.g. a comment written while offline. + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: {[optimisticCreatedAction.reportActionID]: null}, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`, + value: null, + }, + ); + } else { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, + value: {[optimisticCreatedAction.reportActionID]: {pendingAction: null}}, + }); + } // Add the createdReportActionID parameter to the API call parameters.createdReportActionID = optimisticCreatedAction.reportActionID; @@ -2111,7 +2154,8 @@ function openReport(params: OpenReportActionParams) { failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${newReportObject.parentReportID}`, - value: {[parentReportActionID]: {childType: ''}}, + // The thread is rolled back, so the parent action must not keep pointing at it. + value: {[parentReportActionID]: shouldRollBackOptimisticReport ? {childReportID: null, childType: ''} : {childType: ''}}, }); } } @@ -2524,6 +2568,8 @@ function createTransactionThreadReport(params: CreateTransactionThreadReportPara personalDetails, newReportObject: optimisticTransactionThread, parentReportActionID: iouReportAction?.reportActionID, + // The app creates this thread, not the user, so a "Fix" row on it is not actionable. Roll it back instead. + shouldRemoveOptimisticReportOnFailure: true, transaction, transactionViolations, parentReportID: selfDMReportID, diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 1f7cf8b0b538..3b8981d0e91d 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -1,3 +1,4 @@ +import {getFreshMarketingAttribution} from '@libs/actions/MarketingAttribution'; import * as PersistedRequests from '@libs/actions/PersistedRequests'; import * as API from '@libs/API'; import type { @@ -75,6 +76,7 @@ import ADD_WORK_EMAIL_INPUT_IDS from '@src/types/form/AddWorkEmailForm'; import type {Report, TryNewDot} from '@src/types/onyx'; import type Credentials from '@src/types/onyx/Credentials'; import type Locale from '@src/types/onyx/Locale'; +import type {StoredMarketingAttribution} from '@src/types/onyx/MarketingAttribution'; import type {OnyxData} from '@src/types/onyx/Request'; import type Response from '@src/types/onyx/Response'; import type Session from '@src/types/onyx/Session'; @@ -658,9 +660,9 @@ function buildOnyxDataToCleanUpAnonymousUser(): PersonalDetailsOnyxUpdate { /** * Creates an account for the new user and signs them into the application with the newly created account. - * + * The marketing attribution captured from the landing URL is sent with the request and cleared once signup succeeds. */ -function signUpUser(login: string | undefined, preferredLocale: Locale | undefined, hasSMSMarketingConsent?: boolean) { +function signUpUser(login: string | undefined, preferredLocale: Locale | undefined, hasSMSMarketingConsent?: boolean, storedMarketingAttribution?: OnyxEntry) { const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -674,7 +676,7 @@ function signUpUser(login: string | undefined, preferredLocale: Locale | undefin const onyxOperationToCleanUpAnonymousUser = buildOnyxDataToCleanUpAnonymousUser(); - const successData: Array | PersonalDetailsOnyxUpdate> = [ + const successData: Array | PersonalDetailsOnyxUpdate> = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, @@ -683,6 +685,12 @@ function signUpUser(login: string | undefined, preferredLocale: Locale | undefin }, }, onyxOperationToCleanUpAnonymousUser, + // The attribution is stored on the new account, so clear it to avoid sending it again on a later signup + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.MARKETING_ATTRIBUTION, + value: null, + }, ]; const failureData: Array> = [ @@ -696,7 +704,7 @@ function signUpUser(login: string | undefined, preferredLocale: Locale | undefin ]; Device.getDeviceInfoWithID().then((deviceInfo) => { - const params: SignUpUserParams = {email: login, preferredLocale: preferredLocale ?? null, deviceInfo}; + const params: SignUpUserParams = {email: login, preferredLocale: preferredLocale ?? null, deviceInfo, ...getFreshMarketingAttribution(storedMarketingAttribution)}; if (hasSMSMarketingConsent !== undefined) { params.hasSMSMarketingConsent = hasSMSMarketingConsent; } @@ -857,29 +865,44 @@ function setupNewDotAfterTransitionFromOldDot(hybridAppSettings: HybridAppSettin }); } +/** + * Builds the Onyx data for a Google or Apple sign in. These can create a new account, so on success we clear the marketing + * attribution sent with the request, the same way signUpUser does, so it isn't sent again on a later signup. + */ +function getThirdPartySignInOnyxData(): OnyxData { + const {optimisticData, successData, failureData} = signInAttemptState(); + return { + optimisticData, + successData: [...(successData ?? []), {onyxMethod: Onyx.METHOD.SET, key: ONYXKEYS.MARKETING_ATTRIBUTION, value: null}], + failureData, + }; +} + /** * Given an idToken from Sign in with Apple, checks the API to see if an account * exists for that email address and signs the user in if so. + * The marketing attribution captured from the landing URL is sent along, since this can create a new account. */ -function beginAppleSignIn(idToken: string | undefined | null, preferredLocale: Locale | undefined) { - const {optimisticData, successData, failureData} = signInAttemptState(); +function beginAppleSignIn(idToken: string | undefined | null, preferredLocale: Locale | undefined, storedMarketingAttribution?: OnyxEntry) { + const onyxData = getThirdPartySignInOnyxData(); Device.getDeviceInfoWithID().then((deviceInfo) => { - const params: BeginAppleSignInParams = {idToken, preferredLocale: preferredLocale ?? null, deviceInfo}; - API.write(WRITE_COMMANDS.SIGN_IN_WITH_APPLE, params, {optimisticData, successData, failureData}); + const params: BeginAppleSignInParams = {idToken, preferredLocale: preferredLocale ?? null, deviceInfo, ...getFreshMarketingAttribution(storedMarketingAttribution)}; + API.write(WRITE_COMMANDS.SIGN_IN_WITH_APPLE, params, onyxData); }); } /** * Shows Google sign-in process, and if an auth token is successfully obtained, - * passes the token on to the Expensify API to sign in with + * passes the token on to the Expensify API to sign in with. + * The marketing attribution captured from the landing URL is sent along, since this can create a new account. */ -function beginGoogleSignIn(token: string | null, preferredLocale: Locale | undefined) { - const {optimisticData, successData, failureData} = signInAttemptState(); +function beginGoogleSignIn(token: string | null, preferredLocale: Locale | undefined, storedMarketingAttribution?: OnyxEntry) { + const onyxData = getThirdPartySignInOnyxData(); Device.getDeviceInfoWithID().then((deviceInfo) => { - const params: BeginGoogleSignInParams = {token, preferredLocale: preferredLocale ?? null, deviceInfo}; - API.write(WRITE_COMMANDS.SIGN_IN_WITH_GOOGLE, params, {optimisticData, successData, failureData}); + const params: BeginGoogleSignInParams = {token, preferredLocale: preferredLocale ?? null, deviceInfo, ...getFreshMarketingAttribution(storedMarketingAttribution)}; + API.write(WRITE_COMMANDS.SIGN_IN_WITH_GOOGLE, params, onyxData); }); } @@ -887,7 +910,7 @@ function beginGoogleSignIn(token: string | null, preferredLocale: Locale | undef * Will create a temporary login for the user in the passed authenticate response which is used when * re-authenticating after an authToken expires. */ -function signInWithShortLivedAuthToken(authToken: string, isSAML: boolean, exitTo: string | undefined, login: string | undefined) { +function signInWithShortLivedAuthToken(authToken: string, currentAuthToken: string | undefined, isSAML: boolean, exitTo: string | undefined, login: string | undefined) { const {optimisticData, failureData, finallyData} = getShortLivedLoginParams(false, isSAML); const authMethod = isSAML ? CONST.AUTH_METHOD.SAML : CONST.AUTH_METHOD.SHORT_LIVED_AUTH_TOKEN; // Set the in-flight guard synchronously, before awaiting device info. optimisticData below (which also sets this key) @@ -896,12 +919,20 @@ function signInWithShortLivedAuthToken(authToken: string, isSAML: boolean, exitT // re-fires and loops. This key is RAM-only (resets on reload), so setting it early carries no stuck-state risk; the // optimisticData re-sets it and finallyData reverts it exactly as before. Onyx.set(ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN, true); - Device.getDeviceInfoWithID().then((deviceInfo) => { - API.read(READ_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN, {authToken, skipReauthentication: true, authMethod, deviceInfo}, {optimisticData, failureData, finallyData}); - }); NetworkStore.setLastShortAuthToken(authToken); + + const signInPromise = Device.getDeviceInfoWithID().then((deviceInfo) => + // We use makeRequestWithSideEffects here because the caller needs to inspect the response to detect a SESSION_MISMATCH error + // eslint-disable-next-line rulesdir/no-api-side-effects-method + API.makeRequestWithSideEffects( + SIDE_EFFECT_REQUEST_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN, + {authToken, skipReauthentication: true, authMethod, deviceInfo, currentAuthToken}, + {optimisticData, failureData, finallyData}, + ), + ); + if (!exitTo) { - return; + return signInPromise; } // waitForUserSignIn keeps a single resolver that openReportFromDeepLink may already hold, so wait on the routes instead. @@ -919,6 +950,8 @@ function signInWithShortLivedAuthToken(authToken: string, isSAML: boolean, exitT Log.warn('Unable to return to the last visited path after SAML sign in', {error}); } }); + + return signInPromise; } /** diff --git a/src/libs/actions/TableColumnWidths.ts b/src/libs/actions/TableColumnWidths.ts new file mode 100644 index 000000000000..9e9b8a450578 --- /dev/null +++ b/src/libs/actions/TableColumnWidths.ts @@ -0,0 +1,11 @@ +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +/** Persists a dragged column's width once the drag ends. The drag itself only touches CSS. */ +function setTableColumnWidth(columnResizingID: string, columnKey: string, width: number) { + Onyx.merge(ONYXKEYS.TABLE_COLUMN_WIDTHS, {[columnResizingID]: {[columnKey]: width}}); +} + +// eslint-disable-next-line import/prefer-default-export -- additional actions may be added here +export {setTableColumnWidth}; diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index 4af432692d17..10bf8e37c5b8 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -2,7 +2,7 @@ import {write} from '@libs/API'; import type {CreateWorkspaceApprovalParams, RemoveWorkspaceApprovalParams, SetApprovalWorkflowParams, UpdateWorkspaceApprovalParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; -import {getDefaultApprover} from '@libs/PolicyUtils'; +import {getDefaultApprover, isNonMemberApprover} from '@libs/PolicyUtils'; import type {ApprovalWorkflowRulesDiff} from '@libs/WorkflowUtils'; import { addMembersToRule, @@ -12,6 +12,7 @@ import { calculateApprovers, convertApprovalWorkflowToPolicyEmployees, getApprovalWorkflowRulesForPolicy, + getNonMemberApproverError, getOverLimitForwardsToDisplayName, getWorkflowMemberEmails, hasRuleBasedDefaultWorkflow, @@ -286,7 +287,7 @@ function removeApprovalWorkflow(approvalWorkflow: ApprovalWorkflow, policy: Onyx onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, value: { - employeeList: Object.fromEntries(Object.keys(updatedEmployees).map((key) => [key, {pendingAction: null}])), + employeeList: Object.fromEntries(Object.keys(updatedEmployees).map((key) => [key, {pendingAction: null, pendingFields: null}])), }, }, ]; @@ -639,6 +640,8 @@ function setApprovalWorkflowApprover({approver, approverIndex, currentApprovalWo return { ...existingApprover, isCircularReference: hasCircularReference, + // Re-check, so picking a new additional approver for reports over the limit clears the flag + isOverLimitForwardsToNotWorkspaceMember: isNonMemberApprover(policy, existingApprover.overLimitForwardsTo) || undefined, }; }); @@ -765,6 +768,11 @@ function validateApprovalWorkflow(approvalWorkflow: ApprovalWorkflowOnyx): appro errors[`approver-${approverIndex}`] = 'workflowsPage.approverCircularReference'; } + const nonMemberApproverError = getNonMemberApproverError(approver, approvalWorkflow.isDefault); + if (nonMemberApproverError) { + errors[`approver-${approverIndex}`] = nonMemberApproverError; + } + // Validate that if overLimitForwardsTo is set, approvalLimit must also be set if (approver?.overLimitForwardsTo && (!approver?.approvalLimit || approver.approvalLimit <= 0)) { errors[`approver-${approverIndex}`] = 'workflowsApprovalLimitPage.enterAmountError'; diff --git a/src/libs/actions/connections/merge/index.ts b/src/libs/actions/connections/merge/index.ts index d71001f8d73e..9589b9c78f5b 100644 --- a/src/libs/actions/connections/merge/index.ts +++ b/src/libs/actions/connections/merge/index.ts @@ -68,6 +68,9 @@ function syncMerge(policy: OnyxEntry, connectionName: MergeConnectionNam syncStatus: CONST.MERGE.SYNC_STATUS.SYNCING, syncType: CONST.MERGE.SYNC_TYPE.MANUAL, manualSyncTimestamps: [DateUtils.getDBTime(), ...(previousLastSync?.manualSyncTimestamps ?? [])], + // A prior failure may have been a configuration error; starting a fresh sync clears + // that flag so a later unrelated failure isn't mistaken for one that's already fixed. + isConfigurationError: false, }, }, }, @@ -86,6 +89,7 @@ function syncMerge(policy: OnyxEntry, connectionName: MergeConnectionNam syncStatus: CONST.MERGE.SYNC_STATUS.FAILED, errorMessage: null, manualSyncTimestamps: previousLastSync?.manualSyncTimestamps ?? null, + isConfigurationError: false, }, }, }, diff --git a/src/libs/fileDownload/validateReceiptFile/index.native.ts b/src/libs/fileDownload/validateReceiptFile/index.native.ts index 22af1fef7ce7..55e31d8627db 100644 --- a/src/libs/fileDownload/validateReceiptFile/index.native.ts +++ b/src/libs/fileDownload/validateReceiptFile/index.native.ts @@ -24,14 +24,11 @@ function validateReceiptFile( const localPath = ReceiptStorage.resolve(receiptPath) ?? receiptPathString; - return checkFileExists(localPath).then((exists) => { - if (!exists) { - onFailure(); - return; - } + const succeed = () => onSuccess({uri: localPath, name: receiptFilename, type: receiptType, source: localPath} as File); - onSuccess({uri: localPath, name: receiptFilename, type: receiptType, source: localPath} as File); - }); + return checkFileExists(localPath) + .then((exists) => exists || ReceiptStorage.recheckAfterSwap(receiptPath).then((isPresent) => isPresent && checkFileExists(localPath))) + .then((exists) => (exists ? succeed() : onFailure())); } export default validateReceiptFile; diff --git a/src/libs/getImageRecyclingKey.ts b/src/libs/getImageRecyclingKey.ts index 7e7fe71e2e87..6457d1a4a132 100644 --- a/src/libs/getImageRecyclingKey.ts +++ b/src/libs/getImageRecyclingKey.ts @@ -3,6 +3,14 @@ import type {ImageRequireSource, ImageURISource} from 'react-native'; type ImageSourceParam = ImageSource | number | ImageSource[] | Omit | ImageRequireSource | undefined; +/** expo-image blanks the view whenever recyclingKey changes, so a fragment that only forces a reload of the same file stays out of the key. */ +function withoutFragment(uri: string): string { + if (uri.startsWith('data:')) { + return uri; + } + return uri.split('#').at(0) ?? uri; +} + /** * Returns a string key for image recycling in FlashList. * Extracts the URI if available, otherwise stringifies the source. @@ -22,7 +30,7 @@ function getImageRecyclingKey(source: ImageSourceParam): string | undefined { } if (typeof source === 'object' && 'uri' in source && source.uri) { - return source.uri; + return withoutFragment(source.uri); } return JSON.stringify(source); diff --git a/src/libs/merge/HRUtils.ts b/src/libs/merge/HRUtils.ts index eb3ae048f74f..1c1c3f3e67ae 100644 --- a/src/libs/merge/HRUtils.ts +++ b/src/libs/merge/HRUtils.ts @@ -48,6 +48,52 @@ function isMergeHRCompleteSetupNeeded(policy?: OnyxEntry): boolean { return syncDone && hasGroups && !setupComplete; } +/** + * True when a selected group ID is missing from the cached group list, meaning it no longer exists upstream. + * Returns false if the cache has never synced, since there's nothing to compare against. + */ +function hasStaleMergeHRGroups(policy?: OnyxEntry): boolean { + const mergeHR = policy?.connections?.merge_hris; + const selectedGroupIDs = mergeHR?.config?.groups; + // allGroupIDs (not the display-filtered groups) is what the backend prunes against, so a group + // missing a name/type isn't wrongly flagged as deleted here. + const availableGroupIDs = mergeHR?.data?.allGroupIDs; + // allGroupIDs is explicitly [] once a sync has actually run and found zero groups, so only + // undefined (never synced) means there's nothing to compare against yet. + if (!selectedGroupIDs?.length || !availableGroupIDs) { + return false; + } + return selectedGroupIDs.some((groupID) => !availableGroupIDs.includes(groupID)); +} + +/** + * The admin's group selection, minus any group that no longer exists anywhere in the HR system. Compares + * against allGroupIDs (the same source hasStaleMergeHRGroups uses), falling back to data.groups when + * allGroupIDs hasn't synced yet. A cache that has never synced is left untouched. + */ +function getValidMergeHRGroupIDs(policy?: OnyxEntry): string[] { + const mergeHR = policy?.connections?.merge_hris; + const selectedGroupIDs = mergeHR?.config?.groups ?? []; + const availableGroupIDs = mergeHR?.data?.allGroupIDs ?? mergeHR?.data?.groups?.map((group) => group.id); + if (!availableGroupIDs) { + return [...selectedGroupIDs]; + } + return selectedGroupIDs.filter((groupID) => availableGroupIDs.includes(groupID)); +} + +/** + * The subset of getValidMergeHRGroupIDs with no renderable row — a group the HR system still has, but that + * Merge sent back without a name or type. The selector shows these with a fallback label so the admin can + * still see and deselect them, instead of silently dropping or silently preserving them on save. + */ +function getNonRenderableMergeHRGroupIDs(policy?: OnyxEntry): string[] { + const availableGroups = policy?.connections?.merge_hris?.data?.groups; + if (!availableGroups) { + return []; + } + return getValidMergeHRGroupIDs(policy).filter((groupID) => !availableGroups.some((group) => group.id === groupID)); +} + /** Returns display info for the HR provider currently connected to the policy (Gusto, Zenefits, or Merge HR), or null if none are connected. */ function getConnectedHRProvider(policy?: OnyxEntry): HRProviderInfo | null { if (isGustoConnected(policy)) { @@ -167,7 +213,7 @@ function shouldShowHRConnectionError(policy: OnyxEntry, isSyncInProgress return true; } if (connectedProvider.connectionName === CONST.POLICY.CONNECTIONS.NAME.MERGE_HR) { - return hasMergeSyncError(policy, CONST.POLICY.CONNECTIONS.NAME.MERGE_HR); + return hasMergeSyncError(policy, CONST.POLICY.CONNECTIONS.NAME.MERGE_HR) || hasStaleMergeHRGroups(policy); } return hasSynchronizationErrorMessage(policy, connectedProvider.connectionName, isSyncInProgress); } @@ -177,6 +223,9 @@ export { getHRApprovalMode, getHRAdvancedModeFinalApprover, getHRFinalApprover, + getNonRenderableMergeHRGroupIDs, + getValidMergeHRGroupIDs, + hasStaleMergeHRGroups, isAnyHRConnected, isAnyHRReadOnlyWorkflowMode, isGustoConnected, diff --git a/src/libs/merge/MergeUtils.ts b/src/libs/merge/MergeUtils.ts index 52b72f3dfe1d..77be4c4942c1 100644 --- a/src/libs/merge/MergeUtils.ts +++ b/src/libs/merge/MergeUtils.ts @@ -38,9 +38,13 @@ function hasMergeAuthenticationError(policy: OnyxEntry, connectionName: /** * Returns true when the last sync of the given Merge connection ended in an error the admin has to resolve — either * the integration needs to be reconnected, or the sync itself failed. + * + * A failure the connection's own settings caused is excluded: the settings may already have been corrected, and the + * next sync only reports back once it runs, so callers surface those from the current settings instead. */ function hasMergeSyncError(policy: OnyxEntry, connectionName: MergeConnectionName): boolean { - return hasMergeAuthenticationError(policy, connectionName) || policy?.connections?.[connectionName]?.lastSync?.syncStatus === CONST.MERGE.SYNC_STATUS.FAILED; + const lastSync = policy?.connections?.[connectionName]?.lastSync; + return hasMergeAuthenticationError(policy, connectionName) || (lastSync?.syncStatus === CONST.MERGE.SYNC_STATUS.FAILED && !lastSync?.isConfigurationError); } /** Returns the given Merge connection's finalApprover when it is in basic or advanced (manager) approval mode, or null otherwise. */ diff --git a/src/libs/prepareRequestPayload/index.native.ts b/src/libs/prepareRequestPayload/index.native.ts index eb613f45289b..4334d3794187 100644 --- a/src/libs/prepareRequestPayload/index.native.ts +++ b/src/libs/prepareRequestPayload/index.native.ts @@ -46,6 +46,12 @@ const prepareRequestPayload: PrepareRequestPayload = (command, data, initiatedOf return Promise.resolve(); } + // The assertion is FormData's signature being narrower than what request data can hold. + const appendValueAsIs = () => { + validateFormDataParameter(command, key, value); + formData.append(key, value as string | Blob); + }; + if (key === 'receipt') { const {source, name, type, receiptTraceId} = value as Omit & Pick; @@ -55,13 +61,12 @@ const prepareRequestPayload: PrepareRequestPayload = (command, data, initiatedOf return Promise.resolve(); } - const localUri = ReceiptStorage.resolve(source) ?? source; - - return checkFileExistsWithReason(localUri).then(({exists, error}) => { - if (!exists) { + return ReceiptStorage.locate(source).then((localUri) => { + if (!localUri) { const transactionID = typeof data.transactionID === 'string' ? data.transactionID : undefined; - return getReceiptsFolderState().then((receiptsFolder) => { - logReceiptDropped({receiptTraceId, transactionID, command, source, localUri, fileName: name, statError: error, receiptsFolder}); + const resolvedUri = ReceiptStorage.resolve(source) ?? source; + return Promise.all([checkFileExistsWithReason(resolvedUri), getReceiptsFolderState()]).then(([{error}, receiptsFolder]) => { + logReceiptDropped({receiptTraceId, transactionID, command, source, localUri: resolvedUri, fileName: name, statError: error, receiptsFolder}); }); } const receiptFormData = { @@ -73,6 +78,10 @@ const prepareRequestPayload: PrepareRequestPayload = (command, data, initiatedOf formData.append(key, receiptFormData as File); }); } + + if (name) { + return ReceiptStorage.settle(name).then(appendValueAsIs); + } } if (key === 'file' && initiatedOffline) { @@ -95,8 +104,7 @@ const prepareRequestPayload: PrepareRequestPayload = (command, data, initiatedOf }); } - validateFormDataParameter(command, key, value); - formData.append(key, value as string | Blob); + appendValueAsIs(); return Promise.resolve(); }); diff --git a/src/libs/sanitizeLogParams.ts b/src/libs/sanitizeLogParams.ts index 6949f6aa8330..9fac07156ae8 100644 --- a/src/libs/sanitizeLogParams.ts +++ b/src/libs/sanitizeLogParams.ts @@ -14,6 +14,7 @@ const SENSITIVE_KEYS = new Set([ 'oldDotCurrentEncryptedAuthToken', 'oldDotAutoGeneratedPassword', 'apiKey', + 'currentAuthToken', 'clientSecret', 'cardNumber', 'cardCVV', diff --git a/src/pages/DynamicReportDetailsPage/ReportDetailsContent.tsx b/src/pages/DynamicReportDetailsPage/ReportDetailsContent.tsx new file mode 100644 index 000000000000..73d6703860a9 --- /dev/null +++ b/src/pages/DynamicReportDetailsPage/ReportDetailsContent.tsx @@ -0,0 +1,115 @@ +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; + +import useDynamicBackPath from '@hooks/useDynamicBackPath'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import getBase62ReportID from '@libs/getBase62ReportID'; +import Navigation from '@libs/Navigation/Navigation'; +import Permissions from '@libs/Permissions'; +import { + isFinancialReportsForBusinesses as isFinancialReportsForBusinessesUtil, + isInvoiceReport as isInvoiceReportUtil, + isMoneyRequest as isMoneyRequestUtil, + isMoneyRequestReport as isMoneyRequestReportUtil, + isSelfDM as isSelfDMUtil, +} from '@libs/ReportUtils'; + +import {getReportPrivateNote} from '@userActions/Report'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +import React, {useEffect} from 'react'; +import {View} from 'react-native'; + +import ReportDetailsActions from './ReportDetailsActions'; +import ReportDetailsAvatar from './ReportDetailsAvatar'; +import ReportDetailsDescription from './ReportDetailsDescription'; +import ReportDetailsNameSection from './ReportDetailsNameSection'; +import ReportDetailsPromotedActions from './ReportDetailsPromotedActions'; +import ReportDetailsTitleSection from './ReportDetailsTitleSection'; + +type ReportDetailsContentProps = { + reportID: string; +}; + +function ReportDetailsContent({reportID}: ReportDetailsContentProps) { + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`); + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const styles = useThemeStyles(); + const navigateBackFromReportDetailsPath = useDynamicBackPath(DYNAMIC_ROUTES.REPORT_DETAILS.path); + const isMoneyRequestReport = isMoneyRequestReportUtil(report); + const isMoneyRequest = isMoneyRequestUtil(report); + const isInvoiceReport = isInvoiceReportUtil(report); + const isFinancialReportsForBusinesses = isFinancialReportsForBusinessesUtil(report); + const isSelfDM = isSelfDMUtil(report); + const isExpenseReport = isMoneyRequestReport || isInvoiceReport || isMoneyRequest; + const base62ReportID = getBase62ReportID(Number(reportID)); + + const isPrivateNotesFetchTriggered = reportLoadingState?.isLoadingPrivateNotes !== undefined; + + useEffect(() => { + // Do not fetch private notes if the feature is disabled, isLoadingPrivateNotes is already defined, the network is offline, or if the report is a self DM. + if (!Permissions.canUsePrivateNotes() || isPrivateNotesFetchTriggered || isOffline || isSelfDM) { + return; + } + + getReportPrivateNote(report?.reportID); + }, [report?.reportID, isOffline, isPrivateNotesFetchTriggered, isSelfDM]); + + return ( + + + Navigation.goBack(navigateBackFromReportDetailsPath)} + /> + + + + + {isExpenseReport ? : } + + + + {isFinancialReportsForBusinesses && ( + <> + + + + )} + + + + + + + + ); +} + +export default ReportDetailsContent; diff --git a/src/pages/DynamicReportDetailsPage/ReportDetailsMenuItems.tsx b/src/pages/DynamicReportDetailsPage/ReportDetailsMenuItems.tsx index e7b71f94b650..203701d48d02 100644 --- a/src/pages/DynamicReportDetailsPage/ReportDetailsMenuItems.tsx +++ b/src/pages/DynamicReportDetailsPage/ReportDetailsMenuItems.tsx @@ -1,30 +1,11 @@ -import MenuItem from '@components/MenuItem'; - -import useActivePolicy from '@hooks/useActivePolicy'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useLastWorkspaceNumber from '@hooks/useLastWorkspaceNumber'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useParentReportAction from '@hooks/useParentReportAction'; -import usePreferredPolicy from '@hooks/usePreferredPolicy'; import useReportIsArchived from '@hooks/useReportIsArchived'; -import {generateDefaultWorkspaceName} from '@libs/actions/Policy/Policy'; -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import Permissions from '@libs/Permissions'; -import {getTrackExpenseActionableWhisper} from '@libs/ReportActionsUtils'; -import {getOriginalReportID, isArchivedNonExpenseReport, isSelfDM as isSelfDMUtil, isTrackExpenseReportNew as isTrackExpenseReportUtil} from '@libs/ReportUtils'; -import {getOriginalTransactionWithSplitInfo, isPerDiemRequest, isTimeRequest} from '@libs/TransactionUtils'; - -import {createDraftTransactionAndNavigateToParticipantSelector} from '@userActions/IOU/StartExpenseFlows'; +import {isArchivedNonExpenseReport, isSelfDM as isSelfDMUtil, isTrackExpenseReportNew as isTrackExpenseReportUtil} from '@libs/ReportUtils'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {billingRestrictionPolicySelector, createFilteredPoliciesInfoSelector, createHasWorkspaceToSubmitToSelector} from '@selectors/Policy'; -import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import React from 'react'; import type {ReportDetailsRequestData} from './types'; @@ -37,6 +18,7 @@ import ReportDetailsMenuMarkAsIncompleteItem from './ReportDetailsMenuMarkAsInco import ReportDetailsMenuMembersOrInviteItem from './ReportDetailsMenuMembersOrInviteItem'; import ReportDetailsMenuPrivateNotesItem from './ReportDetailsMenuPrivateNotesItem'; import ReportDetailsMenuSettingsItem from './ReportDetailsMenuSettingsItem'; +import ReportDetailsMenuTrackExpenseItems from './ReportDetailsMenuTrackExpenseItems'; type ReportDetailsMenuItemsProps = { reportID: string; @@ -44,44 +26,15 @@ type ReportDetailsMenuItemsProps = { }; function ReportDetailsMenuItems({reportID, requestData}: ReportDetailsMenuItemsProps) { - const {translate} = useLocalize(); - const {isOffline} = useNetwork(); - const {isRestrictedToPreferredPolicy, preferredPolicyID} = usePreferredPolicy(); - const activePolicy = useActivePolicy(); - const lastWorkspaceNumber = useLastWorkspaceNumber(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Send', 'Folder', 'UserPlus']); - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); - const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); - const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); const parentReportAction = useParentReportAction(report); - const [reportActionsForOriginalReportID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`); - // The report from which a tracked expense would be submitted/categorized/shared, and its actions - - // createDraftTransactionAndNavigateToParticipantSelector uses them to find the linked track-expense action - const actionReportID = getOriginalReportID(reportID, parentReportAction, reportActionsForOriginalReportID, isOffline); - const [actionReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${actionReportID}`); - - const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const currentUserAccountID = currentUserPersonalDetails?.accountID; - const currentUserEmail = currentUserPersonalDetails?.email; - const currentUserLogin = currentUserPersonalDetails?.login; - const currentUserDisplayName = currentUserPersonalDetails?.displayName; - const currentUserLocalCurrencyCode = currentUserPersonalDetails?.localCurrencyCode; - const filteredPoliciesInfoSelector = createFilteredPoliciesInfoSelector(currentUserEmail); - const [filteredPoliciesInfo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: filteredPoliciesInfoSelector}); - const [preferredPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(preferredPolicyID)}`, {selector: billingRestrictionPolicySelector}); const isSelfDM = isSelfDMUtil(report); const isTrackExpenseReport = isTrackExpenseReportUtil(report, parentReport, parentReportAction); const isReportArchived = useReportIsArchived(report?.reportID); const isArchivedRoom = isArchivedNonExpenseReport(report, isReportArchived); - const hasWorkspaceToSubmitToSelector = createHasWorkspaceToSubmitToSelector(currentUserLogin); - const [hasWorkspaceToSubmitTo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: hasWorkspaceToSubmitToSelector}); if (isSelfDM) { return null; @@ -91,147 +44,17 @@ function ReportDetailsMenuItems({reportID, requestData}: ReportDetailsMenuItemsP return null; } - let trackExpenseItems: React.ReactNode = null; - if (isTrackExpenseReport && requestData && !requestData.isDeletedParentAction) { - const {iouTransactionID, moneyRequestReport, moneyRequestReportActions, iouTransaction, iouOriginalTransaction} = requestData; - const whisperAction = getTrackExpenseActionableWhisper(iouTransactionID, moneyRequestReport?.reportID, moneyRequestReportActions); - const actionableWhisperReportActionID = whisperAction?.reportActionID; - const currentUserLocalCurrency = currentUserLocalCurrencyCode ?? CONST.CURRENCY.USD; - const {isExpenseSplit: isSelfDMExpenseSplit} = getOriginalTransactionWithSplitInfo(iouTransaction, iouOriginalTransaction); - - let submitItems: React.ReactNode = null; - // Hide the "Submit it to someone" option for self-DM split expenses when the user isn't a member of any workspace. - if (!isSelfDMExpenseSplit || hasWorkspaceToSubmitTo) { - const baseSubmitParams = { - reportID: actionReportID, - reportActions: actionReportActions, - reportActionID: actionableWhisperReportActionID, - introSelected, - draftTransactionIDs, - activePolicy, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - restrictedPreferredPolicy: isRestrictedToPreferredPolicy ? preferredPolicy : undefined, - transaction: iouTransaction, - currentUserAccountID, - currentUserEmail: currentUserEmail ?? '', - currentUserLocalCurrency, - filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, - firstPolicy: filteredPoliciesInfo?.firstPolicy, - }; - // "Submit to someone" splits into two destinations here too, matching the track-expense whisper: - // submit to an individual ("a friend") or a submit-enabled workspace ("my employer"). - const defaultWorkspaceName = generateDefaultWorkspaceName(currentUserEmail ?? '', currentUserDisplayName, lastWorkspaceNumber, translate); - - submitItems = ( - <> - {/* Self-DM split expenses can only be submitted to a workspace, so the "a friend" destination is omitted here - just like it is on the track-expense whisper. A DM is not a valid destination for per diem and time expenses, - nor for a user restricted to one workspace. */} - {!isSelfDMExpenseSplit && !isRestrictedToPreferredPolicy && !isPerDiemRequest(iouTransaction) && !isTimeRequest(iouTransaction) && ( - { - createDraftTransactionAndNavigateToParticipantSelector({ - ...baseSubmitParams, - actionName: CONST.IOU.ACTION.SUBMIT, - submitDestination: CONST.IOU.SUBMIT_DESTINATION.FRIEND, - defaultWorkspaceName, - }); - }} - isAnonymousAction={false} - shouldShowRightIcon - /> - )} - { - createDraftTransactionAndNavigateToParticipantSelector({ - ...baseSubmitParams, - actionName: CONST.IOU.ACTION.SUBMIT, - submitDestination: CONST.IOU.SUBMIT_DESTINATION.EMPLOYER, - defaultWorkspaceName, - }); - }} - isAnonymousAction={false} - shouldShowRightIcon - /> - - ); - } - - trackExpenseItems = ( - <> - {submitItems} - {Permissions.canUseTrackFlows() && ( - <> - { - createDraftTransactionAndNavigateToParticipantSelector({ - reportID: actionReportID, - reportActions: actionReportActions, - actionName: CONST.IOU.ACTION.CATEGORIZE, - reportActionID: actionableWhisperReportActionID, - introSelected, - draftTransactionIDs, - activePolicy, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - transaction: iouTransaction, - currentUserAccountID, - currentUserEmail: currentUserEmail ?? '', - currentUserLocalCurrency, - filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, - firstPolicy: filteredPoliciesInfo?.firstPolicy, - }); - }} - isAnonymousAction={false} - shouldShowRightIcon - /> - { - createDraftTransactionAndNavigateToParticipantSelector({ - reportID: actionReportID, - reportActions: actionReportActions, - actionName: CONST.IOU.ACTION.SHARE, - reportActionID: actionableWhisperReportActionID, - introSelected, - draftTransactionIDs, - activePolicy, - userBillingGracePeriodEnds, - amountOwed, - ownerBillingGracePeriodEnd, - transaction: iouTransaction, - currentUserAccountID, - currentUserEmail: currentUserEmail ?? '', - currentUserLocalCurrency, - filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, - firstPolicy: filteredPoliciesInfo?.firstPolicy, - }); - }} - isAnonymousAction={false} - shouldShowRightIcon - /> - - )} - - ); - } - return ( <> - {trackExpenseItems} + {isTrackExpenseReport && !!requestData && !requestData.isDeletedParentAction && ( + + )} diff --git a/src/pages/DynamicReportDetailsPage/ReportDetailsMenuTrackExpenseItems.tsx b/src/pages/DynamicReportDetailsPage/ReportDetailsMenuTrackExpenseItems.tsx new file mode 100644 index 000000000000..fd396f5f23dd --- /dev/null +++ b/src/pages/DynamicReportDetailsPage/ReportDetailsMenuTrackExpenseItems.tsx @@ -0,0 +1,204 @@ +import MenuItem from '@components/MenuItem'; + +import useActivePolicy from '@hooks/useActivePolicy'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLastWorkspaceNumber from '@hooks/useLastWorkspaceNumber'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import useParentReportAction from '@hooks/useParentReportAction'; +import usePreferredPolicy from '@hooks/usePreferredPolicy'; + +import {generateDefaultWorkspaceName} from '@libs/actions/Policy/Policy'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import Permissions from '@libs/Permissions'; +import {getTrackExpenseActionableWhisper} from '@libs/ReportActionsUtils'; +import {getOriginalReportID} from '@libs/ReportUtils'; +import {getOriginalTransactionWithSplitInfo, isPerDiemRequest, isTimeRequest} from '@libs/TransactionUtils'; + +import {createDraftTransactionAndNavigateToParticipantSelector} from '@userActions/IOU/StartExpenseFlows'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import {billingRestrictionPolicySelector, createFilteredPoliciesInfoSelector, createHasWorkspaceToSubmitToSelector} from '@selectors/Policy'; +import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; +import React from 'react'; + +import type {ReportDetailsRequestData} from './types'; + +type ReportDetailsMenuTrackExpenseItemsProps = { + reportID: string; + requestData: ReportDetailsRequestData; +}; + +function ReportDetailsMenuTrackExpenseItems({reportID, requestData}: ReportDetailsMenuTrackExpenseItemsProps) { + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const {isRestrictedToPreferredPolicy, preferredPolicyID} = usePreferredPolicy(); + const activePolicy = useActivePolicy(); + const lastWorkspaceNumber = useLastWorkspaceNumber(); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Send', 'Folder', 'UserPlus']); + + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); + const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); + const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); + + const parentReportAction = useParentReportAction(report); + + const [reportActionsForOriginalReportID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`); + // The report from which a tracked expense would be submitted/categorized/shared, and its actions - + // createDraftTransactionAndNavigateToParticipantSelector uses them to find the linked track-expense action + const actionReportID = getOriginalReportID(reportID, parentReportAction, reportActionsForOriginalReportID, isOffline); + const [actionReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${actionReportID}`); + + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const currentUserAccountID = currentUserPersonalDetails?.accountID; + const currentUserEmail = currentUserPersonalDetails?.email; + const currentUserLogin = currentUserPersonalDetails?.login; + const currentUserDisplayName = currentUserPersonalDetails?.displayName; + const currentUserLocalCurrencyCode = currentUserPersonalDetails?.localCurrencyCode; + const filteredPoliciesInfoSelector = createFilteredPoliciesInfoSelector(currentUserEmail); + const [filteredPoliciesInfo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: filteredPoliciesInfoSelector}); + const [preferredPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(preferredPolicyID)}`, {selector: billingRestrictionPolicySelector}); + const hasWorkspaceToSubmitToSelector = createHasWorkspaceToSubmitToSelector(currentUserLogin); + const [hasWorkspaceToSubmitTo] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: hasWorkspaceToSubmitToSelector}); + + const {iouTransactionID, moneyRequestReport, moneyRequestReportActions, iouTransaction, iouOriginalTransaction} = requestData; + const whisperAction = getTrackExpenseActionableWhisper(iouTransactionID, moneyRequestReport?.reportID, moneyRequestReportActions); + const actionableWhisperReportActionID = whisperAction?.reportActionID; + const currentUserLocalCurrency = currentUserLocalCurrencyCode ?? CONST.CURRENCY.USD; + const {isExpenseSplit: isSelfDMExpenseSplit} = getOriginalTransactionWithSplitInfo(iouTransaction, iouOriginalTransaction); + + let submitItems: React.ReactNode = null; + // Hide the "Submit it to someone" option for self-DM split expenses when the user isn't a member of any workspace. + if (!isSelfDMExpenseSplit || hasWorkspaceToSubmitTo) { + const baseSubmitParams = { + reportID: actionReportID, + reportActions: actionReportActions, + reportActionID: actionableWhisperReportActionID, + introSelected, + draftTransactionIDs, + activePolicy, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + restrictedPreferredPolicy: isRestrictedToPreferredPolicy ? preferredPolicy : undefined, + transaction: iouTransaction, + currentUserAccountID, + currentUserEmail: currentUserEmail ?? '', + currentUserLocalCurrency, + filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, + firstPolicy: filteredPoliciesInfo?.firstPolicy, + }; + // "Submit to someone" splits into two destinations here too, matching the track-expense whisper: + // submit to an individual ("a friend") or a submit-enabled workspace ("my employer"). + const defaultWorkspaceName = generateDefaultWorkspaceName(currentUserEmail ?? '', currentUserDisplayName, lastWorkspaceNumber, translate); + + submitItems = ( + <> + {/* Self-DM split expenses can only be submitted to a workspace, so the "a friend" destination is omitted here + just like it is on the track-expense whisper. A DM is not a valid destination for per diem and time expenses, + nor for a user restricted to one workspace. */} + {!isSelfDMExpenseSplit && !isRestrictedToPreferredPolicy && !isPerDiemRequest(iouTransaction) && !isTimeRequest(iouTransaction) && ( + { + createDraftTransactionAndNavigateToParticipantSelector({ + ...baseSubmitParams, + actionName: CONST.IOU.ACTION.SUBMIT, + submitDestination: CONST.IOU.SUBMIT_DESTINATION.FRIEND, + defaultWorkspaceName, + }); + }} + isAnonymousAction={false} + shouldShowRightIcon + /> + )} + { + createDraftTransactionAndNavigateToParticipantSelector({ + ...baseSubmitParams, + actionName: CONST.IOU.ACTION.SUBMIT, + submitDestination: CONST.IOU.SUBMIT_DESTINATION.EMPLOYER, + defaultWorkspaceName, + }); + }} + isAnonymousAction={false} + shouldShowRightIcon + /> + + ); + } + + return ( + <> + {submitItems} + {Permissions.canUseTrackFlows() && ( + <> + { + createDraftTransactionAndNavigateToParticipantSelector({ + reportID: actionReportID, + reportActions: actionReportActions, + actionName: CONST.IOU.ACTION.CATEGORIZE, + reportActionID: actionableWhisperReportActionID, + introSelected, + draftTransactionIDs, + activePolicy, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + transaction: iouTransaction, + currentUserAccountID, + currentUserEmail: currentUserEmail ?? '', + currentUserLocalCurrency, + filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, + firstPolicy: filteredPoliciesInfo?.firstPolicy, + }); + }} + isAnonymousAction={false} + shouldShowRightIcon + /> + { + createDraftTransactionAndNavigateToParticipantSelector({ + reportID: actionReportID, + reportActions: actionReportActions, + actionName: CONST.IOU.ACTION.SHARE, + reportActionID: actionableWhisperReportActionID, + introSelected, + draftTransactionIDs, + activePolicy, + userBillingGracePeriodEnds, + amountOwed, + ownerBillingGracePeriodEnd, + transaction: iouTransaction, + currentUserAccountID, + currentUserEmail: currentUserEmail ?? '', + currentUserLocalCurrency, + filteredPoliciesCount: filteredPoliciesInfo?.filteredPoliciesCount ?? 0, + firstPolicy: filteredPoliciesInfo?.firstPolicy, + }); + }} + isAnonymousAction={false} + shouldShowRightIcon + /> + + )} + + ); +} + +export default ReportDetailsMenuTrackExpenseItems; diff --git a/src/pages/DynamicReportDetailsPage/index.tsx b/src/pages/DynamicReportDetailsPage/index.tsx index c0a48a894224..ec3c096548b4 100644 --- a/src/pages/DynamicReportDetailsPage/index.tsx +++ b/src/pages/DynamicReportDetailsPage/index.tsx @@ -1,115 +1,24 @@ -import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; +import ReportOrNotFoundGuard from '@components/ReportOrNotFoundGuard'; -import useDynamicBackPath from '@hooks/useDynamicBackPath'; -import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import getBase62ReportID from '@libs/getBase62ReportID'; -import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportDetailsNavigatorParamList} from '@libs/Navigation/types'; -import Permissions from '@libs/Permissions'; -import { - isFinancialReportsForBusinesses as isFinancialReportsForBusinessesUtil, - isInvoiceReport as isInvoiceReportUtil, - isMoneyRequest as isMoneyRequestUtil, - isMoneyRequestReport as isMoneyRequestReportUtil, - isSelfDM as isSelfDMUtil, -} from '@libs/ReportUtils'; - -import type {WithReportOrNotFoundProps} from '@pages/inbox/report/withReportOrNotFound'; -import withReportOrNotFound from '@pages/inbox/report/withReportOrNotFound'; - -import {getReportPrivateNote} from '@userActions/Report'; -import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; - -import React, {useEffect} from 'react'; -import {View} from 'react-native'; - -import ReportDetailsActions from './ReportDetailsActions'; -import ReportDetailsAvatar from './ReportDetailsAvatar'; -import ReportDetailsDescription from './ReportDetailsDescription'; -import ReportDetailsNameSection from './ReportDetailsNameSection'; -import ReportDetailsPromotedActions from './ReportDetailsPromotedActions'; -import ReportDetailsTitleSection from './ReportDetailsTitleSection'; -type DynamicReportDetailsPageProps = WithReportOrNotFoundProps & PlatformStackScreenProps; +import React from 'react'; -function DynamicReportDetailsPage({report, reportLoadingState}: DynamicReportDetailsPageProps) { - const {translate} = useLocalize(); - const {isOffline} = useNetwork(); - const styles = useThemeStyles(); - const navigateBackFromReportDetailsPath = useDynamicBackPath(DYNAMIC_ROUTES.REPORT_DETAILS.path); - const isMoneyRequestReport = isMoneyRequestReportUtil(report); - const isMoneyRequest = isMoneyRequestUtil(report); - const isInvoiceReport = isInvoiceReportUtil(report); - const isFinancialReportsForBusinesses = isFinancialReportsForBusinessesUtil(report); - const isSelfDM = isSelfDMUtil(report); - const isExpenseReport = isMoneyRequestReport || isInvoiceReport || isMoneyRequest; - const base62ReportID = getBase62ReportID(Number(report.reportID)); +import ReportDetailsContent from './ReportDetailsContent'; - const isPrivateNotesFetchTriggered = reportLoadingState?.isLoadingPrivateNotes !== undefined; +type DynamicReportDetailsPageProps = PlatformStackScreenProps; - useEffect(() => { - // Do not fetch private notes if the feature is disabled, isLoadingPrivateNotes is already defined, the network is offline, or if the report is a self DM. - if (!Permissions.canUsePrivateNotes() || isPrivateNotesFetchTriggered || isOffline || isSelfDM) { - return; - } - - getReportPrivateNote(report?.reportID); - }, [report?.reportID, isOffline, isPrivateNotesFetchTriggered, isSelfDM]); +function DynamicReportDetailsPage({route}: DynamicReportDetailsPageProps) { + const reportID = route.params.reportID; return ( - - - Navigation.goBack(navigateBackFromReportDetailsPath)} - /> - - - - - {isExpenseReport ? : } - - - - {isFinancialReportsForBusinesses && ( - <> - - - - )} - - - - - - - + + + ); } -export default withReportOrNotFound()(DynamicReportDetailsPage); +export default DynamicReportDetailsPage; diff --git a/src/pages/LogInWithShortLivedAuthTokenPage.tsx b/src/pages/LogInWithShortLivedAuthTokenPage.tsx index 3a7d93beffc8..606b54917c0f 100644 --- a/src/pages/LogInWithShortLivedAuthTokenPage.tsx +++ b/src/pages/LogInWithShortLivedAuthTokenPage.tsx @@ -26,6 +26,7 @@ type LogInWithShortLivedAuthTokenPageProps = PlatformStackScreenProps { + Log.warn('Unable to sign in with shortLivedAuthToken', {error: signInError}); + }); // For SAML sign-ins, navigate to HOME explicitly since the SAML flow // doesn't use exitTo deep link routing. For non-SAML flows, let the // navigation system handle exitTo routing naturally via setUpPoliciesAndNavigate. diff --git a/src/pages/LogOutPreviousUserPage.tsx b/src/pages/LogOutPreviousUserPage.tsx index 0bf187abac7b..ab192de485c8 100644 --- a/src/pages/LogOutPreviousUserPage.tsx +++ b/src/pages/LogOutPreviousUserPage.tsx @@ -1,17 +1,22 @@ import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import {useInitialURLState} from '@components/InitialURLContextProvider'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import useConfirmModal from '@hooks/useConfirmModal'; +import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import Log from '@libs/Log'; +import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath'; +import navigationRef from '@libs/Navigation/navigationRef'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {getLastShortAuthToken} from '@libs/Network/NetworkStore'; -import {isLoggingInAsDelegate as isLoggingInAsDelegateSessionUtils, isLoggingInAsNewUser as isLoggingInAsNewUserSessionUtils} from '@libs/SessionUtils'; +import {getEmailFromTransitionURL, isLoggingInAsDelegate as isLoggingInAsDelegateSessionUtils, isLoggingInAsNewUser as isLoggingInAsNewUserSessionUtils} from '@libs/SessionUtils'; import Navigation from '@navigation/Navigation'; import type {AuthScreensParamList} from '@navigation/types'; -import {isDelegateSession, signInWithShortLivedAuthToken, signInWithSupportAuthToken, signOutAndRedirectToSignIn} from '@userActions/Session'; +import {isAnonymousUser, isDelegateSession, signInWithShortLivedAuthToken, signInWithSupportAuthToken, signOutAndRedirectToSignIn} from '@userActions/Session'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; @@ -20,7 +25,9 @@ import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import React, {useEffect} from 'react'; +import React, {useEffect, useState} from 'react'; + +import NotFoundPage from './ErrorPage/NotFoundPage'; type LogOutPreviousUserPageProps = PlatformStackScreenProps; @@ -29,26 +36,99 @@ type LogOutPreviousUserPageProps = PlatformStackScreenProps { + if (!initialURL) { + return; + } + const sessionEmail = session?.email; - const transitionURL = CONFIG.IS_HYBRID_APP ? `${CONST.DEEPLINK_BASE_URL}${initialURL ?? ''}` : initialURL; - const isLoggingInAsNewUser = isLoggingInAsNewUserSessionUtils(transitionURL ?? undefined, sessionEmail); + const transitionURL = CONFIG.IS_HYBRID_APP ? `${CONST.DEEPLINK_BASE_URL}${initialURL}` : initialURL; + const isLoggingInAsNewUser = isLoggingInAsNewUserSessionUtils(transitionURL, sessionEmail); const isSupportalLogin = authTokenType === CONST.AUTH_TOKEN_TYPES.SUPPORT; if (isLoggingInAsNewUser) { - Log.info('[LogOutPreviousUserPage] Signing out for a transition to another user', false, { - isLinkNamingDelegator: isLoggingInAsDelegateSessionUtils(transitionURL ?? undefined), - isDelegateSession: isDelegateSession(session), - isSupportalLogin, + if (isSupportalLogin) { + Log.info('[LogOutPreviousUserPage] Signing out for a transition to another user', false, { + isLinkNamingDelegator: isLoggingInAsDelegateSessionUtils(transitionURL ?? undefined), + isDelegateSession: isDelegateSession(session), + isSupportalLogin, + }); + // We don't want to close react-native app in this particular case. + signOutAndRedirectToSignIn(false, isSupportalLogin, true, undefined, CONST.SIGN_OUT_REASON.LOGIN_AS_NEW_USER); + return; + } + + if (isAnonymousUser(session)) { + // We don't want to close react-native app in this particular case. + Navigation.isNavigationReady().then(() => { + if (lastVisitedPath) { + try { + // Rebuilt like a cold start restore of this path, so the sign-in modal opens over the last public room instead of a blank loader. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + navigationRef.resetRoot({...getAdaptedStateFromPath(lastVisitedPath as Route), stale: true}); + } catch (error) { + // A path saved by an older build may no longer exist. + Log.warn('Unable to restore the last visited path for an anonymous user', {error}); + Navigation.goBack(); + } + } else { + // We must call goBack() to remove the /transition route from history + Navigation.goBack(); + } + Log.info('[LogOutPreviousUserPage] Signing out for a transition to another user', false, { + isLinkNamingDelegator: isLoggingInAsDelegateSessionUtils(transitionURL ?? undefined), + isDelegateSession: isDelegateSession(session), + isSupportalLogin, + }); + signOutAndRedirectToSignIn(false, isSupportalLogin, true, undefined, CONST.SIGN_OUT_REASON.LOGIN_AS_NEW_USER); + }); + return; + } + + const linkEmail = getEmailFromTransitionURL(transitionURL); + + if (!linkEmail) { + showConfirmModal({ + title: translate('deeplinkWrapper.notValid'), + prompt: translate('deeplinkWrapper.sessionMismatch'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }).then(() => { + Navigation.isNavigationReady().then(() => { + Navigation.goBack(ROUTES.HOME); + }); + }); + return; + } + + showConfirmModal({ + title: translate('deeplinkWrapper.switchAccount.title'), + prompt: translate('deeplinkWrapper.switchAccount.prompt', {newEmail: linkEmail, currentEmail: sessionEmail ?? ''}), + confirmText: translate('deeplinkWrapper.switchAccount.confirm'), + cancelText: translate('deeplinkWrapper.switchAccount.staySignedIn'), + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { + setHasCancelledSwitch(true); + return; + } + Log.info('[LogOutPreviousUserPage] Signing out for a transition to another user', false, { + isLinkNamingDelegator: isLoggingInAsDelegateSessionUtils(transitionURL ?? undefined), + isDelegateSession: isDelegateSession(session), + isSupportalLogin, + }); + // We don't want to close react-native app in this particular case. + signOutAndRedirectToSignIn(false, isSupportalLogin, true, undefined, CONST.SIGN_OUT_REASON.LOGIN_AS_NEW_USER); }); - // We don't want to close react-native app in this particular case. - signOutAndRedirectToSignIn(false, isSupportalLogin, true, undefined, CONST.SIGN_OUT_REASON.LOGIN_AS_NEW_USER); return; } @@ -66,7 +146,7 @@ function LogOutPreviousUserPage({route}: LogOutPreviousUserPageProps) { }); return; } - const isLoggingInAsDelegate = isLoggingInAsDelegateSessionUtils(transitionURL ?? undefined); + const isLoggingInAsDelegate = isLoggingInAsDelegateSessionUtils(transitionURL); if (isLoggingInAsDelegate) { return; @@ -74,16 +154,41 @@ function LogOutPreviousUserPage({route}: LogOutPreviousUserPageProps) { // Even if the user was already authenticated in NewDot, we need to reauthenticate them with shortLivedAuthToken, // because the old authToken stored in Onyx may be invalid. - signInWithShortLivedAuthToken(shortLivedAuthToken, false, undefined, undefined); + signInWithShortLivedAuthToken(shortLivedAuthToken, session?.authToken, false, undefined, undefined) + .then((response) => { + if (response?.type !== CONST.ERROR_TYPE.SESSION_MISMATCH) { + return; + } + showConfirmModal({ + title: translate('deeplinkWrapper.notValid'), + prompt: translate('deeplinkWrapper.sessionMismatch'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }).then(() => { + // Go to the home page once the user dismisses this. The exitTo navigation below only runs when !CONFIG.IS_HYBRID_APP, so nothing else leaves /transition on HybridApp. + Navigation.isNavigationReady().then(() => { + Navigation.goBack(ROUTES.HOME); + }); + }); + }) + .catch((error) => Log.warn('Unable to sign in with shortLivedAuthToken', {error})); // We only want to run this effect once on mount (when the page first loads after transitioning from OldDot) // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialURL]); useEffect(() => { + if (hasCancelledSwitch) { + Navigation.isNavigationReady().then(() => { + Navigation.goBack(ROUTES.HOME); + }); + return; + } + const sessionEmail = session?.email; const transitionURL = CONFIG.IS_HYBRID_APP ? `${CONST.DEEPLINK_BASE_URL}${initialURL ?? ''}` : initialURL; const isLoggingInAsNewUser = isLoggingInAsNewUserSessionUtils(transitionURL ?? undefined, sessionEmail); + // We don't want to navigate to the exitTo route when creating a new workspace from a deep link, // because we already handle creating the optimistic policy and navigating to it in App.setUpPoliciesAndNavigate, // which is already called when AuthScreens mounts. @@ -98,7 +203,14 @@ function LogOutPreviousUserPage({route}: LogOutPreviousUserPageProps) { }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [initialURL, isAccountLoading]); + }, [initialURL, isAccountLoading, hasCancelledSwitch]); + + // Linking.getInitialURL() only ever resolves the URL that launched the app. + // If it's still unset once it has settled, this mount came from tapping a link inside the app (e.g. a link in a message), which this page can't process. + // Show a not-found page instead of hanging here. + if (!isLoadingInitialURL && !initialURL) { + return ; + } return ; } diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx index 435e2fbaf273..40858a9131c5 100644 --- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx +++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx @@ -27,7 +27,7 @@ import {expensifyLoginsSelector, isCurrentUserValidated} from '@libs/UserUtils'; import variables from '@styles/variables'; import {completeOnboarding} from '@userActions/Report'; -import {setOnboardingErrorMessage, setOnboardingPurposeSelected} from '@userActions/Welcome'; +import {clearOnboardingMergeAccountBlocked, setOnboardingErrorMessage, setOnboardingPurposeSelected} from '@userActions/Welcome'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -132,6 +132,9 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro // validation, so it still needs to add one; an unvalidated private-domain one already has one and // only needs to validate it. if (choice === CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE) { + // A merge blocked earlier in onboarding leaves isMergingAccountBlocked set, which would make the work + // email step reopen in its blocked state instead of showing the form. + clearOnboardingMergeAccountBlocked(); const isCurrentPrimaryPublicDomain = PUBLIC_DOMAINS_SET.has(session?.email?.split('@').at(1)?.toLowerCase() ?? ''); if (isValidated && !isCurrentPrimaryPublicDomain) { Navigation.navigate(ROUTES.ONBOARDING_WORKSPACES.getRoute(ROUTES.ONBOARDING_PERSONAL_DETAILS.getRoute())); diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx index 9fe85deb7a6e..64c84801309f 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerProvider.tsx @@ -1,12 +1,12 @@ import useOnyx from '@hooks/useOnyx'; import useOriginalReportID from '@hooks/useOriginalReportID'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import Log from '@libs/Log'; import {chatIncludesConcierge} from '@libs/ReportUtils'; import {useReportActionActiveEdit} from '@pages/inbox/report/ReportActionEditMessageContext'; +import useShouldEditInComposer from '@pages/inbox/report/useShouldEditInComposer'; import {isBlockedFromConcierge as isBlockedFromConciergeUserAction} from '@userActions/User'; @@ -44,7 +44,7 @@ type ComposerProviderProps = { }; function ComposerProvider({children, reportID}: ComposerProviderProps) { - const {shouldUseNarrowLayout} = useResponsiveLayout(); + const shouldEditInComposer = useShouldEditInComposer(); const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); const [initialModalState] = useOnyx(ONYXKEYS.MODAL); @@ -82,7 +82,7 @@ function ComposerProvider({children, reportID}: ComposerProviderProps) { const [didResetComposerHeightWhileEditing, setDidResetComposerHeightWhileEditing] = useState(false); - const isEditingInComposer = shouldUseNarrowLayout && editingState !== 'off' && !didResetComposerHeightWhileEditing; + const isEditingInComposer = shouldEditInComposer && editingState !== 'off' && !didResetComposerHeightWhileEditing; const effectiveDraft = isEditingInComposer ? editingMessage : draftComment; const {debouncedCommentMaxLengthValidation, exceededMaxLength, isExceedingMaxLength, isTaskTitle} = useDebouncedCommentMaxLengthValidation({ diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 9f73d1198880..128ef3727bc5 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -40,6 +40,7 @@ import {useReportActionActiveEditActions} from '@pages/inbox/report/ReportAction import useDebouncedSaveDraft from '@pages/inbox/report/useDebouncedSaveDraft'; import useDebouncedSaveReportActionDraft from '@pages/inbox/report/useDebouncedSaveReportActionDraft'; import useDraftMessageVideoAttributeCache from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; +import useShouldEditInComposer from '@pages/inbox/report/useShouldEditInComposer'; import {isEmojiPickerVisible} from '@userActions/EmojiPickerAction'; import type {OnEmojiSelected} from '@userActions/EmojiPickerAction'; @@ -215,6 +216,7 @@ function ComposerWithSuggestions({ const emojisPresentBefore = useRef([]); const isInSidePanel = useIsInSidePanel(); const {shouldUseNarrowLayout} = useResponsiveLayout(); + const shouldEditInComposer = useShouldEditInComposer(); const composerRef = useRef(null); @@ -533,7 +535,7 @@ function ComposerWithSuggestions({ } commentRef.current = newCommentConverted; - if (editingState === CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING && shouldUseNarrowLayout) { + if (editingState === CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING && shouldEditInComposer) { setEditingMessage(newCommentConverted); if (shouldDebounceSaveComment) { debouncedSaveReportActionDraft(editingReportID ?? reportID, editingReportAction, reportActions, newCommentConverted); @@ -564,7 +566,7 @@ function ComposerWithSuggestions({ setText, onValueChange, editingState, - shouldUseNarrowLayout, + shouldEditInComposer, suggestionsRef, setCurrentEditMessageSelection, setEditingMessage, diff --git a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts index c740bd597cec..2885746d4634 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditComposerToggle.ts @@ -1,6 +1,6 @@ import type {ComposerRef, TextSelection} from '@components/Composer/types'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useShouldEditInComposer from '@pages/inbox/report/useShouldEditInComposer'; import CONST from '@src/CONST'; @@ -35,7 +35,7 @@ type UseEditComposerToggleProps = { * and to update the selection of the composer when the editing state is toggled on. */ function useEditComposerToggle({selection, composerRef, onFocus, onValueChange, onSelectionChange}: UseEditComposerToggleProps) { - const {shouldUseNarrowLayout} = useResponsiveLayout(); + const shouldEditInComposer = useShouldEditInComposer(); const {isEditingInComposer, editingState, editingReportActionID, editingMessage, currentEditMessageSelection, draftComment} = useComposerEditState(); const {setDidResetComposerHeightWhileEditing} = useComposerEditActions(); @@ -43,7 +43,7 @@ function useEditComposerToggle({selection, composerRef, onFocus, onValueChange, const isEditing = editingState !== CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.OFF; const wasEditingRef = useRef(isEditing); - const wasEditingInComposerRef = useRef(shouldUseNarrowLayout); + const wasEditingInComposerRef = useRef(shouldEditInComposer); const previousDraftSelectionRef = useRef(null); const previousEditingReportActionIDRef = useRef(null); const previousTextRef = useRef(null); @@ -100,7 +100,7 @@ function useEditComposerToggle({selection, composerRef, onFocus, onValueChange, } wasEditingRef.current = false; - wasEditingInComposerRef.current = shouldUseNarrowLayout; + wasEditingInComposerRef.current = shouldEditInComposer; previousDraftSelectionRef.current = null; return; } @@ -111,9 +111,9 @@ function useEditComposerToggle({selection, composerRef, onFocus, onValueChange, previousDraftSelectionRef.current = selection; wasEditingRef.current = true; - wasEditingInComposerRef.current = shouldUseNarrowLayout; + wasEditingInComposerRef.current = shouldEditInComposer; - if (!shouldUseNarrowLayout) { + if (!shouldEditInComposer) { // Wide layout – another editor handles the edit, keep composer draft as-is. return; } @@ -124,7 +124,7 @@ function useEditComposerToggle({selection, composerRef, onFocus, onValueChange, } // Editing is ongoing and layout toggled from wide to narrow. - if (shouldUseNarrowLayout && !wasEditingInComposerRef.current) { + if (shouldEditInComposer && !wasEditingInComposerRef.current) { wasEditingInComposerRef.current = true; // We just moved from wide to narrow while editing – start editing in the composer. applyComposerValue(editingMessage ?? '', {isEditingInComposer: true}); @@ -132,14 +132,14 @@ function useEditComposerToggle({selection, composerRef, onFocus, onValueChange, } // Editing is ongoing and layout toggled from narrow to wide. - if (!shouldUseNarrowLayout && wasEditingInComposerRef.current) { + if (!shouldEditInComposer && wasEditingInComposerRef.current) { wasEditingInComposerRef.current = false; applyComposerValue(draftComment ?? ''); return; } // The editing report action and message changed - if (shouldUseNarrowLayout && editingReportActionID !== previousEditingReportActionIDRef.current) { + if (shouldEditInComposer && editingReportActionID !== previousEditingReportActionIDRef.current) { applyComposerValue(editingMessage ?? '', {isEditingInComposer: true, shouldForceNativeValueUpdate: true}); previousEditingReportActionIDRef.current = editingReportActionID; } @@ -154,7 +154,7 @@ function useEditComposerToggle({selection, composerRef, onFocus, onValueChange, onFocus, selection, setDidResetComposerHeightWhileEditing, - shouldUseNarrowLayout, + shouldEditInComposer, draftComment, onValueChange, ]); diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index da0dcc09a78d..ec9e5073c49a 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -50,6 +50,8 @@ import { isActionOfType, isCreatedTaskReportAction, isDeletedParentAction as isDeletedParentActionUtils, + isDeletedReportPreviewWithError, + getVisibleReportActionErrors, isMessageDeleted, isMoneyRequestAction, isPendingRemove, @@ -108,6 +110,7 @@ import ReportActionItemFrame from './ReportActionItemFrame'; import ReportActionItemThread from './ReportActionItemThread'; import SearchActionHeader from './SearchActionHeader'; import TripSummary from './TripSummary'; +import useShouldEditInComposer from './useShouldEditInComposer'; import WhisperBanner from './WhisperBanner'; type ReportActionItemProps = { @@ -223,6 +226,7 @@ function ReportActionItem({ const [actorDisplayName] = usePersonalDetail(action.actorAccountID, displayNameOrDefaultSelector(translate, formatPhoneNumber)); const {showConfirmModal} = useConfirmModal(); const {shouldUseNarrowLayout} = useResponsiveLayout(); + const shouldEditInComposer = useShouldEditInComposer(); const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -246,7 +250,7 @@ function ReportActionItem({ const isDeletedParentAction = isDeletedParentActionUtils(action); const draftMessage = editingReportAction && action && editingReportAction.reportActionID === action.reportActionID ? (editingMessage ?? undefined) : undefined; const hasDraft = draftMessage !== undefined; - const isEditingInline = !shouldUseNarrowLayout && hasDraft; + const isEditingInline = !shouldEditInComposer && hasDraft; // IOUDetails only exists when we are sending money const isSendingMoney = isMoneyRequestAction(action) && getOriginalMessage(action)?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && getOriginalMessage(action)?.IOUDetails; @@ -407,13 +411,25 @@ function ReportActionItem({ const disabledActions = !canWriteInReport(report) ? RestrictedReadOnlyContextMenuActions : []; - const hasActionErrors = !isEmptyValueObject(action.errors); + // An error the payer cannot see must not disable the action, or the preview loses its context menu for no visible reason. + const visibleActionErrors = getVisibleReportActionErrors(action); + + const hasActionErrors = !isEmptyValueObject(visibleActionErrors); // Receipt upload errors should still allow the context menu so the user can access "Delete expense" - const hasOnlyReceiptErrors = hasActionErrors && Object.values(action.errors ?? {}).every((error) => error === null || isReceiptError(error)); + const hasOnlyReceiptErrors = hasActionErrors && Object.values(visibleActionErrors ?? {}).every((error) => error === null || isReceiptError(error)); const isContextMenuDisabled = hasDraft || (hasActionErrors && !hasOnlyReceiptErrors) || !shouldDisplayContextMenuValue; + const latestActionErrors = getLatestErrorMessageField({...action, errors: visibleActionErrors} as OnyxDataWithErrors); + + // Once the report is deleted there is nothing to retry, so say what happened instead of "try again later". + let displayedActionErrors = latestActionErrors; + if (isDeletedReportPreviewWithError(action)) { + const payFailedMessage = translate('iou.error.payFailedExpenseDeleted'); + displayedActionErrors = mapValues(latestActionErrors, () => payFailedMessage); + } + /** * Show the ReportActionContextMenu modal popover. * @@ -614,7 +630,7 @@ function ReportActionItem({ hasDraft ? undefined : (action.pendingAction ?? (action.isOptimisticAction ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : undefined)) } shouldHideOnDelete={!isDeletedParentAction} - errors={(linkedTransactionRouteError ?? !isOnSearch) ? getLatestErrorMessageField(action as OnyxDataWithErrors) : {}} + errors={(linkedTransactionRouteError ?? !isOnSearch) ? displayedActionErrors : {}} errorRowStyles={[styles.ml10, styles.mr2]} needsOffscreenAlphaCompositing={isMoneyRequestAction(action)} shouldDisableStrikeThrough diff --git a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx index a78f4a3421e1..9935ff214a26 100644 --- a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx +++ b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx @@ -44,6 +44,7 @@ import { isCardIssuedAction, isCreatedTaskReportAction, isCreatedSupportTicketReportAction, + isDeletedReportPreviewWithError, isIOURequestReportAction, isMemberChangeAction, isMoneyRequestAction, @@ -212,7 +213,8 @@ function ActionContentRouter({ /> ); } - if (action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && isClosedExpenseReportWithNoExpenses) { + // This preview has no report left to show, so render the placeholder instead of pointing at a report that is gone. + if (action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && (isClosedExpenseReportWithNoExpenses || isDeletedReportPreviewWithError(action))) { return ${translate('parentReportAction.deletedReport')}`} />; } if (action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { diff --git a/src/pages/inbox/report/actionContents/ChatMessageContent.tsx b/src/pages/inbox/report/actionContents/ChatMessageContent.tsx index 5998ca623eee..2f8025424666 100644 --- a/src/pages/inbox/report/actionContents/ChatMessageContent.tsx +++ b/src/pages/inbox/report/actionContents/ChatMessageContent.tsx @@ -5,7 +5,6 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {parseFollowupsFromHtml} from '@libs/ReportActionFollowupUtils'; @@ -20,6 +19,7 @@ import { import ReportActionItemMessage from '@pages/inbox/report/ReportActionItemMessage'; import ReportActionItemMessageEdit from '@pages/inbox/report/ReportActionItemMessageEdit'; +import useShouldEditInComposer from '@pages/inbox/report/useShouldEditInComposer'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -60,8 +60,8 @@ function ChatMessageContent({ const {translate} = useLocalize(); const styles = useThemeStyles(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const isEditingInline = !shouldUseNarrowLayout && draftMessage !== undefined; + const shouldEditInComposer = useShouldEditInComposer(); + const isEditingInline = !shouldEditInComposer && draftMessage !== undefined; const mentionReportContextValue = {currentReportID: reportID, exactlyMatch: true}; const attachmentContextValue = isOnSearch ? {type: CONST.ATTACHMENT_TYPE.SEARCH} : {reportID, type: CONST.ATTACHMENT_TYPE.REPORT}; diff --git a/src/pages/inbox/report/useReportDeepLinkOnOpen.ts b/src/pages/inbox/report/useReportDeepLinkOnOpen.ts new file mode 100644 index 000000000000..c8abfbf5994c --- /dev/null +++ b/src/pages/inbox/report/useReportDeepLinkOnOpen.ts @@ -0,0 +1,49 @@ +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useOnyx from '@hooks/useOnyx'; + +import {openReport} from '@libs/actions/Report'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; +import {useEffect} from 'react'; + +type UseReportDeepLinkOnOpenParams = { + reportID: string | undefined; + isReportLoaded: boolean; + shouldFetchReport: boolean; +}; + +/** + * When accessing certain report-dependant pages (e.g. Task Title) by deeplink, the OpenReport API is not called, + * So we need to call OpenReport API here to make sure the report data is loaded if it exists on the Server + */ +function useReportDeepLinkOnOpen({reportID, isReportLoaded, shouldFetchReport}: UseReportDeepLinkOnOpenParams) { + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); + const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); + const [hasReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {selector: Boolean}); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + + useEffect(() => { + if (isReportLoaded || !shouldFetchReport) { + // If the report is not required or is already loaded, we don't need to call the API + return; + } + + openReport({ + reportID, + introSelected, + conciergeChat, + hasReportActions, + currentUserAccountID, + isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed, + hasCompletedGuidedSetupFlow: guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, + }); + // OpenReport should only fire when the fetch decision or report identity changes; the other args are read at call time. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shouldFetchReport, isReportLoaded, reportID, currentUserAccountID]); +} + +export default useReportDeepLinkOnOpen; diff --git a/src/pages/inbox/report/useShouldEditInComposer.ts b/src/pages/inbox/report/useShouldEditInComposer.ts new file mode 100644 index 000000000000..d7e40760961a --- /dev/null +++ b/src/pages/inbox/report/useShouldEditInComposer.ts @@ -0,0 +1,14 @@ +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +/** + * Whether message edits happen in the bottom composer (#90516) instead of the inline editor. + * This depends only on the screen size, so reports opened in the RHP on a wide screen still edit inline. + */ +function useShouldEditInComposer() { + // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout, because shouldUseNarrowLayout is also true in the RHP on wide screens + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + return isSmallScreenWidth; +} + +export default useShouldEditInComposer; diff --git a/src/pages/inbox/report/useShouldShowComposerForActiveEditDraft.ts b/src/pages/inbox/report/useShouldShowComposerForActiveEditDraft.ts index 4ecd2988b1a4..87f2cc134f22 100644 --- a/src/pages/inbox/report/useShouldShowComposerForActiveEditDraft.ts +++ b/src/pages/inbox/report/useShouldShowComposerForActiveEditDraft.ts @@ -1,12 +1,11 @@ -import useResponsiveLayout from '@hooks/useResponsiveLayout'; - import {useReportActionActiveEdit} from './ReportActionEditMessageContext'; +import useShouldEditInComposer from './useShouldEditInComposer'; /** Narrow-screen edits use the bottom composer (#90516); mount it when a draft exists even if posting is admin-only. */ function useShouldShowComposerForActiveEditDraft() { - const {shouldUseNarrowLayout} = useResponsiveLayout(); + const shouldEditInComposer = useShouldEditInComposer(); const {editingReportActionID} = useReportActionActiveEdit(); - return shouldUseNarrowLayout && editingReportActionID !== null; + return shouldEditInComposer && editingReportActionID !== null; } export default useShouldShowComposerForActiveEditDraft; diff --git a/src/pages/inbox/report/withReportOrNotFound.tsx b/src/pages/inbox/report/withReportOrNotFound.tsx index 706644db9efd..cd2fc0b1fef8 100644 --- a/src/pages/inbox/report/withReportOrNotFound.tsx +++ b/src/pages/inbox/report/withReportOrNotFound.tsx @@ -1,14 +1,13 @@ import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import {getReportOrNotFoundDecision} from '@components/ReportOrNotFoundGuard'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useReportIsArchived from '@hooks/useReportIsArchived'; -import {openReport} from '@libs/actions/Report'; import getComponentDisplayName from '@libs/getComponentDisplayName'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import {canAccessReport, hasExpensifyGuidesEmails} from '@libs/ReportUtils'; +import {hasExpensifyGuidesEmails} from '@libs/ReportUtils'; import type { ParticipantsNavigatorParamList, @@ -33,8 +32,9 @@ import type {ComponentType} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {useIsFocused} from '@react-navigation/native'; -import {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; -import React, {useEffect} from 'react'; +import React from 'react'; + +import useReportDeepLinkOnOpen from './useReportDeepLinkOnOpen'; type WithReportOrNotFoundOnyxProps = { report: OnyxTypes.Report; @@ -79,18 +79,12 @@ export default function (shouldRequireReportID = true): { - if (isReportLoaded || !shouldFetchReport) { - // If the report is not required or is already loaded, we don't need to call the API - return; - } - - openReport({ - reportID, - introSelected, - conciergeChat, - hasReportActions, - currentUserAccountID, - isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed, - hasCompletedGuidedSetupFlow: guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [shouldFetchReport, isReportLoaded, reportID, currentUserAccountID]); - - if (shouldRequireReportID || isReportIdInRoute) { - const shouldShowFullScreenLoadingIndicator = !isReportLoaded && (isLoadingReportData !== false || shouldFetchReport); - const shouldShowNotFoundPage = !isReportLoaded || !canAccessReport(report, isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), hasGuidesEmails, isReportArchived); - - // If the content was shown, but it's not anymore, that means the report was deleted, and we are probably navigating out of this screen. - // Return null for this case to avoid rendering FullScreenLoadingIndicator or NotFoundPage when animating transition. - // We also suppress the NotFound page while a delete-transaction navigation is in flight (e.g. deleting an invoice - // navigates back to the invoice room without synchronously removing focus from this details RHP), mirroring ReportNotFoundGuard. - if (shouldShowNotFoundPage && contentShown.current && (!isFocused || !!deleteTransactionNavigateBackUrl)) { - return null; - } - - if (shouldShowFullScreenLoadingIndicator) { - return ; - } - - if (shouldShowNotFoundPage) { - return ; - } + useReportDeepLinkOnOpen({reportID, isReportLoaded, shouldFetchReport}); + + const decision = getReportOrNotFoundDecision({ + shouldRequireReportID, + isReportIdInRoute, + isReportLoaded, + report, + isLoadingReportData, + shouldFetchReport, + isDefaultRoomsBetaEnabled: isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), + hasGuidesEmails, + isReportArchived, + isFocused, + hasShownContent: contentShown.current, + deleteTransactionNavigateBackUrl, + }); + + if (decision === 'blank') { + return null; + } + + if (decision === 'loading') { + return ; + } + + if (decision === 'notFound') { + return ; } if (!contentShown.current) { diff --git a/src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx b/src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx index cebe3dca5442..e19d22939ae8 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepUpgrade.tsx @@ -59,7 +59,7 @@ type DynamicIOURequestStepUpgradeProps = PlatformStackScreenProps { if (isMultiScanEnabled) { @@ -171,7 +173,13 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou const path = getReceiptsUploadFolderPath(); - captureReceipt(camera.current, {flash, hasFlash, isPlatformMuted, path, isInLandscapeMode}) + const shouldUpgradeToPhoto = canUpgradeReceiptQuality && !isMultiScanEnabled && !shouldTakePhoto({flash, hasFlash, isInLandscapeMode}); + + // The snapshot goes first so its request reaches the native queue ahead of the still. On iOS it + // reads the most recent video frame, which a photo capture can interrupt. + const receiptCapture = captureReceipt(camera.current, {flash, hasFlash, isPlatformMuted, path, isInLandscapeMode}); + + receiptCapture .then((photo: PhotoFile) => { endSpanWithAttributes(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE, { [CONST.TELEMETRY.ATTRIBUTE_PHOTO_WIDTH]: photo.width, @@ -196,6 +204,10 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou type: 'image/jpeg', }; + if (shouldUpgradeToPhoto) { + upgradeReceiptWithPhoto(durableName); + } + onCapture(cameraFile, source); }) .catch((error: string) => { @@ -203,7 +215,12 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou maybeCancelShutterSpan(); showCameraAlert(); Log.warn('Error taking photo', error); + discardPendingPhoto(); }); + + if (shouldUpgradeToPhoto) { + startPhotoCapture(camera.current); + } }; // Wait for camera permission status to render @@ -246,12 +263,17 @@ function Camera({onCapture, onPicked, shouldAcceptMultipleFiles = false, onLayou blinkStyle={blinkStyle} isAttachmentPickerActive={isAttachmentPickerActive} didCapturePhoto={didCapturePhoto} + hasPendingPhotoCapture={hasPendingPhotoCapture} onInitialized={handleCameraInitialized} shouldShowFlashButton={canUseMultiScan} cameraPermissionStatus={cameraPermissionStatus} flash={flash} hasFlash={hasFlash} setFlash={setFlash} + // "preview" turns every capture to match the screen, the way the preview is drawn. The default "device" follows the + // phone's physical orientation instead, so a phone held flat over a receipt keeps its last landscape reading and the + // photo comes out sideways. + outputOrientation="preview" /> )} diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts index 4bfae6924dfc..44285d9f4d56 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/types.ts @@ -25,6 +25,9 @@ type CameraProps = { /** Called when the user taps the multi-scan submit button on the preview ribbon. Used by native and mobile web. */ onMultiScanSubmit?: () => void; + + /** Whether the shutter may take a full-resolution photo to upgrade the fast snapshot. Defaults to true; off for replace-receipt and multi-scan. */ + canUpgradeReceiptQuality?: boolean; }; export default CameraProps; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx index fe860d7e9317..101e8a6a6f74 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/CameraViewport.tsx @@ -16,7 +16,7 @@ import type {LayoutChangeEvent, ViewStyle} from 'react-native'; import type {GestureType} from 'react-native-gesture-handler'; import type {PermissionStatus} from 'react-native-permissions'; import type {AnimatedStyle} from 'react-native-reanimated'; -import type {Camera, CameraDevice, CameraDeviceFormat} from 'react-native-vision-camera'; +import type {Camera, CameraDevice, CameraDeviceFormat, CameraProps} from 'react-native-vision-camera'; import React from 'react'; import {StyleSheet, View} from 'react-native'; @@ -63,6 +63,9 @@ type CameraViewportProps = { /** Whether a photo has been captured (forces camera inactive) */ didCapturePhoto?: boolean; + /** Whether a full-resolution capture is still running; keeps the camera session active even after didCapturePhoto so the capture is not cancelled */ + hasPendingPhotoCapture?: boolean; + /** Callback fired when the camera finishes initializing */ onInitialized?: () => void; @@ -85,6 +88,9 @@ type CameraViewportProps = { setFlash: (updater: (prev: boolean) => boolean) => void; + /** Whether photos follow the device or the preview orientation, defaults to "device" */ + outputOrientation?: CameraProps['outputOrientation']; + /** Extra content rendered below the viewfinder, inside the camera view */ children?: ReactNode; }; @@ -102,6 +108,7 @@ function CameraViewport({ blinkStyle, isAttachmentPickerActive, didCapturePhoto = false, + hasPendingPhotoCapture = false, onInitialized, onLayout, shouldShowFlashButton, @@ -110,6 +117,7 @@ function CameraViewport({ flash, hasFlash, setFlash, + outputOrientation = 'device', children, }: CameraViewportProps) { const theme = useTheme(); @@ -130,8 +138,10 @@ function CameraViewport({ style={styles.flex1} zoom={device.neutralZoom} photo + outputOrientation={outputOrientation} cameraTabIndex={1} - forceInactive={isAttachmentPickerActive || didCapturePhoto} + forceInactive={isAttachmentPickerActive || (didCapturePhoto && !hasPendingPhotoCapture)} + shouldStayActiveWhenBlurred={hasPendingPhotoCapture} onInitialized={onInitialized} onLayout={onLayout} // Use TextureView on Android to fix partially blank images for takeSnapshot() diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/Camera.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/Camera.tsx index 1c32134f7e9a..5f89c563823f 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/Camera.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/Camera.tsx @@ -10,9 +10,12 @@ import type {NavigationAwareCameraNativeProps} from './types'; const IS_VIDEO_REQUIRED_FOR_SNAPSHOT = Platform.OS === 'ios'; // Wraps a camera that will only be active when the tab is focused or as soon as it starts to become focused. -function Camera({cameraTabIndex, ref, forceInactive = false, ...props}: NavigationAwareCameraNativeProps) { +function Camera({cameraTabIndex, ref, forceInactive = false, shouldStayActiveWhenBlurred = false, ...props}: NavigationAwareCameraNativeProps) { const isFocused = useIsFocused(); - const isCameraActive = isFocused && !forceInactive; + + // The scan screen blurs as soon as a capture navigates away, and closing the session would cancel a + // capture that is still running. + const isCameraActive = (isFocused || shouldStayActiveWhenBlurred) && !forceInactive; return ( & { cameraTabIndex: number; ref?: ForwardedRef; forceInactive?: boolean; + shouldStayActiveWhenBlurred?: boolean; }; export type {NavigationAwareCameraProps, NavigationAwareCameraNativeProps}; diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx index 0812375fac80..00c256817ced 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanEditReceipt.tsx @@ -123,6 +123,7 @@ function ScanEditReceipt({report, transactionID, backTo, isEditing}: ScanEditRec onPicked={validateFiles} onAttachmentPickerStatusChange={setIsLoaderVisible} isReplacingReceipt + canUpgradeReceiptQuality={false} /> ); diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx index 2e688317087a..da62ad57d6a2 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/components/ScanSkipConfirmation.tsx @@ -406,6 +406,7 @@ function ScanSkipConfirmation({report, action, iouType, reportID, transactionID, onAttachmentPickerStatusChange={setIsLoaderVisible} onMultiScanSubmit={submitMultiScan} shouldAcceptMultipleFiles + canUpgradeReceiptQuality={false} /> ); diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts b/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts new file mode 100644 index 000000000000..a3a389751940 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade.ts @@ -0,0 +1,316 @@ +/** + * Captures a full-resolution photo alongside the fast snapshot and, once a receipt exists, swaps the + * higher-quality photo in behind it. Owns capture deadlines, rotation, and upgrade telemetry. + */ +import Log from '@libs/Log'; +import ReceiptStorage from '@libs/ReceiptStorage'; +import {finish as finishUpgrade, isClaimedForRead as wasReceiptClaimed, recordUpgrade, start as startUpgrade} from '@libs/ReceiptStorage/receiptUpgrades'; +import {endSpanWithAttributes, getSpan, startSpan} from '@libs/telemetry/activeSpans'; + +import rotatePhotoToUpright from '@pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright'; + +import CONST from '@src/CONST'; + +import type {Camera, PhotoFile} from 'react-native-vision-camera'; + +import {useEffect, useRef, useState} from 'react'; +import RNFS from 'react-native-fs'; + +const PHOTO_CAPTURE_TIMEOUT_MS = 3000; + +const ROTATE_TIMEOUT_MS = 5000; + +function discardPhoto(path: string) { + ReceiptStorage.discard(path).catch((error: unknown) => { + Log.warn('[PhotoUpgrade] could not delete the abandoned photo', {error: error instanceof Error ? error.message : String(error)}); + }); +} + +function discardWhenItLands(pending: PendingPhoto) { + pending.promise + .then((photo) => { + if (!photo) { + return; + } + discardPhoto(photo.path); + }) + .catch(() => {}); +} + +class UpgradeStepError extends Error { + step: string; + + hasTimedOut: boolean; + + constructor(step: string, message: string, hasTimedOut: boolean) { + super(`[PhotoUpgrade] ${step} ${message}`); + this.step = step; + this.hasTimedOut = hasTimedOut; + } +} + +function withDeadline(promise: Promise, capMs: number, step: string, discardLateResult?: (value: T) => void): Promise { + let cap: ReturnType; + let hasTimedOut = false; + const deadline = new Promise((_resolve, reject) => { + cap = setTimeout(() => { + hasTimedOut = true; + reject(new UpgradeStepError(step, `did not finish within ${capMs}ms`, true)); + }, capMs); + }); + + if (discardLateResult) { + promise + .then((value) => { + if (!hasTimedOut) { + return; + } + discardLateResult(value); + }) + .catch(() => {}); + } + + return Promise.race([promise, deadline]).finally(() => clearTimeout(cap)); +} + +type PendingPhoto = { + promise: Promise; + + discard: () => void; + + startedAt: number; + + getFailureOutcome: () => string; +}; + +function toUpgradeSpanId(durableName: string) { + return `${CONST.TELEMETRY.SPAN_RECEIPT_UPGRADE}_${durableName}`; +} + +function abandonPending(pending: PendingPhoto) { + pending.discard(); + discardWhenItLands(pending); +} + +function usePhotoUpgrade() { + const [hasPendingPhotoCapture, setHasPendingPhotoCapture] = useState(false); + const pendingPhotoRef = useRef(undefined); + + // `hasPendingPhotoCapture` holds the vision-camera session open: closing it mid-capture cancels the + // photo with "Camera is closed." + const captureCountRef = useRef(0); + + const photosInFlightRef = useRef(0); + + useEffect( + () => () => { + const pending = pendingPhotoRef.current; + if (!pending) { + return; + } + pendingPhotoRef.current = undefined; + abandonPending(pending); + }, + [], + ); + + const releaseSession = () => { + captureCountRef.current -= 1; + if (captureCountRef.current > 0) { + return; + } + setHasPendingPhotoCapture(false); + }; + + const startPhotoCapture = (camera: Camera) => { + const orphaned = pendingPhotoRef.current; + pendingPhotoRef.current = undefined; + if (orphaned) { + abandonPending(orphaned); + } + + // Abandoning a capture does not stop it: `discard` only marks the result for deletion, and + // vision-camera cannot cancel a `takePhoto`. A second one started over a capture still running + // makes them contend, and the next `takeSnapshot` pays for it. 6.6s measured against an 80ms + // baseline. A receipt keeping its snapshot is the cheaper loss. + if (photosInFlightRef.current > 0) { + Log.info('[PhotoUpgrade] a full-resolution capture is still running, so this receipt keeps its snapshot'); + getSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE)?.setAttributes({ + [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_ATTEMPTED]: false, + [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.STACKED_CAPTURE_SKIPPED, + }); + return; + } + + getSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE)?.setAttributes({[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_ATTEMPTED]: true}); + + const startedAt = Date.now(); + let failureOutcome: string = CONST.TELEMETRY.UPGRADE_OUTCOME.CAPTURE_FAILED; + let isDiscarded = false; + + const nativeCapture = camera.takePhoto({flash: 'off', enableShutterSound: false, path: RNFS.TemporaryDirectoryPath}); + + photosInFlightRef.current += 1; + const releasePhotoSlot = () => { + photosInFlightRef.current -= 1; + }; + nativeCapture.then(releasePhotoSlot, releasePhotoSlot); + + const capture = nativeCapture.then( + (photo) => { + if (!isDiscarded) { + return photo; + } + discardPhoto(photo.path); + return undefined; + }, + (error: unknown) => { + Log.warn('[PhotoUpgrade] full-resolution capture failed', {error: error instanceof Error ? error.message : String(error)}); + failureOutcome = CONST.TELEMETRY.UPGRADE_OUTCOME.CAPTURE_FAILED; + return undefined; + }, + ); + + let deadlineTimeout: ReturnType; + const deadline = new Promise((resolve) => { + deadlineTimeout = setTimeout(() => { + isDiscarded = true; + failureOutcome = CONST.TELEMETRY.UPGRADE_OUTCOME.CAPTURE_TIMED_OUT; + resolve(undefined); + }, PHOTO_CAPTURE_TIMEOUT_MS); + }); + + const promise = Promise.race([capture, deadline]).then((photo) => { + clearTimeout(deadlineTimeout); + releaseSession(); + + if (!photo) { + Log.info('[PhotoUpgrade] no photo to upgrade with, keeping the snapshot'); + return undefined; + } + + return photo; + }); + + pendingPhotoRef.current = { + promise, + discard: () => { + isDiscarded = true; + }, + startedAt, + getFailureOutcome: () => failureOutcome, + }; + captureCountRef.current += 1; + setHasPendingPhotoCapture(true); + }; + + const upgradeReceiptWithPhoto = (durableName: string) => { + const pending = pendingPhotoRef.current; + if (!pending) { + Log.info('[PhotoUpgrade] no pending photo for this receipt, so it keeps its snapshot', false, {durableName}); + return; + } + pendingPhotoRef.current = undefined; + + const spanId = toUpgradeSpanId(durableName); + let hasReportedOutcome = false; + const reportOutcome = (outcome: string, attributes: Record = {}) => { + if (hasReportedOutcome) { + return; + } + hasReportedOutcome = true; + endSpanWithAttributes(spanId, {[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: outcome, ...attributes}); + }; + startSpan(spanId, { + name: CONST.TELEMETRY.SPAN_RECEIPT_UPGRADE, + op: CONST.TELEMETRY.SPAN_RECEIPT_UPGRADE, + startTime: pending.startedAt, + attributes: { + [CONST.TELEMETRY.ATTRIBUTE_PLATFORM]: CONST.TELEMETRY.SPAN_PLATFORM.NATIVE, + }, + }); + + startUpgrade(durableName); + + pending.promise + .then((photo) => { + if (!photo) { + reportOutcome(pending.getFailureOutcome()); + return undefined; + } + + const rotation = rotatePhotoToUpright(photo.path, photo.orientation).catch((error: unknown) => { + throw new UpgradeStepError('rotate', `failed: ${error instanceof Error ? error.message : String(error)}`, false); + }); + return withDeadline(rotation, ROTATE_TIMEOUT_MS, 'rotate', (latePath) => { + if (!latePath) { + return; + } + discardPhoto(latePath); + }).then((uprightPath) => { + if (wasReceiptClaimed(durableName)) { + Log.info('[PhotoUpgrade] keeping the snapshot, upgrade claimed for upload before the swap started', false, {durableName}); + reportOutcome(CONST.TELEMETRY.UPGRADE_OUTCOME.CLAIMED_FOR_UPLOAD); + discardPhoto(photo.path); + if (uprightPath) { + discardPhoto(uprightPath); + } + return undefined; + } + + return ReceiptStorage.overwrite(durableName, uprightPath ?? photo.path, () => wasReceiptClaimed(durableName)) + .then(() => { + Log.info('[PhotoUpgrade] receipt upgraded to the full-resolution photo', false, { + width: photo.width, + height: photo.height, + wasRotated: !!uprightPath, + }); + reportOutcome(CONST.TELEMETRY.UPGRADE_OUTCOME.UPGRADED, { + [CONST.TELEMETRY.ATTRIBUTE_PHOTO_WIDTH]: photo.width, + [CONST.TELEMETRY.ATTRIBUTE_PHOTO_HEIGHT]: photo.height, + }); + recordUpgrade(durableName); + + if (!uprightPath) { + return; + } + discardPhoto(photo.path); + }) + .catch((error: unknown) => { + if (uprightPath) { + discardPhoto(uprightPath); + } + throw error; + }); + }); + }) + .catch((error: unknown) => { + const wasClaimed = wasReceiptClaimed(durableName); + const reason = error instanceof Error ? error.message : String(error); + Log.warn(`[PhotoUpgrade] keeping the snapshot, upgrade ${wasClaimed ? 'claimed for upload' : 'failed'}`, {error: reason}); + let outcome: string = CONST.TELEMETRY.UPGRADE_OUTCOME.SWAP_FAILED; + if (wasClaimed) { + outcome = CONST.TELEMETRY.UPGRADE_OUTCOME.CLAIMED_FOR_UPLOAD; + } else if (error instanceof UpgradeStepError && error.step === 'rotate') { + outcome = error.hasTimedOut ? CONST.TELEMETRY.UPGRADE_OUTCOME.ROTATE_TIMED_OUT : CONST.TELEMETRY.UPGRADE_OUTCOME.ROTATE_FAILED; + } + reportOutcome(outcome); + pending.discard(); + discardWhenItLands(pending); + }) + .finally(() => finishUpgrade(durableName)); + }; + + const discardPendingPhoto = () => { + const pending = pendingPhotoRef.current; + if (!pending) { + return; + } + pendingPhotoRef.current = undefined; + abandonPending(pending); + }; + + return {hasPendingPhotoCapture, startPhotoCapture, upgradeReceiptWithPhoto, discardPendingPhoto}; +} + +export default usePhotoUpgrade; diff --git a/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts new file mode 100644 index 000000000000..6af92dbb4aa8 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright.ts @@ -0,0 +1,71 @@ +/** + * Rotates a captured still so the receipt reads upright, reconciling the decoded frame's orientation + * with VisionCamera's display orientation. Returns undefined when the photo is already upright. + */ +import {JPEG_QUALITY} from '@libs/fileDownload/FileUtils'; +import getPhotoSource from '@libs/fileDownload/getPhotoSource'; +import Log from '@libs/Log'; + +import type {Orientation} from 'react-native-vision-camera'; + +import {ImageManipulator, SaveFormat} from 'expo-image-manipulator'; +import ImageSize from 'react-native-image-size'; + +type PhotoSize = { + width: number; + height: number; + rotation?: number; +}; + +function getUprightRotation({width, height, rotation = 0}: PhotoSize, orientation?: Orientation): number | undefined { + // The image loader applies the file's rotation metadata while decoding, so measure the frame the way it + // hands it over: a 1920x1440 photo whose metadata asks for 90 decodes as 1440x1920. + const isSideways = rotation === 90 || rotation === 270; + const decodedWidth = isSideways ? height : width; + const decodedHeight = isSideways ? width : height; + + // VisionCamera's `PhotoFile.orientation` is the display orientation relative to the sensor, as a + // counter-clockwise angle (see its `RotationHelper`): a portrait capture off a landscape sensor reports + // `landscape-left`, so 270 counter-clockwise, so 90 clockwise. The opposite hold turns the other way. + const quarterTurn = orientation === 'landscape-right' ? 270 : 90; + return decodedWidth > decodedHeight ? quarterTurn : undefined; +} + +/** The garbage collector can't see the native memory a 12 MP bitmap holds, so free it as soon as the photo is saved. */ +function releaseQuietly(releasable: {release: () => void}) { + try { + releasable.release(); + } catch (error) { + Log.warn('[PhotoUpgrade] could not release a native image', {error: error instanceof Error ? error.message : String(error)}); + } +} + +function rotatePhotoToUpright(stillPath: string, orientation?: Orientation): Promise { + const sourceUri = getPhotoSource(stillPath); + const startedAt = Date.now(); + + return ImageSize.getSize(sourceUri).then((imageSize) => { + const angle = getUprightRotation(imageSize, orientation); + if (angle === undefined) { + return undefined; + } + + const context = ImageManipulator.manipulate(sourceUri).rotate(angle); + return context + .renderAsync() + .then((image) => image.saveAsync({compress: JPEG_QUALITY, format: SaveFormat.JPEG}).finally(() => releaseQuietly(image))) + .then((result) => { + Log.info('[PhotoUpgrade] rotated the full-resolution photo', false, { + durationMs: Date.now() - startedAt, + angle, + exifRotation: imageSize.rotation, + source: `${imageSize.width}x${imageSize.height}`, + }); + return result.uri; + }) + .finally(() => releaseQuietly(context)); + }); +} + +export default rotatePhotoToUpright; +export {getUprightRotation}; diff --git a/src/pages/signin/SAMLSignInPage/index.native.tsx b/src/pages/signin/SAMLSignInPage/index.native.tsx index ca50ec0f5513..88e3904da478 100644 --- a/src/pages/signin/SAMLSignInPage/index.native.tsx +++ b/src/pages/signin/SAMLSignInPage/index.native.tsx @@ -27,6 +27,7 @@ import handleSAMLLoginError from './handleSAMLLoginError'; function SAMLSignInPage() { const [credentials] = useOnyx(ONYXKEYS.CREDENTIALS); + const [session] = useOnyx(ONYXKEYS.SESSION); const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH); const [showNavigation, shouldShowNavigation] = useState(true); const [SAMLUrl, setSAMLUrl] = useState(''); @@ -106,7 +107,9 @@ function SAMLSignInPage() { // A forced re-auth leaves account.isLoading true until sign-in, so the token alone decides here. if (credentials?.login && shortLivedAuthToken) { Log.info('SAMLSignInPage - Successfully received shortLivedAuthToken. Signing in...'); - signInWithShortLivedAuthToken(shortLivedAuthToken, true, lastVisitedPath, credentials?.login); + signInWithShortLivedAuthToken(shortLivedAuthToken, session?.authToken, true, lastVisitedPath, credentials?.login).catch((error) => { + Log.hmmm('SAMLSignInPage - Failed to sign in with shortLivedAuthToken', {error}); + }); return; } @@ -123,7 +126,7 @@ function SAMLSignInPage() { Navigation.navigate(ROUTES.HOME); }); }, - [credentials?.login, lastVisitedPath, translate], + [credentials?.login, lastVisitedPath, translate, session?.authToken], ); useEffect(() => { diff --git a/src/pages/signin/SignUpWelcomeForm.tsx b/src/pages/signin/SignUpWelcomeForm.tsx index e14e958eeafe..4eb97d299881 100644 --- a/src/pages/signin/SignUpWelcomeForm.tsx +++ b/src/pages/signin/SignUpWelcomeForm.tsx @@ -29,6 +29,7 @@ function SignUpWelcomeForm() { const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [login] = useOnyx(ONYXKEYS.CREDENTIALS, {selector: (credentials) => credentials?.login}); const [preferredLocale] = useOnyx(ONYXKEYS.NVP_PREFERRED_LOCALE); + const [marketingAttribution] = useOnyx(ONYXKEYS.MARKETING_ATTRIBUTION); const serverErrorText = useMemo(() => (account ? getLatestErrorMessage(account) : ''), [account]); const isPhoneSignup = Str.isSMSLogin(login ?? ''); const [hasSMSMarketingConsent, setHasSMSMarketingConsent] = useState(false); @@ -53,7 +54,7 @@ function SignUpWelcomeForm() { size={CONST.BUTTON_SIZE.LARGE} isLoading={account?.isLoading} onPress={() => { - signUpUser(login, preferredLocale, isPhoneSignup ? hasSMSMarketingConsent : undefined); + signUpUser(login, preferredLocale, isPhoneSignup ? hasSMSMarketingConsent : undefined, marketingAttribution); setReadyToShowAuthScreens(true); }} style={[styles.mb2]} diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 2f252c1f6a59..fbd5a0c4c18d 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -10,6 +10,7 @@ import ScrollView from '@components/ScrollView'; import useCardFeedErrors from '@hooks/useCardFeedErrors'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useGetReceiptPartnersIntegrationData from '@hooks/useGetReceiptPartnersIntegrationData'; +import useHasApprovalWorkflowWithNonMemberApprover from '@hooks/useHasApprovalWorkflowWithNonMemberApprover'; import useIsWorkspacesTabFocused from '@hooks/useIsWorkspacesTabFocused'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -93,6 +94,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const workspaceAccountID = useWorkspaceAccountID(policyID); const {shouldShowEnterCredentialsError} = useGetReceiptPartnersIntegrationData(policyID); const {shouldShowRbrForWorkspaceAccountID} = useCardFeedErrors(); + const hasApprovalWorkflowWithNonMemberApprover = useHasApprovalWorkflowWithNonMemberApprover(policyID); const expensifyIcons = useMemoizedLazyExpensifyIcons([ 'Building', 'CalendarSolid', @@ -190,6 +192,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac previousPendingFields: prevPendingFields, shouldShowEnterCredentialsError, shouldShowRBR, + hasApprovalWorkflowWithNonMemberApprover, isVendorMatchingBetaEnabled: isBetaEnabled(CONST.BETAS.VENDOR_MATCHING), isRecruitingBetaEnabled: isBetaEnabled(CONST.BETAS.MERGE_ATS), convertToDisplayString, diff --git a/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx b/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx index f90a2b8798ae..b2f0f9e7ac20 100644 --- a/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx +++ b/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx @@ -7,34 +7,42 @@ import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; import Text from '@components/Text'; +import useActiveServer from '@hooks/useActiveServer'; import {useCompanyCardBankIcons} from '@hooks/useCompanyCardIcons'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import {addNewCompanyCardsFeed} from '@libs/actions/CompanyCards'; import {isPlaidSupportedCountry} from '@libs/CardUtils'; +import Navigation from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; import {setAddNewCompanyCardStepAndData} from '@userActions/CompanyCards'; +import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {CardFeedProvider} from '@src/types/onyx/CardFeeds'; +import ROUTES from '@src/ROUTES'; +import type {CardFeedProvider, CombinedCardFeeds} from '@src/types/onyx/CardFeeds'; import type {StyleProp, ViewStyle} from 'react-native'; import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; +type CardTypeSelection = CardFeedProvider | typeof CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK; + type AvailableCompanyCardTypes = { translate: LocaleContextProps['translate']; - typeSelected?: CardFeedProvider; + typeSelected?: CardTypeSelection; styles: StyleProp; companyCardBankIcons: ReturnType; + shouldShowMockFeed: boolean; }; -function getAvailableCompanyCardTypes({translate, typeSelected, styles, companyCardBankIcons}: AvailableCompanyCardTypes) { +function getAvailableCompanyCardTypes({translate, typeSelected, styles, companyCardBankIcons, shouldShowMockFeed}: AvailableCompanyCardTypes) { const defaultCards = [ { value: CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD, @@ -66,7 +74,13 @@ function getAvailableCompanyCardTypes({translate, typeSelected, styles, companyC }, ]; - return [ + const cards: Array<{ + value: CardTypeSelection; + text: string; + keyForList: CardTypeSelection; + isSelected: boolean; + leftElement: React.JSX.Element; + }> = [ { value: CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX, text: translate('workspace.companyCards.addNewCard.cardProviders.gl1025'), @@ -83,21 +97,51 @@ function getAvailableCompanyCardTypes({translate, typeSelected, styles, companyC }, ...defaultCards, ]; + + if (shouldShowMockFeed) { + cards.push({ + value: CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK, + text: CONST.COMPANY_CARDS.CARD_TYPE_NAMES.MOCK_COMMERCIAL, + keyForList: CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK, + isSelected: typeSelected === CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK, + leftElement: ( + + ), + }); + } + + return cards; } -function CardTypeStep() { +type CardTypeStepProps = { + policyID?: string; + cardFeeds?: CombinedCardFeeds; + workspaceAccountID: number; +}; + +function CardTypeStep({policyID, cardFeeds, workspaceAccountID}: CardTypeStepProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const companyCardBankIcons = useCompanyCardBankIcons(); const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD); - const [localTypeSelected, setLocalTypeSelected] = useState(); + const [lastSelectedFeed] = useOnyx(`${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyID}`); + const {activeServer} = useActiveServer(); + const [localTypeSelected, setLocalTypeSelected] = useState(); const typeSelected = localTypeSelected ?? addNewCard?.data?.feedType; const [isError, setIsError] = useState(false); + // Gate on the API the requests actually reach, since a dev build talks to production by default. + const shouldShowMockFeed = activeServer !== CONST.SERVER.PRODUCTION || CONFIG.IS_USING_LOCAL_WEB; const data = getAvailableCompanyCardTypes({ translate, typeSelected, styles: styles.mr3, companyCardBankIcons, + shouldShowMockFeed, }); const {bankName, selectedBank, feedType} = addNewCard?.data ?? {}; const isOtherBankSelected = selectedBank === CONST.COMPANY_CARDS.BANKS.OTHER; @@ -105,8 +149,11 @@ function CardTypeStep() { const doesCountrySupportPlaid = isPlaidSupportedCountry(addNewCard?.data?.selectedCountry); const submit = useCallback(() => { - if (!typeSelected) { + if (!typeSelected || (typeSelected === CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK && !shouldShowMockFeed)) { setIsError(true); + } else if (typeSelected === CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK) { + addNewCompanyCardsFeed(policyID, workspaceAccountID, typeSelected, {}, cardFeeds, lastSelectedFeed); + Navigation.goBack(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID)); } else { setAddNewCompanyCardStepAndData({ step: CONST.COMPANY_CARDS.STEP.CARD_INSTRUCTIONS, @@ -117,7 +164,7 @@ function CardTypeStep() { isEditing: false, }); } - }, [bankName, isNewCardTypeSelected, isOtherBankSelected, typeSelected]); + }, [bankName, cardFeeds, isNewCardTypeSelected, isOtherBankSelected, lastSelectedFeed, policyID, shouldShowMockFeed, typeSelected, workspaceAccountID]); const handleBackButtonPress = () => { if (isOtherBankSelected) { diff --git a/src/pages/workspace/companyCards/addNew/DynamicAddNewCardPage.tsx b/src/pages/workspace/companyCards/addNew/DynamicAddNewCardPage.tsx index 0ba5c55bd597..5cf5ba6f28f8 100644 --- a/src/pages/workspace/companyCards/addNew/DynamicAddNewCardPage.tsx +++ b/src/pages/workspace/companyCards/addNew/DynamicAddNewCardPage.tsx @@ -123,7 +123,13 @@ function DynamicAddNewCardPage({policy}: WithPolicyAndFullscreenLoadingProps) { CurrentStep = ; break; case CONST.COMPANY_CARDS.STEP.CARD_TYPE: - CurrentStep = ; + CurrentStep = ( + + ); break; case CONST.COMPANY_CARDS.STEP.BANK_CONNECTION: CurrentStep = ; diff --git a/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx b/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx index 4b49d7560645..3a80429454f6 100644 --- a/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx +++ b/src/pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm.tsx @@ -198,12 +198,16 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm return result.filter((item): item is NonNullable => !!item); })(); + // Merchant rules live under Rules, so they are copied only along with it. The Rules row is hidden when the source has no workspace-level rules, and merchant rules can then be copied on their own. + const hasRulesItem = items.some((item) => item.value === 'rules'); + const effectiveSelectedItems = hasRulesItem && !selectedItems.includes('rules') ? selectedItems.filter((value) => value !== 'codingRules') : selectedItems; + const featuresToCopy: ListItem[] = items.map((option) => { const alternateText = option?.alternateText ? option.alternateText.trim().replaceAll(/,$/g, '') : undefined; return { text: option.translation, keyForList: option.value, - isSelected: selectedItems.includes(option.value), + isSelected: effectiveSelectedItems.includes(option.value), alternateText, }; }); @@ -227,25 +231,25 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm policyID: policy.id, targetPolicyID: duplicateWorkspace.policyID, welcomeNote: `${translate('workspace.duplicateWorkspace.welcomeNote')} ${duplicateWorkspace.name}`, - policyCategories: selectedItems.includes('categories') ? policyCategories : undefined, + policyCategories: effectiveSelectedItems.includes('categories') ? policyCategories : undefined, personalDetailsByLogins: employeePersonalDetails, parts: { - people: selectedItems.includes('members'), - reports: selectedItems.includes('reports'), - connections: selectedItems.includes('accounting'), - categories: selectedItems.includes('categories'), - tags: selectedItems.includes('tags'), - taxes: selectedItems.includes('taxes'), - perDiem: selectedItems.includes('perDiem'), - reimbursements: selectedItems.includes('invoices'), - expenses: selectedItems.includes('rules'), - distance: selectedItems.includes('distanceRates'), - invoices: selectedItems.includes('invoices'), - invoiceFields: selectedItems.includes('invoices'), - exportLayouts: selectedItems.includes('workflows'), - overview: selectedItems.includes('overview'), - travel: selectedItems.includes('travel'), - codingRules: selectedItems.includes('codingRules'), + people: effectiveSelectedItems.includes('members'), + reports: effectiveSelectedItems.includes('reports'), + connections: effectiveSelectedItems.includes('accounting'), + categories: effectiveSelectedItems.includes('categories'), + tags: effectiveSelectedItems.includes('tags'), + taxes: effectiveSelectedItems.includes('taxes'), + perDiem: effectiveSelectedItems.includes('perDiem'), + reimbursements: effectiveSelectedItems.includes('invoices'), + expenses: effectiveSelectedItems.includes('rules'), + distance: effectiveSelectedItems.includes('distanceRates'), + invoices: effectiveSelectedItems.includes('invoices'), + invoiceFields: effectiveSelectedItems.includes('invoices'), + exportLayouts: effectiveSelectedItems.includes('workflows'), + overview: effectiveSelectedItems.includes('overview'), + travel: effectiveSelectedItems.includes('travel'), + codingRules: effectiveSelectedItems.includes('codingRules'), }, file: duplicatedWorkspaceAvatar, localCurrency: currentUserPersonalDetails?.localCurrencyCode ?? CONST.CURRENCY.USD, @@ -257,7 +261,7 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm const duplicateWorkspaceName = duplicateWorkspace?.name; const duplicateWorkspacePolicyID = duplicateWorkspace?.policyID; const onConfirmSelectList = () => { - if (!totalMembers || totalMembers < 2 || !selectedItems.includes('members')) { + if (!totalMembers || totalMembers < 2 || !effectiveSelectedItems.includes('members')) { confirmDuplicate(); return; } @@ -287,7 +291,7 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm }; const toggleAllItems = () => { - if (selectedItems.length === items.length) { + if (effectiveSelectedItems.length === items.length) { setSelectedItems([]); } else { setSelectedItems(items.map((i) => i.value)); @@ -296,15 +300,18 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm const updateSelectedItems = (listItem: ListItem) => { if (listItem.isSelected) { - setSelectedItems(selectedItems.filter((i) => i !== listItem.keyForList)); + const deselectedItems = listItem.keyForList === 'rules' ? ['rules', 'codingRules'] : [listItem.keyForList]; + setSelectedItems(selectedItems.filter((i) => !deselectedItems.includes(i))); return; } const newItem = items.find((i) => i.value === listItem.keyForList)?.value; - - if (newItem) { - setSelectedItems([...selectedItems, newItem]); + if (!newItem) { + return; } + + const newItems = newItem === 'codingRules' && hasRulesItem ? [newItem, 'rules'] : [newItem]; + setSelectedItems(Array.from(new Set([...selectedItems, ...newItems]))); }; // When the component mounts, if there is a new avatar, see if the image can be read from the disk. If not, redirect the user to the starting step of the flow. @@ -339,7 +346,7 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const isSelectAllChecked = selectedItems.length > 0 && selectedItems.length === items.length; + const isSelectAllChecked = effectiveSelectedItems.length > 0 && effectiveSelectedItems.length === items.length; const confirmButtonOptions: ConfirmButtonOptions = { showButton: true, @@ -363,7 +370,7 @@ function WorkspaceDuplicateSelectFeaturesForm({policyID}: WorkspaceDuplicateForm 0 && selectedItems.length !== items.length} + isIndeterminate={effectiveSelectedItems.length > 0 && effectiveSelectedItems.length !== items.length} onPress={toggleAllItems} disabled={items.length === 0} shouldSelectOnPressEnter diff --git a/src/pages/workspace/getWorkspaceMenuItems.ts b/src/pages/workspace/getWorkspaceMenuItems.ts index ef6bb5fb0f0c..3ec908c36625 100644 --- a/src/pages/workspace/getWorkspaceMenuItems.ts +++ b/src/pages/workspace/getWorkspaceMenuItems.ts @@ -109,6 +109,8 @@ type GetWorkspaceMenuItemsParams = { shouldShowEnterCredentialsError?: boolean; /** Whether the company cards row should show an error indicator. */ shouldShowRBR?: boolean; + /** Whether a workflow the user can fix has an approver who is no longer a workspace member, which shows an error on the Workflows row. */ + hasApprovalWorkflowWithNonMemberApprover?: boolean; /** Whether the vendor matching beta is enabled. */ isVendorMatchingBetaEnabled?: boolean; /** Whether the Merge ATS beta gating the Recruiting feature is enabled. */ @@ -128,6 +130,7 @@ function getWorkspaceMenuItems({ previousPendingFields, shouldShowEnterCredentialsError = false, shouldShowRBR = false, + hasApprovalWorkflowWithNonMemberApprover = false, isVendorMatchingBetaEnabled = false, isRecruitingBetaEnabled = false, convertToDisplayString, @@ -353,7 +356,7 @@ function getWorkspaceMenuItems({ translationKey: 'workspace.common.workflows', icon: icons.Workflows, getRoute: () => ROUTES.WORKSPACE_WORKFLOWS.getRoute(policyID), - brickRoadIndicator: !isEmptyObject(policy?.errorFields?.reimburser ?? {}) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + brickRoadIndicator: !isEmptyObject(policy?.errorFields?.reimburser ?? {}) || hasApprovalWorkflowWithNonMemberApprover ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, screenName: SCREENS.WORKSPACE.WORKFLOWS, sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.WORKFLOWS, highlighted: highlightedPolicyFeature === CONST.POLICY.MORE_FEATURES.ARE_WORKFLOWS_ENABLED, diff --git a/src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx b/src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx index 61928054b960..a5d40240b47d 100644 --- a/src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx +++ b/src/pages/workspace/hr/merge/MergeHRGroupsPage.tsx @@ -1,3 +1,4 @@ +import BlockingView from '@components/BlockingViews/BlockingView'; import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -7,11 +8,13 @@ import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectL import type {ListItem} from '@components/SelectionList/ListItem/types'; import Text from '@components/Text'; +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateMergeHRGroups} from '@libs/actions/connections/merge/HR'; +import {getNonRenderableMergeHRGroupIDs, getValidMergeHRGroupIDs} from '@libs/merge/HRUtils'; import {isMergeConnected} from '@libs/merge/MergeUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -20,10 +23,12 @@ import tokenizedSearch from '@libs/tokenizedSearch'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import variables from '@styles/variables'; + import CONST from '@src/CONST'; import type SCREENS from '@src/SCREENS'; -import React, {useState} from 'react'; +import React, {useMemo, useState} from 'react'; import {View} from 'react-native'; type MergeHRGroupsPageProps = PlatformStackScreenProps; @@ -40,50 +45,66 @@ function MergeHRGroupsPage({ }: MergeHRGroupsPageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + const illustrations = useMemoizedLazyIllustrations(['Telescope']); const policy = usePolicy(policyID); const availableGroups = policy?.connections?.merge_hris?.data?.groups ?? []; const currentGroups = policy?.connections?.merge_hris?.config?.groups; + const nonRenderableGroupIDs = getNonRenderableMergeHRGroupIDs(policy); + const hasAnyGroups = availableGroups.length > 0 || nonRenderableGroupIDs.length > 0; - const [selectedIds, setSelectedIds] = useState>(() => new Set(currentGroups ?? [])); + // null until the admin touches a checkbox, so the selection keeps tracking policy data (which may still be + // loading on a cold open) instead of freezing on whatever was available at mount. + const [manualSelection, setManualSelection] = useState | null>(null); + const selectedIds = manualSelection ?? new Set(getValidMergeHRGroupIDs(policy)); const [searchText, setSearchText] = useState(''); const filteredGroups = tokenizedSearch(availableGroups, searchText, (group) => [group.name, group.type]); - const listData: GroupListItem[] = filteredGroups.map((group) => ({ - text: group.name, - alternateText: group.type.charAt(0).toUpperCase() + group.type.slice(1).toLowerCase(), - keyForList: group.id, - value: group.id, - isSelected: selectedIds.has(group.id), - itemStyle: styles.pv4, - })); + // Groups the HR system still has but that Merge sent back without a name or type are labelled with their ID, + // so that ID is the only thing there is to search them by. + const filteredNonRenderableGroupIDs = tokenizedSearch(nonRenderableGroupIDs, searchText, (groupID) => [groupID]); + + const listData: GroupListItem[] = [ + ...filteredGroups.map((group) => ({ + text: group.name, + alternateText: group.type.charAt(0).toUpperCase() + group.type.slice(1).toLowerCase(), + keyForList: group.id, + value: group.id, + isSelected: selectedIds.has(group.id), + itemStyle: styles.pv4, + })), + ...filteredNonRenderableGroupIDs.map((groupID) => ({ + text: translate('workspace.hr.mergeHR.groups.unnamedGroup', groupID), + keyForList: groupID, + value: groupID, + isSelected: selectedIds.has(groupID), + itemStyle: styles.pv4, + })), + ]; + const visibleGroupIDs = [...filteredGroups.map((group) => group.id), ...filteredNonRenderableGroupIDs]; const toggleItem = (item: GroupListItem) => { - setSelectedIds((prev) => { - const next = new Set(prev); - if (next.has(item.value)) { - next.delete(item.value); - } else { - next.add(item.value); - } - return next; - }); + const next = new Set(selectedIds); + if (next.has(item.value)) { + next.delete(item.value); + } else { + next.add(item.value); + } + setManualSelection(next); }; const toggleSelectAll = () => { - setSelectedIds((prev) => { - const next = new Set(prev); - const allVisibleSelected = filteredGroups.length > 0 && filteredGroups.every((group) => next.has(group.id)); - for (const group of filteredGroups) { - if (allVisibleSelected) { - next.delete(group.id); - } else { - next.add(group.id); - } + const next = new Set(selectedIds); + const allVisibleSelected = visibleGroupIDs.length > 0 && visibleGroupIDs.every((groupID) => next.has(groupID)); + for (const groupID of visibleGroupIDs) { + if (allVisibleSelected) { + next.delete(groupID); + } else { + next.add(groupID); } - return next; - }); + } + setManualSelection(next); }; const handleSave = () => { @@ -91,6 +112,20 @@ function MergeHRGroupsPage({ Navigation.goBack(); }; + const listEmptyContent = useMemo( + () => ( + + ), + [illustrations.Telescope, translate, styles.pb10], + ); + return ( 0 ? toggleSelectAll : undefined} + listEmptyContent={listEmptyContent} + shouldShowListEmptyContent={!hasAnyGroups} textInputOptions={{ label: translate('common.search'), value: searchText, onChangeText: setSearchText, + headerMessage: listData.length === 0 && hasAnyGroups ? translate('common.noResultsFound') : undefined, style: {containerStyle: styles.pb5}, }} style={{listHeaderSelectAllTextStyle: styles.textLabelSupporting}} @@ -128,7 +166,6 @@ function MergeHRGroupsPage({ size={CONST.BUTTON_SIZE.LARGE} variant={CONST.BUTTON_VARIANT.SUCCESS} onPress={handleSave} - isDisabled={selectedIds.size === 0} > {translate('common.save')} diff --git a/src/pages/workspace/hr/utils.ts b/src/pages/workspace/hr/utils.ts index 2637e3d7e992..ec1d08137f5f 100644 --- a/src/pages/workspace/hr/utils.ts +++ b/src/pages/workspace/hr/utils.ts @@ -6,9 +6,9 @@ import getGustoSetupLink from '@libs/actions/connections/Gusto'; import {getMergeSetupLink} from '@libs/actions/connections/merge'; import getZenefitsSetupLink from '@libs/actions/connections/Zenefits'; import {formatList} from '@libs/Localize'; -import {getConnectedHRProvider, getHRApprovalMode, isMergeHRCompleteSetupNeeded} from '@libs/merge/HRUtils'; +import {getConnectedHRProvider, getHRApprovalMode, hasStaleMergeHRGroups, isMergeHRCompleteSetupNeeded} from '@libs/merge/HRUtils'; import type {HRConnectionName} from '@libs/merge/HRUtils'; -import {getMergeFinalApproverDisplayName} from '@libs/merge/MergeUtils'; +import {getMergeFinalApproverDisplayName, hasMergeSyncError} from '@libs/merge/MergeUtils'; import {getIntegrationLastSuccessfulDate} from '@libs/PolicyUtils'; import type {MergeProviderCardDescriptor, MergeProviderConfigRow} from '@pages/workspace/merge/types'; @@ -46,7 +46,7 @@ function getMergeHRSyncState(policy: OnyxEntry) { return { isSyncInProgress, isInitialSyncInProgress: isSyncInProgress && lastSync?.syncType === CONST.MERGE.SYNC_TYPE.INITIAL, - hasError: lastSync?.syncStatus === CONST.MERGE.SYNC_STATUS.FAILED, + hasError: hasMergeSyncError(policy, CONST.POLICY.CONNECTIONS.NAME.MERGE_HR), syncStageInProgress: undefined, successfulDate: lastSync?.successfulDate, }; @@ -257,6 +257,7 @@ function getHRCards({ const state = getHRCardState({policy, connectionName: mergeConnectionName, connectionSyncProgress, getLocalDateFromDatetime, mergeSlug: slug}); const mergeConfig = state.isConnected ? policy?.connections?.merge_hris?.config : undefined; const needsSetup = state.isConnected && !state.needsReconnect && isMergeHRCompleteSetupNeeded(policy); + const hasStaleGroups = state.isConnected && !state.needsReconnect && !needsSetup && hasStaleMergeHRGroups(policy); const groupsRoute = ROUTES.WORKSPACE_HR_MERGE_GROUPS.getRoute(policyID); // In Custom approval the connection sets no approvers, so it has no final approver either @@ -284,6 +285,7 @@ function getHRCards({ route: groupsRoute, pendingAction: mergeConfig?.pendingFields?.groups, errors: mergeConfig?.errorFields?.groups, + hasInvalidValue: hasStaleGroups, }, { field: 'approvalMode', @@ -306,6 +308,7 @@ function getHRCards({ setupLink: getMergeSetupLink(policyID, slug, CONST.MERGE.CATEGORY.HRIS), ...(state.isConnected ? state : disconnectedState), completeSetupRoute: needsSetup ? groupsRoute : undefined, + staleGroupsRoute: hasStaleGroups ? groupsRoute : undefined, configRows, }); } diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 8df2f6712c50..fc14ab53f9e1 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -154,7 +154,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const canWriteMembers = canMemberWrite(policy, currentUserLogin, CONST.POLICY.POLICY_FEATURE.MEMBERS); const canManageSelectedMemberRole = canMemberAssignRole(policy, currentUserLogin, member?.role); const canRemoveSelectedMember = canWriteMembers && !isSelectedMemberOwner && !isSelectedMemberCurrentUser && canMemberManageMemberWithRole(policy, currentUserLogin, member?.role); - const ownerDetails = personalDetails?.[policy?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? ({} as PersonalDetails); + const ownerDetails = personalDetails?.[policy?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID]; const policyOwnerDisplayName = temporaryGetDisplayNameOrDefault({passedPersonalDetails: ownerDetails, translate, formatPhoneNumber}) ?? policy?.owner ?? ''; const {cardList: assignableCards, ...workspaceCards} = getAllCardsForWorkspace(workspaceAccountID, cardList, cardFeeds, expensifyCardSettings); const isSMSLogin = Str.isSMSLogin(memberLogin); @@ -242,11 +242,11 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM }; const removeUser = () => { - const ownerEmail = ownerDetails?.login; - const removedApprover = personalDetails?.[accountID]; + // Fall back to the policy owner, so submitters are still reassigned when personal details aren't loaded. + const ownerEmail = ownerDetails?.login ?? policy?.owner; // If the user is not an approver, proceed with member removal - if (!isPolicyApprover(policy, memberLogin) || !removedApprover?.login || !ownerEmail) { + if (!memberLogin || !isPolicyApprover(policy, memberLogin) || !ownerEmail) { removeMemberAndCloseModal(); return; } @@ -254,7 +254,8 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM // Update approval workflows after approver removal const updatedWorkflows = updateWorkflowDataOnApproverRemoval({ approvalWorkflows, - removedApprover, + removedApproverEmail: memberLogin, + ownerEmail, ownerDetails, }); diff --git a/src/pages/workspace/merge/MergeProviderCard.tsx b/src/pages/workspace/merge/MergeProviderCard.tsx index 8e6e93b918f3..2805e8637ec7 100644 --- a/src/pages/workspace/merge/MergeProviderCard.tsx +++ b/src/pages/workspace/merge/MergeProviderCard.tsx @@ -69,6 +69,8 @@ function MergeProviderCard({card, policy, handleConnect, canWriteMoreFeatures, s connectionDescription = translate('workspace.merge.lastSync', datetimeToRelative(card.successfulDate)); } + // Checked in this order: reconnect, then a stale selection, then any other sync failure - a stale + // selection is reported here even if the same sync also failed for an unrelated reason. let lastSyncErrorMessage: ReactNode | undefined; if (card.needsReconnect) { lastSyncErrorMessage = ( @@ -84,6 +86,25 @@ function MergeProviderCard({card, policy, handleConnect, canWriteMoreFeatures, s )} ); + } else if (card.staleGroupsRoute) { + const staleGroupsRoute = card.staleGroupsRoute; + lastSyncErrorMessage = ( + <> + {`${translate('workspace.hr.mergeHR.groups.staleSelectionError', card.displayName)} `} + { + if (!canWriteMoreFeatures) { + showReadOnlyModal(); + return; + } + Navigation.navigate(staleGroupsRoute); + }} + > + {translate('workspace.hr.mergeHR.groups.updateSelectionLink')} + + + ); } else if (card.hasError) { const genericError = translate('workspace.merge.syncError', card.displayName); lastSyncErrorMessage = card.lastSyncErrorMessage ? `${genericError} ("${card.lastSyncErrorMessage}")` : genericError; @@ -224,7 +245,7 @@ function MergeProviderCard({card, policy, handleConnect, canWriteMoreFeatures, s numberOfLinesTitle={row.numberOfLinesTitle} style={styles.sectionMenuItemTopDescription} shouldShowRightIcon={canWriteMoreFeatures} - brickRoadIndicator={row.errors ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + brickRoadIndicator={row.errors || row.hasInvalidValue ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} onPress={() => Navigation.navigate(row.route)} interactive={canWriteMoreFeatures} /> diff --git a/src/pages/workspace/merge/types.ts b/src/pages/workspace/merge/types.ts index 62300d5bd4c7..a33d617858fa 100644 --- a/src/pages/workspace/merge/types.ts +++ b/src/pages/workspace/merge/types.ts @@ -43,6 +43,9 @@ type MergeProviderConfigRow = { /** Errors for this field when the last update failed. */ errors?: Errors | null; + + /** Whether the saved value itself is no longer valid, as opposed to the last update having failed. */ + hasInvalidValue?: boolean; }; /** Everything `MergeProviderCard` needs to render one integration, on either the HR or the Recruiting page. */ @@ -77,6 +80,9 @@ type MergeProviderCardDescriptor = { /** Navigation route to the post-connect setup RHP. Set only while the admin still needs to finish setup. */ completeSetupRoute?: Route; + /** Navigation route to the group selector. Set only while the saved Merge HR group selection points at groups the HR system no longer has. */ + staleGroupsRoute?: Route; + /** ISO date string of the last successful sync, used for "last synced" display. */ successfulDate?: string; diff --git a/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx b/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx index 7c5b3e18b25b..3808dbaeaf04 100644 --- a/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx +++ b/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx @@ -19,7 +19,7 @@ import Navigation from '@libs/Navigation/Navigation'; import {isControlPolicy} from '@libs/PolicyUtils'; import sortAlphabetically from '@libs/sortAlphabetically'; import {getDefaultAvatarURL} from '@libs/UserAvatarUtils'; -import {getApprovalLimitDescription} from '@libs/WorkflowUtils'; +import {getApprovalLimitDescription, getNonMemberApproverError} from '@libs/WorkflowUtils'; import CONST from '@src/CONST'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; @@ -114,6 +114,12 @@ function ApprovalWorkflowEditor({approvalWorkflow, removeApprovalWorkflow, polic const previousApprover = approvalWorkflow.approvers.slice(0, approverIndex).findLast(Boolean); const error = approvalWorkflow?.errors?.[`approver-${approverIndex}`]; + // Flag a route to a non-member as soon as the editor opens, rather than only after a Save attempt + const nonMemberApproverError = getNonMemberApproverError(approver, approvalWorkflow.isDefault); + if (!error && nonMemberApproverError) { + return translate(nonMemberApproverError); + } + if (!error) { return; } @@ -131,7 +137,7 @@ function ApprovalWorkflowEditor({approvalWorkflow, removeApprovalWorkflow, polic return translate(error); }, - [approvalWorkflow.approvers, approvalWorkflow.errors, translate, formatPhoneNumber], + [approvalWorkflow.approvers, approvalWorkflow.errors, approvalWorkflow.isDefault, translate, formatPhoneNumber], ); const editApprover = useCallback( diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx index 29c98a1db071..9bac15237e98 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx @@ -29,6 +29,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import type {Approver} from '@src/types/onyx/ApprovalWorkflow'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import {useNavigationState} from '@react-navigation/native'; @@ -55,13 +56,18 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa // Keep the removed approver visible until navigation finishes. // Without this temporary state, clearing the approver immediately causes the empty state to flash // while this screen is still mounted during the dismiss animation. - const [removingApproverEmail, setRemovingApproverEmail] = useState(); + const [removingApprover, setRemovingApprover] = useState(); const isChangeApproverRoute = route.name === SCREENS.WORKSPACE.WORKFLOWS_APPROVALS_APPROVER_CHANGE; const isInitialCreationFlow = approvalWorkflow?.action === CONST.APPROVAL_WORKFLOW.ACTION.CREATE && approvalWorkflow?.isInitialFlow; const currentApprover = approvalWorkflow?.approvers[approverIndex]; - const selectedApproverEmail = currentApprover?.email; - const visibleSelectedApproverEmail = removingApproverEmail ?? selectedApproverEmail; + const visibleSelectedApprover = removingApprover ?? currentApprover; + const visibleSelectedApproverEmail = visibleSelectedApprover?.email; + + // An approver who left the workspace isn't on employeeList, so they get their own selected row the admin can tap + // to remove them. It only exists while they hold this slot, so once removed or replaced they can't be picked again. + const nonMemberApprover = visibleSelectedApprover?.isNotWorkspaceMember ? visibleSelectedApprover : undefined; + const nonMemberApproverAccountID = nonMemberApprover ? personalDetailsByEmail?.[nonMemberApprover.email]?.accountID : undefined; const employeeList = policy?.employeeList; const approversFromWorkflow = approvalWorkflow?.approvers; @@ -77,7 +83,7 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa const membersEmail = approvalWorkflow?.members?.map((member) => member.email); - return Object.values(employeeList) + const approvers = Object.values(employeeList) .map((employee): SelectionListApprover | null => { const email = employee.email; @@ -134,6 +140,20 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa }; }) .filter((approver): approver is SelectionListApprover => !!approver); + + if (nonMemberApprover && !approvers.some((approver) => approver.login === nonMemberApprover.email)) { + approvers.push({ + text: nonMemberApprover.displayName, + alternateText: nonMemberApprover.email, + keyForList: nonMemberApprover.email, + isSelected: true, + login: nonMemberApprover.email, + value: nonMemberApprover.email, + icons: [{source: nonMemberApprover.avatar ?? icons.FallbackAvatar, type: CONST.ICON_TYPE_AVATAR, name: nonMemberApprover.displayName, id: nonMemberApproverAccountID}], + }); + } + + return approvers; }, [ isApprovalWorkflowLoading, employeeList, @@ -150,9 +170,11 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa personalDetails, icons.FallbackAvatar, shouldFilterOutExpensifyTeam, + nonMemberApprover, + nonMemberApproverAccountID, ]); - const shouldShowListEmptyContent = !!approvalWorkflow && !isApprovalWorkflowLoading && !removingApproverEmail; + const shouldShowListEmptyContent = !!approvalWorkflow && !isApprovalWorkflowLoading && !removingApprover; const goBack = useCallback(() => { let backToRoute; @@ -175,7 +197,7 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa const isRemovingApprover = approvers.length === 0; if (isRemovingApprover) { - setRemovingApproverEmail(visibleSelectedApproverEmail); + setRemovingApprover(visibleSelectedApprover); clearApprovalWorkflowApprover({approverIndex, currentApprovalWorkflow: approvalWorkflow}); if (isChangeApproverRoute && approvalWorkflow?.action === CONST.APPROVAL_WORKFLOW.ACTION.EDIT) { // Don't compare params — see goBack above. @@ -228,7 +250,7 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa personalDetailsByEmail, isChangeApproverRoute, firstApprover, - visibleSelectedApproverEmail, + visibleSelectedApprover, ], ); diff --git a/src/selectors/Session.ts b/src/selectors/Session.ts index c1798bac7d6d..95ffe111e926 100644 --- a/src/selectors/Session.ts +++ b/src/selectors/Session.ts @@ -15,4 +15,7 @@ const sessionEmailAndAccountIDSelector = (session: OnyxEntry) => ({emai const authTokenSelector = (session: OnyxEntry) => session?.authToken; -export {emailSelector, accountIDSelector, sessionEmailAndAccountIDSelector, authTokenSelector, isSupportalSessionSelector, isDelegateSessionSelector}; +/** Whether the user is signed in to a real account, which excludes the anonymous sessions used to view public rooms */ +const hasNonAnonymousSessionSelector = (session: OnyxEntry) => !!session?.authToken && session.authTokenType !== CONST.AUTH_TOKEN_TYPES.ANONYMOUS; + +export {emailSelector, accountIDSelector, sessionEmailAndAccountIDSelector, authTokenSelector, hasNonAnonymousSessionSelector, isSupportalSessionSelector, isDelegateSessionSelector}; diff --git a/src/selectors/TableColumnWidths.ts b/src/selectors/TableColumnWidths.ts new file mode 100644 index 000000000000..3ca8486ffff3 --- /dev/null +++ b/src/selectors/TableColumnWidths.ts @@ -0,0 +1,9 @@ +import type {TableColumnWidths} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +/** One table's stored widths. Narrowing keeps a drag in one table from re-rendering every other table. */ +const tableColumnWidthsSelector = (columnResizingID: string) => (tableColumnWidths: OnyxEntry) => tableColumnWidths?.[columnResizingID]; + +// eslint-disable-next-line import/prefer-default-export -- additional selectors may be added here +export {tableColumnWidthsSelector}; diff --git a/src/setup/index.ts b/src/setup/index.ts index 6d57358a5d4c..c6967ca7bffc 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -3,6 +3,7 @@ import finishCloudflareSignInFromURL from '@libs/CloudflareAccess/finishSignInFr import intlPolyfill from '@libs/IntlPolyfill'; import registerMiddlewares from '@libs/Middleware/register'; import {startMainQueue} from '@libs/Network'; +import ReceiptStorage from '@libs/ReceiptStorage'; import registerReportActionsPagination from '@libs/registerReportActionsPagination'; import {setDeviceID} from '@userActions/Device'; @@ -110,6 +111,8 @@ export default function () { cleanupPreMountedDraftReports(); + ReceiptStorage.sweepLeftovers(); + // The carousel's sibling list belongs to the screen that seeded it, and that ownership lives in module state // which dies with the JS runtime. A list that survives in storage is therefore orphaned the moment the app // reloads: no mounted screen can refresh or release it, and an expense opened straight from a deeplink would diff --git a/src/styles/index.ts b/src/styles/index.ts index 2eac028b4aa8..5dc78ee6118c 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -2790,6 +2790,10 @@ const staticStyles = (theme: ThemeColors) => borderColor: 'transparent', }, + tableColumnResizeIndicator: { + backgroundColor: theme.iconMenu, + }, + tableRowHeightCompact: { minHeight: variables.tableRowHeightCompact, }, diff --git a/src/styles/utils/cursor/index.native.ts b/src/styles/utils/cursor/index.native.ts index 67b788f0f87e..720dc68d58e2 100644 --- a/src/styles/utils/cursor/index.native.ts +++ b/src/styles/utils/cursor/index.native.ts @@ -14,6 +14,7 @@ const cursor: CursorStyles = { cursorInitial: {}, cursorText: {}, cursorEwResize: {}, + cursorColResize: {}, cursorNsResize: {}, cursorNeswResize: {}, cursorNwseResize: {}, diff --git a/src/styles/utils/cursor/index.ts b/src/styles/utils/cursor/index.ts index 7576f5063cba..bbabc801fb1d 100644 --- a/src/styles/utils/cursor/index.ts +++ b/src/styles/utils/cursor/index.ts @@ -47,6 +47,10 @@ const cursor: CursorStyles = { cursorEwResize: { cursor: 'ew-resize' as ViewStyle['cursor'], }, + cursorColResize: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- "col-resize" is a valid web cursor that react-native's CursorValue type doesn't include + cursor: 'col-resize' as ViewStyle['cursor'], + }, cursorNsResize: { cursor: 'ns-resize' as ViewStyle['cursor'], }, diff --git a/src/styles/utils/cursor/types.ts b/src/styles/utils/cursor/types.ts index 066cd7407a43..d62ed4c54bd3 100644 --- a/src/styles/utils/cursor/types.ts +++ b/src/styles/utils/cursor/types.ts @@ -14,6 +14,7 @@ type CursorStylesKeys = | 'cursorInitial' | 'cursorText' | 'cursorEwResize' + | 'cursorColResize' | 'cursorNsResize' | 'cursorNeswResize' | 'cursorNwseResize'; diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 895135311b65..de4cdb4259a5 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -15,7 +15,7 @@ import type {Dimensions} from '@src/types/utils/Layout'; import type Nullable from '@src/types/utils/Nullable'; // eslint-disable-next-line no-restricted-imports -import type {AnimatableNumericValue, Animated, ColorValue, ImageStyle, PressableStateCallbackType, StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {AnimatableNumericValue, Animated, ColorValue, DimensionValue, ImageStyle, PressableStateCallbackType, StyleProp, TextStyle, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {EdgeInsets} from 'react-native-safe-area-context'; import type {ValueOf} from 'type-fest'; @@ -514,11 +514,12 @@ function getZoomSizingStyle({imageSize, containerSize, zoomScale, isZoomed, isLo } /** - * Returns a style with width set to the specified number + * Returns a style with width set to the specified number, or on web to a CSS expression like `calc()` */ -function getWidthStyle(width: number): ViewStyle & ImageStyle { +function getWidthStyle(width: number | string): ViewStyle & ImageStyle { return { - width, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- `DimensionValue` can't type CSS expressions like `calc()`, which react-native-web passes through + width: width as DimensionValue, }; } @@ -913,11 +914,12 @@ function getMinimumHeight(minHeight: number): ViewStyle { } /** - * Get minimum width as style + * Get minimum width as style, or on web a CSS expression like `calc()` */ -function getMinimumWidth(minWidth: number): ViewStyle & ImageStyle { +function getMinimumWidth(minWidth: number | string): ViewStyle & ImageStyle { return { - minWidth, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- `DimensionValue` can't type CSS expressions like `calc()`, which react-native-web passes through + minWidth: minWidth as DimensionValue, }; } diff --git a/src/types/onyx/ApprovalWorkflow.ts b/src/types/onyx/ApprovalWorkflow.ts index 321dff5c3251..f53602daed55 100644 --- a/src/types/onyx/ApprovalWorkflow.ts +++ b/src/types/onyx/ApprovalWorkflow.ts @@ -63,6 +63,18 @@ type Approver = { */ isCircularReference?: boolean; + /** + * Is this approver no longer a workspace member. Members can still submit to them when they were removed without + * their submitters being reassigned, so the workflow is shown with an error until an admin fixes it. + */ + isNotWorkspaceMember?: boolean; + + /** + * Is this approver's `overLimitForwardsTo` no longer a workspace member. Reports over the approval limit still go + * to them, so the workflow is shown with an error until an admin picks a new additional approver. + */ + isOverLimitForwardsToNotWorkspaceMember?: boolean; + /** * If report total is above this limit, the report will be forwarded to 'overLimitForwardsTo' instead of 'forwardsTo' */ diff --git a/src/types/onyx/MarketingAttribution.ts b/src/types/onyx/MarketingAttribution.ts new file mode 100644 index 000000000000..858f90d66b12 --- /dev/null +++ b/src/types/onyx/MarketingAttribution.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * Marketing attribution captured from the landing URL (UTM params, Google Ads device and network, and Google Ads click IDs). + * Keys match the request parameter names the backend reads during signup so they can be spread + * directly into the SignUpUser request. + */ +type MarketingAttribution = { + /** The campaign source (e.g. google, reddit) */ + utm_source?: string; + + /** The marketing medium (e.g. cpc, email) */ + utm_medium?: string; + + /** The specific campaign name */ + utm_campaign?: string; + + /** The paid keyword that triggered the ad */ + utm_term?: string; + + /** The campaign content used to differentiate ads */ + utm_content?: string; + + /** Google Ads device the click came from (c for computer, m for mobile, t for tablet) */ + device?: string; + + /** Google Ads network the click came from (e.g. g for Google Search, s for Search Partners, d for Display) */ + network?: string; + + /** Google Ads click ID */ + gclid?: string; + + /** Google Ads click ID for iOS app traffic, used when gclid isn't available */ + gbraid?: string; + + /** Google Ads click ID for iOS web traffic, used when gclid isn't available */ + wbraid?: string; +}; + +/** The attribution as persisted in Onyx. The capture time is only used locally to expire it and is never sent to the server. */ +type StoredMarketingAttribution = MarketingAttribution & { + /** When the attribution was captured from the landing URL, in ms since epoch */ + capturedAt?: number; +}; + +export default MarketingAttribution; +export type {StoredMarketingAttribution}; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 3507b4bd631e..4374bbe84539 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -336,6 +336,9 @@ type MergeConnectionLastSync = ConnectionLastSync & { /** Timestamps of the last few manual ("Sync now") syncs, used for blocking manual syncs client-side once the daily limit is reached */ manualSyncTimestamps?: string[]; + + /** If the connection's last sync failed because of its own settings, so the admin fixes the settings rather than reconnecting */ + isConfigurationError?: boolean; }; /** @@ -3125,6 +3128,9 @@ type MergeHRGroup = { type MergeHRConnectionData = { /** Groups available to import employees from. Distinct from `config.groups`, which is the admin's selection. */ groups?: MergeHRGroup[]; + + /** IDs of every group the HR system has, including ones missing a name/type and so absent from `groups`. Used to tell a deleted group apart from one that just can't render. */ + allGroupIDs?: string[]; }; /** Merge HR connection config */ diff --git a/src/types/onyx/TableColumnWidths.ts b/src/types/onyx/TableColumnWidths.ts new file mode 100644 index 000000000000..20a8be4812df --- /dev/null +++ b/src/types/onyx/TableColumnWidths.ts @@ -0,0 +1,8 @@ +/** User-dragged widths by column key. Absent columns are sized from content, so only touched columns get stored. */ +type ColumnWidthOverrides = Record; + +/** Dragged table column widths, keyed by `columnResizingID`. */ +type TableColumnWidths = Record; + +export default TableColumnWidths; +export type {ColumnWidthOverrides}; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index bd3775139863..55ecf1ec316e 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -118,6 +118,8 @@ import type Login from './Login'; import type {Login as NewLogin} from './Logins'; import type Logins from './Logins'; import type MapboxAccessToken from './MapboxAccessToken'; +import type MarketingAttribution from './MarketingAttribution'; +import type {StoredMarketingAttribution} from './MarketingAttribution'; import type MerchantRuleSuggestion from './MerchantRuleSuggestion'; import type MergeTransaction from './MergeTransaction'; import type Modal from './Modal'; @@ -205,6 +207,7 @@ import type StripeCustomerID from './StripeCustomerID'; import type SuggestedAgent from './SuggestedAgent'; import type SuggestedAgentRule from './SuggestedAgentRule'; import type SupportalPermissionDenied from './SupportalPermissionDenied'; +import type TableColumnWidths from './TableColumnWidths'; import type Task from './Task'; import type Transaction from './Transaction'; import type TransactionPending3DSReview from './TransactionPending3DSReview'; @@ -309,6 +312,8 @@ export type { PasskeyTransport, PendingContactAction, MapboxAccessToken, + MarketingAttribution, + StoredMarketingAttribution, Modal, Network, OnyxInputOrEntry, @@ -376,6 +381,7 @@ export type { DomainSecurityGroupMembership, SelectedTabRequest, Session, + TableColumnWidths, Task, TaxRate, TaxRates, diff --git a/tests/actions/IOUTest/PayMoneyRequestTest.ts b/tests/actions/IOUTest/PayMoneyRequestTest.ts index fd8b2e7243ef..fadb94342190 100644 --- a/tests/actions/IOUTest/PayMoneyRequestTest.ts +++ b/tests/actions/IOUTest/PayMoneyRequestTest.ts @@ -778,6 +778,64 @@ describe('actions/IOU/PayMoneyRequest', () => { mockFetch?.resume?.(); }); + it('puts the payment failure error on the chat report preview, not only inside the expense report', async () => { + // Given an outstanding IOU report that is previewed in the chat the payer is looking at + const chatReport = { + ...createRandomReport(20, undefined), + lastReadTime: DateUtils.getDBTime(), + lastVisibleActionCreated: DateUtils.getDBTime(), + }; + const iouReport = { + ...createRandomReport(21, undefined), + chatType: undefined, + type: CONST.REPORT.TYPE.IOU, + chatReportID: chatReport.reportID, + total: 10, + }; + const reportPreviewAction: ReportAction = { + ...createRandomReportAction(22), + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + originalMessage: {linkedReportID: iouReport.reportID}, + }; + const chatReportActions: ReportActions = {[reportPreviewAction.reportActionID]: reportPreviewAction}; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, chatReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, iouReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, chatReportActions); + await waitForBatchedUpdates(); + + // When the server rejects the payment, which is what happens to a payment queued offline against an + // expense the requestor deleted in the meantime + mockFetch?.fail?.(); + payMoneyRequest({ + isASAPSubmitBetaEnabled: false, + conciergeChat: undefined, + paymentType: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, + chatReport, + iouReport, + introSelected: undefined, + currentUserAccountID: CARLOS_ACCOUNT_ID, + currentUserLogin: CARLOS_EMAIL, + isSelfTourViewed: false, + userBillingGracePeriodEnds: undefined, + amountOwed: 0, + chatReportPolicy: chatReportPolicyFromChat(chatReport), + chatReportActions, + delegateAccountID: undefined, + isTrackIntentUser: false, + getCurrencyDecimals: getCurrencyDecimalsLocal, + rules: undefined, + }); + await waitForBatchedUpdates(); + + // Then the error is on the preview in the chat. The pay action also carries it, but that action lives in + // the expense report, which the payer can no longer reach once it has been deleted. + const updatedChatActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}` as const); + expect(updatedChatActions?.[reportPreviewAction.reportActionID]?.errors).toBeTruthy(); + + mockFetch?.succeed?.(); + }); + describe('delegateAccountID forwarding', () => { it('sets delegateAccountID on the pay IOU action when delegateAccountID is provided', async () => { const DELEGATE_ACCOUNT_ID = 999; diff --git a/tests/actions/MarketingAttributionTest.ts b/tests/actions/MarketingAttributionTest.ts new file mode 100644 index 000000000000..3e721da69e8c --- /dev/null +++ b/tests/actions/MarketingAttributionTest.ts @@ -0,0 +1,266 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useSaveMarketingAttribution from '@hooks/useSaveMarketingAttribution'; + +import {captureMarketingAttributionFromURL, getFreshMarketingAttribution, saveMarketingAttribution} from '@libs/actions/MarketingAttribution'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {StoredMarketingAttribution} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import React from 'react'; +/* eslint-disable @typescript-eslint/naming-convention */ +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const anyNumber: unknown = expect.any(Number); + +function setLandingURL(search: string) { + window.history.replaceState({}, '', `/${search}`); +} + +function getStoredAttribution(): Promise> { + return new Promise((resolve) => { + const connection = Onyx.connect({ + key: ONYXKEYS.MARKETING_ATTRIBUTION, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); +} + +describe('MarketingAttribution', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + afterAll(() => { + setLandingURL(''); + }); + + describe('captureMarketingAttributionFromURL', () => { + it('returns the UTM params, Google Ads params and click IDs from the landing URL', () => { + // Given a landing URL from a Google ad + setLandingURL( + '?utm_source=google&utm_medium=cpc&utm_campaign=123&utm_content=456-789&utm_term=expense%20app-e&device=m&network=g&gclid=testGclid&gbraid=testGbraid&wbraid=testWbraid', + ); + + // When the attribution is captured, then every param is returned under its request param name + expect(captureMarketingAttributionFromURL()).toEqual({ + utm_source: 'google', + utm_medium: 'cpc', + utm_campaign: '123', + utm_content: '456-789', + utm_term: 'expense app-e', + device: 'm', + network: 'g', + gclid: 'testGclid', + gbraid: 'testGbraid', + wbraid: 'testWbraid', + }); + }); + + it('ignores params that are not marketing attribution', () => { + // Given a landing URL with unrelated params + setLandingURL('?utm_source=reddit&exitTo=settings&fbclid=testFbclid'); + + // When the attribution is captured, then only the attribution param is returned + expect(captureMarketingAttributionFromURL()).toEqual({utm_source: 'reddit'}); + }); + + it('returns undefined when the URL carries no params', () => { + // Given a landing URL without attribution params + setLandingURL('?exitTo=settings'); + + // When the attribution is captured, then nothing is returned + expect(captureMarketingAttributionFromURL()).toBeUndefined(); + }); + + it('does not write to Onyx', async () => { + // Given a landing URL with UTM params + setLandingURL('?utm_source=google'); + + // When the attribution is captured + captureMarketingAttributionFromURL(); + await waitForBatchedUpdates(); + + // Then nothing is stored + expect(await getStoredAttribution()).toBeUndefined(); + }); + }); + + describe('saveMarketingAttribution', () => { + it('replaces the stored attribution instead of merging into it', async () => { + // Given attribution stored from an earlier Google ad click + await Onyx.set(ONYXKEYS.MARKETING_ATTRIBUTION, {utm_source: 'google', utm_medium: 'cpc', gclid: 'testGclid', device: 'm'}); + + // When attribution from a later Reddit ad is saved + saveMarketingAttribution({utm_source: 'reddit', utm_campaign: 'spring'}, false); + await waitForBatchedUpdates(); + + // Then only the Reddit values are kept, so values from the two clicks are not combined + expect(await getStoredAttribution()).toEqual({utm_source: 'reddit', utm_campaign: 'spring', capturedAt: anyNumber}); + }); + + it('keeps the stored attribution when nothing was captured', async () => { + // Given attribution stored from an earlier ad click + await Onyx.set(ONYXKEYS.MARKETING_ATTRIBUTION, {utm_source: 'google', gclid: 'testGclid'}); + + // When a load without attribution params is saved + saveMarketingAttribution(undefined, false); + await waitForBatchedUpdates(); + + // Then the stored attribution is kept + expect(await getStoredAttribution()).toEqual({utm_source: 'google', gclid: 'testGclid'}); + }); + + it('stamps the capture time on the saved attribution', async () => { + const before = Date.now(); + saveMarketingAttribution({utm_source: 'google'}, false); + await waitForBatchedUpdates(); + + const stored = await getStoredAttribution(); + expect(stored?.capturedAt).toBeGreaterThanOrEqual(before); + }); + + it('does nothing when the user has a session', async () => { + // When attribution is saved for a signed-in user + saveMarketingAttribution({utm_source: 'google'}, true); + await waitForBatchedUpdates(); + + // Then the key stays unset + expect(await getStoredAttribution()).toBeUndefined(); + }); + }); + + describe('getFreshMarketingAttribution', () => { + const day = 24 * 60 * 60 * 1000; + + it('returns fresh attribution without the capture time', async () => { + // Given attribution captured 89 days ago + await Onyx.set(ONYXKEYS.MARKETING_ATTRIBUTION, {utm_source: 'google', gclid: 'testGclid', capturedAt: Date.now() - 89 * day}); + + // Then it is returned for the request, without capturedAt, and stays stored + expect(getFreshMarketingAttribution(await getStoredAttribution())).toEqual({utm_source: 'google', gclid: 'testGclid'}); + await waitForBatchedUpdates(); + expect(await getStoredAttribution()).toBeDefined(); + }); + + it('ignores and clears attribution older than 90 days', async () => { + // Given attribution captured 91 days ago + await Onyx.set(ONYXKEYS.MARKETING_ATTRIBUTION, {utm_source: 'google', gclid: 'testGclid', capturedAt: Date.now() - 91 * day}); + + // Then nothing is returned and the key is cleared + expect(getFreshMarketingAttribution(await getStoredAttribution())).toBeUndefined(); + await waitForBatchedUpdates(); + expect(await getStoredAttribution()).toBeUndefined(); + }); + + it('treats legacy attribution stored without a capture time as expired', async () => { + await Onyx.set(ONYXKEYS.MARKETING_ATTRIBUTION, {utm_source: 'google', gclid: 'testGclid'}); + + expect(getFreshMarketingAttribution(await getStoredAttribution())).toBeUndefined(); + await waitForBatchedUpdates(); + expect(await getStoredAttribution()).toBeUndefined(); + }); + + it('returns undefined when nothing is stored', () => { + expect(getFreshMarketingAttribution(undefined)).toBeUndefined(); + }); + }); + + describe('useSaveMarketingAttribution', () => { + it('stores the attribution from the landing URL when the user has no session', async () => { + // Given no session and a landing URL with UTM params + setLandingURL('?utm_source=google&utm_medium=cpc'); + + // When the hook renders and the session has loaded + renderHook(() => useSaveMarketingAttribution()); + await waitForBatchedUpdatesWithAct(); + + // Then the attribution is written + expect(await getStoredAttribution()).toEqual({utm_source: 'google', utm_medium: 'cpc', capturedAt: anyNumber}); + }); + + it('does not store the attribution when the user already has a session', async () => { + // Given a signed-in user and a landing URL with UTM params + await Onyx.set(ONYXKEYS.SESSION, {authToken: 'testAuthToken', email: 'test@test.com'}); + setLandingURL('?utm_source=google&utm_medium=cpc'); + + // When the hook renders and the session has loaded + renderHook(() => useSaveMarketingAttribution()); + await waitForBatchedUpdatesWithAct(); + + // Then the key stays unset + expect(await getStoredAttribution()).toBeUndefined(); + }); + + it('stores the attribution when the user only has an anonymous session from viewing a public room', async () => { + // Given an anonymous session and a landing URL with Google Ads params + await Onyx.set(ONYXKEYS.SESSION, {authToken: 'anonymousAuthToken', authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS, email: 'anon-123@guest.expensify.com'}); + setLandingURL('?gclid=testGclid&utm_source=google'); + + // When the hook renders and the session has loaded + renderHook(() => useSaveMarketingAttribution()); + await waitForBatchedUpdatesWithAct(); + + // Then the attribution is written, since the anonymous user can still sign up from the room + expect(await getStoredAttribution()).toEqual({gclid: 'testGclid', utm_source: 'google', capturedAt: anyNumber}); + }); + + it('reads the URL on the first render only', async () => { + // Given a landing URL with UTM params that the router later strips + setLandingURL('?utm_source=google'); + const {rerender} = renderHook(() => useSaveMarketingAttribution()); + setLandingURL(''); + rerender({}); + await waitForBatchedUpdatesWithAct(); + + // Then the attribution from the landing URL is stored + expect(await getStoredAttribution()).toEqual({utm_source: 'google', capturedAt: anyNumber}); + }); + + it('saves the attribution only once, even after the session changes', async () => { + // Given attribution saved on landing + setLandingURL('?utm_source=google'); + renderHook(() => useSaveMarketingAttribution()); + await waitForBatchedUpdatesWithAct(); + + // When signup clears it and the user later signs in and out + await act(async () => { + await Onyx.set(ONYXKEYS.MARKETING_ATTRIBUTION, null); + await Onyx.set(ONYXKEYS.SESSION, {authToken: 'testAuthToken'}); + }); + await act(async () => { + await Onyx.set(ONYXKEYS.SESSION, {}); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the attribution is not written again + expect(await getStoredAttribution()).toBeUndefined(); + }); + + it('works under StrictMode', async () => { + // Given a landing URL with UTM params + setLandingURL('?utm_source=google&gclid=testGclid'); + + // When the hook renders under StrictMode, which double-invokes initializers and effects + renderHook(() => useSaveMarketingAttribution(), {wrapper: React.StrictMode}); + await waitForBatchedUpdatesWithAct(); + + // Then the attribution is stored + expect(await getStoredAttribution()).toEqual({utm_source: 'google', gclid: 'testGclid', capturedAt: anyNumber}); + }); + }); +}); diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index 7e01cf80ef93..8b0692260434 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -733,6 +733,61 @@ describe('actions/Policy', () => { await waitForBatchedUpdates(); }); + it('duplicate workspace without the rules and codingRules options copies no merchant rules', async () => { + // Given a source workspace with a merchant rule + const fakePolicy = createRandomPolicy(16, CONST.POLICY.TYPE.CORPORATE); + const sourceRule: Rule = { + scope: CONST.RULES.SCOPE.POLICY, + scopeID: fakePolicy.id, + triggers: indexMap(CONST.RULES.TRIGGERS.CREATE_TRANSACTION), + filters: {left: CONST.RULES.EXPENSE_DEFAULT.FIELD.MERCHANT, operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, right: 'Acme'}, + actions: indexMap({name: CONST.RULES.ACTIONS.SET, field: CONST.RULES.EXPENSE_DEFAULT.FIELD.CATEGORY, value: 'Travel'}), + }; + // The request stays paused so the assertions read the optimistic data, which is where a wrongly copied rule would appear. + mockFetch?.pause?.(); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); + await waitForBatchedUpdates(); + + const policyID = Policy.generatePolicyID(); + + // When the workspace is duplicated with both the Rules and Merchant rules options unselected + Policy.duplicateWorkspace(fakePolicy, { + currentUserAccountID: ESH_ACCOUNT_ID, + currentUserEmail: ESH_EMAIL, + policyName: 'No Rules Workspace', + policyID: fakePolicy.id, + targetPolicyID: policyID, + welcomeNote: 'Join my policy', + parts: { + people: false, + reports: false, + connections: false, + categories: false, + tags: false, + taxes: false, + perDiem: false, + reimbursements: false, + expenses: false, + distance: false, + invoices: false, + exportLayouts: false, + codingRules: false, + }, + localCurrency: 'USD', + rules: {[`${ONYXKEYS.COLLECTION.RULE}sourceRule`]: sourceRule}, + }); + await waitForBatchedUpdates(); + + // Then no merchant rule is scoped to the duplicate and the Rules feature stays off, because the admin chose not to copy either + const duplicatedRules = Object.values((await getRulesCollection()) ?? {}).filter((rule) => rule?.scopeID === policyID); + expect(duplicatedRules).toHaveLength(0); + const policy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + expect(policy?.areRulesEnabled).toBe(false); + + await mockFetch?.resume?.(); + await waitForBatchedUpdates(); + }); + it('duplicate workspace with 3+ members creates optimistic announce chat using currentUserAccountID', async () => { await Onyx.set(ONYXKEYS.SESSION, {email: ESH_EMAIL, accountID: ESH_ACCOUNT_ID}); const basePolicy = createRandomPolicy(16, CONST.POLICY.TYPE.TEAM); diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index dd21ee8098b7..7a6a36a460fd 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -10696,6 +10696,115 @@ describe('actions/Report', () => { emailList: `${TEST_USER_LOGIN},passed@test.com`, }); }); + + it('should remove the optimistic thread instead of keeping a createChat error when OpenReport fails', async () => { + // Given an IOU report with an expense action that the user has no thread for yet + const mockFetch = TestHelper.createGlobalFetchMock(); + global.fetch = mockFetch; + + const parentReport: OnyxTypes.Report = { + ...createRandomReport(700, undefined), + reportID: '700', + type: CONST.REPORT.TYPE.IOU, + }; + const reportAction: OnyxTypes.ReportAction = { + ...createRandomReportAction(7), + reportActionID: 'action-700', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${parentReport.reportID}`, parentReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReport.reportID}`, {[reportAction.reportActionID]: reportAction}); + await waitForBatchedUpdates(); + + // When the thread is created while the OpenReport request is still in flight + mockFetch.pause(); + const result = Report.createTransactionThreadReport({ + introSelected: TEST_INTRO_SELECTED, + conciergeChat: undefined, + currentUserLogin: TEST_USER_LOGIN, + currentUserAccountID: TEST_USER_ACCOUNT_ID, + personalDetails: undefined, + iouReport: parentReport, + iouReportAction: reportAction, + }); + await waitForBatchedUpdates(); + + if (!result) { + throw new Error('Expected a transaction thread report to be created'); + } + + // Then the thread exists optimistically and the parent action points at it + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${result.reportID}` as const)).toBeTruthy(); + expect((await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReport.reportID}` as const))?.[reportAction.reportActionID]?.childReportID).toBe(result.reportID); + + // When OpenReport then fails, e.g. because the expense was deleted while the request sat in the offline queue + mockFetch.fail(); + await mockFetch.resume(); + await waitForNetworkPromises(); + await waitForBatchedUpdates(); + + // Then the thread is rolled back rather than left in Onyx with a createChat error the user cannot dismiss, + // which is what force-displayed it in the LHN as an empty row with a "Fix" badge + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${result.reportID}` as const)).toBeFalsy(); + // The optimistic CREATED action goes, but the key is merged rather than nulled so anything the user + // queued in the thread, e.g. an offline comment, is not destroyed with it. + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${result.reportID}` as const)).toEqual({}); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${result.reportID}` as const)).toBeFalsy(); + + // And the parent action no longer links to a report that does not exist + const parentActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReport.reportID}` as const); + expect(parentActions?.[reportAction.reportActionID]?.childReportID).toBeUndefined(); + + mockFetch.succeed(); + }); + }); + + describe('openReport', () => { + const TEST_USER_ACCOUNT_ID = 1; + const TEST_USER_LOGIN = 'test@test.com'; + + beforeEach(async () => { + global.fetch = TestHelper.createGlobalFetchMock(); + await TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN); + await waitForBatchedUpdates(); + }); + + it('should keep a user-initiated new chat with a createChat error when OpenReport fails', async () => { + // Given a chat the user themselves asked to create, so the error is actionable and must be kept + const mockFetch = TestHelper.createGlobalFetchMock(); + global.fetch = mockFetch; + + const REPORT_ID = '710'; + const newReportObject: OnyxTypes.Report = { + ...createRandomReport(710, undefined), + reportID: REPORT_ID, + parentReportID: undefined, + parentReportActionID: undefined, + }; + + // When OpenReport fails for that create + mockFetch.fail(); + Report.openReport({ + reportID: REPORT_ID, + introSelected: TEST_INTRO_SELECTED, + conciergeChat: undefined, + participants: [{login: 'other@test.com', accountID: 2}], + newReportObject, + currentUserLogin: TEST_USER_LOGIN, + currentUserAccountID: TEST_USER_ACCOUNT_ID, + hasReportActions: false, + }); + await waitForNetworkPromises(); + await waitForBatchedUpdates(); + + // Then the report is still there and shows the retryable error, unchanged from before this fix + const report = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}` as const); + expect(report).toBeTruthy(); + expect(report?.errorFields?.createChat).toBeTruthy(); + + mockFetch.succeed(); + }); }); describe('resolveActionableMentionWhisper', () => { diff --git a/tests/actions/SessionTest.ts b/tests/actions/SessionTest.ts index e57328263a1c..104afa04df90 100644 --- a/tests/actions/SessionTest.ts +++ b/tests/actions/SessionTest.ts @@ -7,7 +7,7 @@ import OnyxUpdateManager from '@libs/actions/OnyxUpdateManager'; import {getAll as getAllPersistedRequests} from '@libs/actions/PersistedRequests'; import {initReconnect} from '@libs/actions/Reconnect'; import * as SignInRedirect from '@libs/actions/SignInRedirect'; -import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import asyncOpenURL from '@libs/asyncOpenURL'; import buildOldDotURL from '@libs/buildOldDotURL'; import getPlatform from '@libs/getPlatform'; @@ -29,7 +29,8 @@ import * as SessionUtil from '@src/libs/actions/Session'; import {KEYS_TO_PRESERVE_SUPPORTAL, signOutAndRedirectToSignIn} from '@src/libs/actions/Session'; import * as API from '@src/libs/API'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Account, Credentials, Session} from '@src/types/onyx'; +import type {Account, Credentials, MarketingAttribution, Session} from '@src/types/onyx'; +import type {StoredMarketingAttribution} from '@src/types/onyx/MarketingAttribution'; import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; @@ -891,7 +892,7 @@ describe('Session', () => { callback: (val) => (session = val), }); - SessionUtil.signInWithShortLivedAuthToken('testAuthToken', true, undefined, undefined); + SessionUtil.signInWithShortLivedAuthToken('testAuthToken', undefined, true, undefined, undefined); await waitForBatchedUpdates(); expect(session?.signedInWithSAML).toBe(true); @@ -904,41 +905,52 @@ describe('Session', () => { callback: (val) => (session = val), }); - SessionUtil.signInWithShortLivedAuthToken('testAuthToken', false, undefined, undefined); + SessionUtil.signInWithShortLivedAuthToken('testAuthToken', undefined, false, undefined, undefined); await waitForBatchedUpdates(); expect(session?.signedInWithSAML).toBe(false); }); test('signInWithShortLivedAuthToken sends the token to the API with the SAML auth method', async () => { - const readSpy = jest.spyOn(API, 'read').mockImplementation(() => {}); + const makeRequestSpy = jest.spyOn(API, 'makeRequestWithSideEffects').mockResolvedValue(undefined); - SessionUtil.signInWithShortLivedAuthToken('testAuthToken', true, undefined, undefined); + SessionUtil.signInWithShortLivedAuthToken('testAuthToken', undefined, true, undefined, undefined); await waitForBatchedUpdates(); - const call = readSpy.mock.calls.at(0); - expect(call?.at(0)).toBe(READ_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN); + const call = makeRequestSpy.mock.calls.at(0); + expect(call?.at(0)).toBe(SIDE_EFFECT_REQUEST_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN); expect(call?.at(1)).toEqual(expect.objectContaining({authToken: 'testAuthToken', authMethod: CONST.AUTH_METHOD.SAML, skipReauthentication: true})); - readSpy.mockRestore(); + makeRequestSpy.mockRestore(); }); test('signInWithShortLivedAuthToken sends the token to the API with the short-lived-token auth method when it is not a SAML sign in', async () => { - const readSpy = jest.spyOn(API, 'read').mockImplementation(() => {}); + const makeRequestSpy = jest.spyOn(API, 'makeRequestWithSideEffects').mockResolvedValue(undefined); - SessionUtil.signInWithShortLivedAuthToken('testAuthToken', false, undefined, undefined); + SessionUtil.signInWithShortLivedAuthToken('testAuthToken', undefined, false, undefined, undefined); await waitForBatchedUpdates(); - expect(readSpy.mock.calls.at(0)?.at(1)).toEqual(expect.objectContaining({authToken: 'testAuthToken', authMethod: CONST.AUTH_METHOD.SHORT_LIVED_AUTH_TOKEN})); + expect(makeRequestSpy.mock.calls.at(0)?.at(1)).toEqual(expect.objectContaining({authToken: 'testAuthToken', authMethod: CONST.AUTH_METHOD.SHORT_LIVED_AUTH_TOKEN})); - readSpy.mockRestore(); + makeRequestSpy.mockRestore(); + }); + + test('signInWithShortLivedAuthToken sends the current session authToken to the API as currentAuthToken', async () => { + const makeRequestSpy = jest.spyOn(API, 'makeRequestWithSideEffects').mockResolvedValue(undefined); + + SessionUtil.signInWithShortLivedAuthToken('testAuthToken', 'existingSessionAuthToken', false, undefined, undefined); + await waitForBatchedUpdates(); + + expect(makeRequestSpy.mock.calls.at(0)?.at(1)).toEqual(expect.objectContaining({authToken: 'testAuthToken', currentAuthToken: 'existingSessionAuthToken'})); + + makeRequestSpy.mockRestore(); }); test('signInWithShortLivedAuthToken does not wait on navigation when no exitTo is passed', async () => { const waitForProtectedRoutesSpy = jest.spyOn(Navigation, 'waitForProtectedRoutes').mockResolvedValue(undefined); const resetRootSpy = jest.spyOn(navigationRef, 'resetRoot').mockImplementation(() => {}); - SessionUtil.signInWithShortLivedAuthToken('testAuthToken', true, undefined, 'user@saml.example.com'); + SessionUtil.signInWithShortLivedAuthToken('testAuthToken', undefined, true, undefined, 'user@saml.example.com'); await waitForBatchedUpdates(); expect(waitForProtectedRoutesSpy).not.toHaveBeenCalled(); @@ -953,7 +965,7 @@ describe('Session', () => { jest.spyOn(Navigation, 'waitForProtectedRoutes').mockResolvedValue(undefined); const resetRootSpy = jest.spyOn(navigationRef, 'resetRoot').mockImplementation(() => {}); - SessionUtil.signInWithShortLivedAuthToken('testAuthToken', true, '/search?q=status:outstanding', 'user@saml.example.com'); + SessionUtil.signInWithShortLivedAuthToken('testAuthToken', undefined, true, '/search?q=status:outstanding', 'user@saml.example.com'); await waitForBatchedUpdates(); expect(resetRootSpy).toHaveBeenCalledTimes(1); @@ -971,7 +983,7 @@ describe('Session', () => { jest.spyOn(Navigation, 'waitForProtectedRoutes').mockResolvedValue(undefined); const resetRootSpy = jest.spyOn(navigationRef, 'resetRoot').mockImplementation(() => {}); - SessionUtil.signInWithShortLivedAuthToken('testAuthToken', true, '/search?q=status:outstanding', undefined); + SessionUtil.signInWithShortLivedAuthToken('testAuthToken', undefined, true, '/search?q=status:outstanding', undefined); await waitForBatchedUpdates(); expect(resetRootSpy).not.toHaveBeenCalled(); @@ -984,7 +996,7 @@ describe('Session', () => { jest.spyOn(Navigation, 'waitForProtectedRoutes').mockResolvedValue(undefined); const resetRootSpy = jest.spyOn(navigationRef, 'resetRoot').mockImplementation(() => {}); - SessionUtil.signInWithShortLivedAuthToken('testAuthToken', true, '/search?q=status:outstanding', 'user@saml.example.com'); + SessionUtil.signInWithShortLivedAuthToken('testAuthToken', undefined, true, '/search?q=status:outstanding', 'user@saml.example.com'); await waitForBatchedUpdates(); expect(resetRootSpy).not.toHaveBeenCalled(); @@ -1451,4 +1463,98 @@ describe('Session', () => { writeSpy.mockRestore(); }); }); + + describe('signUpUser', () => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const googleAdAttribution: MarketingAttribution = {utm_source: 'google', device: 'm', network: 'g', gclid: 'testGclid', wbraid: 'testWbraid'}; + const storedAttribution: StoredMarketingAttribution = {...googleAdAttribution, capturedAt: Date.now()}; + + test('sends the captured marketing attribution and clears it on success', async () => { + // Given marketing attribution captured from a Google ad + const writeSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); + + // When the user signs up with it + SessionUtil.signUpUser('new.user@example.com', CONST.LOCALES.EN, undefined, storedAttribution); + await waitForBatchedUpdates(); + + // Then the attribution is sent with the request params + expect(writeSpy).toHaveBeenCalledWith(WRITE_COMMANDS.SIGN_UP_USER, expect.objectContaining({email: 'new.user@example.com', ...googleAdAttribution}), expect.anything()); + expect(writeSpy.mock.calls.at(0)?.[1]).not.toHaveProperty('capturedAt'); + + // And the success data clears it so it isn't sent again + const onyxData = writeSpy.mock.calls.at(0)?.[2]; + expect(onyxData?.successData).toContainEqual({onyxMethod: Onyx.METHOD.SET, key: ONYXKEYS.MARKETING_ATTRIBUTION, value: null}); + writeSpy.mockRestore(); + }); + + test('sends no attribution params when the stored attribution is expired or has no capture time', async () => { + const writeSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); + + SessionUtil.signUpUser('a@example.com', CONST.LOCALES.EN, undefined, {...googleAdAttribution, capturedAt: Date.now() - 91 * 24 * 60 * 60 * 1000}); + SessionUtil.signUpUser('b@example.com', CONST.LOCALES.EN, undefined, googleAdAttribution); + await waitForBatchedUpdates(); + + expect(writeSpy).toHaveBeenCalledTimes(2); + for (const [, params] of writeSpy.mock.calls) { + expect(Object.keys(params ?? {}).sort()).toEqual(['deviceInfo', 'email', 'preferredLocale']); + } + writeSpy.mockRestore(); + }); + + test('sends no attribution params when none were captured', async () => { + // Given no captured marketing attribution + const writeSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); + + // When the user signs up + SessionUtil.signUpUser('new.user@example.com', CONST.LOCALES.EN); + await waitForBatchedUpdates(); + + // Then the request carries only the signup params + const params = writeSpy.mock.calls.at(0)?.[1] ?? {}; + expect(Object.keys(params).sort()).toEqual(['deviceInfo', 'email', 'preferredLocale']); + writeSpy.mockRestore(); + }); + }); + + describe.each([ + {name: 'beginGoogleSignIn', command: WRITE_COMMANDS.SIGN_IN_WITH_GOOGLE, beginSignIn: SessionUtil.beginGoogleSignIn, tokenParam: 'token'}, + {name: 'beginAppleSignIn', command: WRITE_COMMANDS.SIGN_IN_WITH_APPLE, beginSignIn: SessionUtil.beginAppleSignIn, tokenParam: 'idToken'}, + ])('$name', ({command, beginSignIn, tokenParam}) => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const googleAdAttribution: MarketingAttribution = {utm_source: 'google', gclid: 'testGclid'}; + + test('sends the captured marketing attribution and clears it on success', async () => { + // Given marketing attribution captured from a Google ad + const writeSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); + + // When the user signs in with the third party, which can create a new account + beginSignIn('testToken', CONST.LOCALES.EN, {...googleAdAttribution, capturedAt: Date.now()}); + await waitForBatchedUpdates(); + + // Then the attribution is sent with the request params + expect(writeSpy).toHaveBeenCalledWith(command, expect.objectContaining({[tokenParam]: 'testToken', ...googleAdAttribution}), expect.anything()); + expect(writeSpy.mock.calls.at(0)?.[1]).not.toHaveProperty('capturedAt'); + + // And the success data clears it, while keeping the usual sign in success data + const onyxData = writeSpy.mock.calls.at(0)?.[2]; + expect(onyxData?.successData).toContainEqual({onyxMethod: Onyx.METHOD.SET, key: ONYXKEYS.MARKETING_ATTRIBUTION, value: null}); + expect(onyxData?.successData).toContainEqual(expect.objectContaining({key: ONYXKEYS.ACCOUNT})); + expect(onyxData?.failureData).not.toContainEqual(expect.objectContaining({key: ONYXKEYS.MARKETING_ATTRIBUTION})); + writeSpy.mockRestore(); + }); + + test('sends no attribution params when none were captured', async () => { + // Given no captured marketing attribution + const writeSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); + + // When the user signs in with the third party + beginSignIn('testToken', CONST.LOCALES.EN); + await waitForBatchedUpdates(); + + // Then the request carries only the sign in params + const params = writeSpy.mock.calls.at(0)?.[1] ?? {}; + expect(Object.keys(params).sort()).toEqual(['deviceInfo', 'preferredLocale', tokenParam].sort()); + writeSpy.mockRestore(); + }); + }); }); diff --git a/tests/actions/WorkflowTest.ts b/tests/actions/WorkflowTest.ts index bed7f571639d..7b3840c4ed93 100644 --- a/tests/actions/WorkflowTest.ts +++ b/tests/actions/WorkflowTest.ts @@ -15,6 +15,7 @@ import { setApprovalWorkflowApprover, updateApprovalWorkflow, updateApprovalWorkflowRules, + validateApprovalWorkflow, } from '@src/libs/actions/Workflow'; import {isApprovalWorkflowRule} from '@src/libs/RuleUtils'; import { @@ -190,7 +191,105 @@ describe('actions/Workflow', () => { }); }); + describe('validateApprovalWorkflow', () => { + const removedApprover: Approver = {email: 'removed@example.com', displayName: 'removed@example.com', isNotWorkspaceMember: true}; + + it('should block saving a workflow whose approver is no longer a workspace member', async () => { + // Given a custom workflow whose only approver was removed from the workspace + const approvalWorkflow: ApprovalWorkflowOnyx = { + ...INITIAL_APPROVAL_WORKFLOW, + members: [{email: employee1Email, displayName: employee1Email}], + approvers: [removedApprover], + }; + await Onyx.set(ONYXKEYS.APPROVAL_WORKFLOW, approvalWorkflow); + + // When the admin tries to save it + const isValid = validateApprovalWorkflow(approvalWorkflow); + await waitForBatchedUpdates(); + + // Then the save is blocked and the approver shows the error offering to replace them or delete the workflow + expect(isValid).toBe(false); + const workflowState = await getOnyxValue(ONYXKEYS.APPROVAL_WORKFLOW); + expect(workflowState?.errors?.['approver-0']).toBe('workflowsPage.approverNotWorkspaceMember'); + }); + + it('should not offer deleting the default workflow when its approver is no longer a workspace member', async () => { + // Given the default workflow, whose approver was removed from the workspace + const approvalWorkflow: ApprovalWorkflowOnyx = { + ...INITIAL_APPROVAL_WORKFLOW, + isDefault: true, + approvers: [removedApprover], + }; + await Onyx.set(ONYXKEYS.APPROVAL_WORKFLOW, approvalWorkflow); + + // When the admin tries to save it + const isValid = validateApprovalWorkflow(approvalWorkflow); + await waitForBatchedUpdates(); + + // Then the save is blocked, and since the default workflow can't be deleted the error only asks for a new approver + expect(isValid).toBe(false); + const workflowState = await getOnyxValue(ONYXKEYS.APPROVAL_WORKFLOW); + expect(workflowState?.errors?.['approver-0']).toBe('workflowsPage.defaultWorkflowApproverNotWorkspaceMember'); + }); + + it('should block saving a workflow whose additional approver for reports over the limit is no longer a workspace member', async () => { + // Given a workflow whose approver is a member, but whose reports over the limit go to someone who was removed + const approvalWorkflow: ApprovalWorkflowOnyx = { + ...INITIAL_APPROVAL_WORKFLOW, + members: [{email: employee1Email, displayName: employee1Email}], + approvers: [{email: ownerEmail, displayName: ownerEmail, approvalLimit: 1000, overLimitForwardsTo: 'removed@example.com', isOverLimitForwardsToNotWorkspaceMember: true}], + }; + await Onyx.set(ONYXKEYS.APPROVAL_WORKFLOW, approvalWorkflow); + + // When the admin tries to save it + const isValid = validateApprovalWorkflow(approvalWorkflow); + await waitForBatchedUpdates(); + + // Then the save is blocked and the approver shows the error asking for a new additional approver + expect(isValid).toBe(false); + const workflowState = await getOnyxValue(ONYXKEYS.APPROVAL_WORKFLOW); + expect(workflowState?.errors?.['approver-0']).toBe('workflowsPage.overLimitApproverNotWorkspaceMember'); + }); + }); + describe('setApprovalWorkflowApprover', () => { + it('should clear the over-limit flag once a member is picked as the additional approver', async () => { + // Given an approver whose reports over the limit go to someone no longer on the workspace + const removedEmail = 'removed@example.com'; + const flaggedApprover: Approver = { + email: ownerEmail, + displayName: ownerEmail, + approvalLimit: 1000, + overLimitForwardsTo: removedEmail, + isOverLimitForwardsToNotWorkspaceMember: true, + }; + const currentApprovalWorkflow: ApprovalWorkflowOnyx = {...INITIAL_APPROVAL_WORKFLOW, approvers: [flaggedApprover]}; + await Onyx.set(ONYXKEYS.APPROVAL_WORKFLOW, currentApprovalWorkflow); + const policy = createMock({ + id: generatePolicyID(), + owner: ownerEmail, + employeeList: { + [ownerEmail]: {email: ownerEmail, role: 'admin', submitsTo: ownerEmail}, + [employee1Email]: {email: employee1Email, role: 'user', submitsTo: ownerEmail}, + }, + }); + + // When the admin picks a member as the new additional approver + setApprovalWorkflowApprover({ + approver: {...flaggedApprover, overLimitForwardsTo: employee1Email}, + approverIndex: 0, + policy, + currentApprovalWorkflow, + personalDetailsByEmail: {}, + }); + await waitForBatchedUpdates(); + + // Then the approver is no longer flagged, so the workflow can be saved + const approvalWorkflow = await getApprovalWorkflowState(); + expect(approvalWorkflow?.approvers.at(0)?.overLimitForwardsTo).toBe(employee1Email); + expect(approvalWorkflow?.approvers.at(0)?.isOverLimitForwardsToNotWorkspaceMember).toBeFalsy(); + }); + it('should add an approver at an empty index', async () => { mockFetch.pause(); @@ -779,6 +878,59 @@ describe('actions/Workflow', () => { await mockFetch.resume(); await waitForBatchedUpdates(); }); + + it('should drop to BASIC mode without sending the non-member when a workflow whose approver left the workspace is deleted', async () => { + mockFetch.pause(); + + // Given an ADVANCED policy where employee3 still submits to someone no longer on the workspace + const removedApproverEmail = 'removed@gmail.com'; + const policy = createMock({ + id: '123456789', + name: 'Test Workspace', + role: 'admin', + type: 'corporate', + owner: ownerEmail, + approver: ownerEmail, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + employeeList: { + [ownerEmail]: {email: ownerEmail, forwardsTo: '', role: 'admin', submitsTo: ownerEmail}, + [employee1Email]: {email: employee1Email, forwardsTo: '', role: 'user', submitsTo: ownerEmail}, + [employee3Email]: {email: employee3Email, forwardsTo: '', role: 'user', submitsTo: removedApproverEmail}, + }, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await Onyx.merge(ONYXKEYS.SESSION, {authToken: '123456789'}); + await waitForBatchedUpdates(); + + // When the admin deletes the flagged workflow shown on the Workflows page + const {approvalWorkflows} = convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails: {}, localeCompare: (a: string, b: string) => a.localeCompare(b)}); + const brokenWorkflow = approvalWorkflows.find((workflow) => workflow.approvers.at(0)?.isNotWorkspaceMember); + expect(brokenWorkflow?.members.map((member) => member.email)).toEqual([employee3Email]); + if (!brokenWorkflow) { + return; + } + removeApprovalWorkflow(brokenWorkflow, policy); + await waitForBatchedUpdates(); + + // Then the workspace drops to BASIC, the member's `submitsTo` is cleared so the backend sends them to the + // default approver, and the request carries no entry for the non-member approver + const updatedPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); + expect(updatedPolicy?.approvalMode).toBe(CONST.POLICY.APPROVAL_MODE.BASIC); + expect(updatedPolicy?.employeeList?.[employee3Email]?.submitsTo).toBe(''); + expect(updatedPolicy?.employeeList?.[removedApproverEmail]).toBeUndefined(); + + const requestBody = getFetchMockCalls(WRITE_COMMANDS.REMOVE_WORKSPACE_APPROVAL).at(0)?.[1]?.body; + const sentEmployees = requestBody instanceof FormData ? requestBody.get('employees') : undefined; + expect(typeof sentEmployees === 'string' ? sentEmployees : '').toContain(employee3Email); + expect(typeof sentEmployees === 'string' ? sentEmployees : '').not.toContain(removedApproverEmail); + + // And once the request succeeds, the member is no longer shown as pending + await mockFetch.resume(); + await waitForBatchedUpdates(); + const settledPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); + expect(settledPolicy?.employeeList?.[employee3Email]?.pendingAction).toBeFalsy(); + expect(settledPolicy?.employeeList?.[employee3Email]?.pendingFields).toBeFalsy(); + }); }); describe('updateApprovalWorkflow', () => { diff --git a/tests/perf-test/DynamicReportDetailsPage.perf-test.tsx b/tests/perf-test/DynamicReportDetailsPage.perf-test.tsx index a52ff68961b5..9fac18b9d013 100644 --- a/tests/perf-test/DynamicReportDetailsPage.perf-test.tsx +++ b/tests/perf-test/DynamicReportDetailsPage.perf-test.tsx @@ -206,16 +206,10 @@ function Wrapper({children}: {children: React.ReactNode}) { return {children}; } -// withReportOrNotFound reads the report from Onyx by the route reportID, so the report prop only satisfies the wrapped props type. function renderPage(report: Report) { return ( ); diff --git a/tests/ui/AuthScreensInitHandlerTest.tsx b/tests/ui/AuthScreensInitHandlerTest.tsx index bf024aebff8c..8468adea42b4 100644 --- a/tests/ui/AuthScreensInitHandlerTest.tsx +++ b/tests/ui/AuthScreensInitHandlerTest.tsx @@ -266,7 +266,20 @@ describe('AuthScreensInitHandler', () => { expect(mockedSubscribeToUserEvents).not.toHaveBeenCalled(); }); - it('signs out when logging in as new user during transition', async () => { + it('signs out when logging in as a new user via a supportal transition', async () => { + mockedGetCurrentUrl.mockReturnValue(`https://new.expensify.com/${ROUTES.TRANSITION_BETWEEN_APPS}?authTokenType=${CONST.AUTH_TOKEN_TYPES.SUPPORT}`); + mockedIsLoggingInAsNewUser.mockReturnValue(true); + + await Onyx.merge(ONYXKEYS.SESSION, {accountID: TEST_ACCOUNT_ID, email: 'test@test.com'}); + await waitForBatchedUpdates(); + + renderAuthScreensInitHandler(); + await waitForBatchedUpdatesWithAct(); + + expect(signOutAndRedirectToSignIn).toHaveBeenCalledWith(false, true, true, undefined, CONST.SIGN_OUT_REASON.LOGIN_AS_NEW_USER); + }); + + it('does not sign out for a non-supportal transition', async () => { mockedGetCurrentUrl.mockReturnValue(`https://new.expensify.com/${ROUTES.TRANSITION_BETWEEN_APPS}`); mockedIsLoggingInAsNewUser.mockReturnValue(true); @@ -276,7 +289,8 @@ describe('AuthScreensInitHandler', () => { renderAuthScreensInitHandler(); await waitForBatchedUpdatesWithAct(); - expect(signOutAndRedirectToSignIn).toHaveBeenCalledWith(false, false, true, undefined, CONST.SIGN_OUT_REASON.LOGIN_AS_NEW_USER); + // LogOutPreviousUserPage owns this case, gated behind its own confirm modal. + expect(signOutAndRedirectToSignIn).not.toHaveBeenCalled(); }); it('calls openApp when didUserLogInDuringSession returns true', async () => { diff --git a/tests/ui/DynamicIOURequestStepUpgradeTest.tsx b/tests/ui/DynamicIOURequestStepUpgradeTest.tsx new file mode 100644 index 000000000000..22b2b762de61 --- /dev/null +++ b/tests/ui/DynamicIOURequestStepUpgradeTest.tsx @@ -0,0 +1,161 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import {CurrencyListContextProvider} from '@components/CurrencyListContextProvider'; +import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import type {WorkspaceConfirmationSubmitFunctionParams} from '@components/WorkspaceConfirmationForm'; + +import Navigation from '@libs/Navigation/Navigation'; + +import DynamicIOURequestStepUpgrade from '@pages/iou/request/step/DynamicIOURequestStepUpgrade'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; + +import type * as NativeNavigation from '@react-navigation/native'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import {signInWithTestUser} from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@hooks/useConfirmModal', () => () => ({showConfirmModal: jest.fn()})); + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + goBack: jest.fn(), + getActiveRoute: jest.fn(() => ''), + getActiveRouteWithoutParams: jest.fn(() => ''), + isNavigationReady: jest.fn(() => Promise.resolve()), + setNavigationActionToMicrotaskQueue: jest.fn((callback: () => void) => callback()), +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: jest.fn(() => ({navigate: jest.fn(), addListener: jest.fn(() => jest.fn())})), + useIsFocused: jest.fn(() => true), + useFocusEffect: jest.fn(), + useRoute: jest.fn(() => ({key: '', name: '', params: {}})), + usePreventRemove: jest.fn(), +})); + +jest.mock('@src/libs/actions/Policy/Policy', () => ({ + ...jest.requireActual>('@src/libs/actions/Policy/Policy'), + createWorkspace: jest.fn(() => ({policyID: 'newPolicyID', policyName: 'Workspace'})), +})); + +// Each step of the upgrade is reduced to the one button that moves it forward. +function mockStepButton(label: string, onPress: () => void) { + const {Pressable, Text} = jest.requireActual void; role?: string}>>>('react-native'); + return ( + + {label} + + ); +} +jest.mock( + '@pages/workspace/upgrade/UpgradeIntro', + () => + ({onUpgrade}: {onUpgrade: () => void}) => + mockStepButton('upgrade', onUpgrade), +); +jest.mock( + '@components/WorkspaceConfirmationForm', + () => + ({onSubmit}: {onSubmit: (params: WorkspaceConfirmationSubmitFunctionParams) => void}) => + mockStepButton('confirm', () => onSubmit({name: 'Workspace', currency: 'USD', makeMeAdmin: false, avatarFile: undefined, policyID: 'newPolicyID'})), +); +jest.mock( + '@pages/workspace/upgrade/UpgradeConfirmation', + () => + ({afterUpgradeAcknowledged}: {afterUpgradeAcknowledged: () => void}) => + mockStepButton('gotIt', afterUpgradeAcknowledged), +); + +const TRANSACTION_ID = 'transaction-1'; +const REPORT_ID = 'report-1'; + +const renderUpgradeStep = (shouldReturnToConfirmation: boolean) => + render( + + + + + + + + + , + ); + +const upgradeAndAcknowledge = async () => { + fireEvent.press(screen.getByText('upgrade')); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByText('confirm')); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByText('gotIt')); + await waitForBatchedUpdatesWithAct(); +}; + +describe('DynamicIOURequestStepUpgrade', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await signInWithTestUser(1, 'test@user.com'); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + }); + + it('only goes back to the confirmation form after a category upgrade that asked to return there', async () => { + // Given the category upgrade, opened from a confirmation form that opens its own category list + renderUpgradeStep(true); + + // When the user upgrades and acknowledges it + await upgradeAndAcknowledge(); + + // Then the upgrade screen closes without pushing the full-page Category step, so the form can open its list in place + expect(Navigation.goBack).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).not.toHaveBeenCalled(); + expect(Navigation.setNavigationActionToMicrotaskQueue).not.toHaveBeenCalled(); + }); + + it('opens the full-page Category step after a category upgrade that did not ask to return', async () => { + // Given the category upgrade, opened from a place that relies on the full-page Category step + renderUpgradeStep(false); + + // When the user upgrades and acknowledges it + await upgradeAndAcknowledge(); + + // Then the upgrade screen closes and the Category step opens, as before + expect(Navigation.goBack).toHaveBeenCalledTimes(1); + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/ui/DynamicReportDetailsPageTest.tsx b/tests/ui/DynamicReportDetailsPageTest.tsx index 39c08307f33b..495c6f481ef3 100644 --- a/tests/ui/DynamicReportDetailsPageTest.tsx +++ b/tests/ui/DynamicReportDetailsPageTest.tsx @@ -117,12 +117,7 @@ describe('DynamicReportDetailsPage', () => { @@ -151,12 +146,7 @@ describe('DynamicReportDetailsPage', () => { @@ -179,12 +169,7 @@ describe('DynamicReportDetailsPage', () => { @@ -320,12 +305,7 @@ describe('DynamicReportDetailsPage', () => { @@ -357,12 +337,7 @@ describe('DynamicReportDetailsPage', () => { diff --git a/tests/ui/IOURequestRedirectToStartPageTest.tsx b/tests/ui/IOURequestRedirectToStartPageTest.tsx index 567e288811a0..96e1fd272e20 100644 --- a/tests/ui/IOURequestRedirectToStartPageTest.tsx +++ b/tests/ui/IOURequestRedirectToStartPageTest.tsx @@ -24,6 +24,7 @@ import Onyx from 'react-native-onyx'; import {getGlobalFetchMock} from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; +jest.mock('@libs/API'); jest.mock('@userActions/Tab'); jest.mock('@rnmapbox/maps', () => ({ default: jest.fn(), diff --git a/tests/ui/LogInWithShortLivedAuthTokenPageTest.tsx b/tests/ui/LogInWithShortLivedAuthTokenPageTest.tsx index 70841fbbcc3f..259484c434e2 100644 --- a/tests/ui/LogInWithShortLivedAuthTokenPageTest.tsx +++ b/tests/ui/LogInWithShortLivedAuthTokenPageTest.tsx @@ -25,7 +25,7 @@ jest.mock('@libs/Navigation/Navigation', () => ({ })); jest.mock('@userActions/Session', () => ({ - signInWithShortLivedAuthToken: jest.fn(), + signInWithShortLivedAuthToken: jest.fn(() => Promise.resolve()), signInWithSupportAuthToken: jest.fn(), setAccountError: jest.fn(), })); @@ -71,7 +71,7 @@ describe('LogInWithShortLivedAuthTokenPage', () => { renderPage({shortLivedAuthToken: 'token', isSAML: true, shouldForceLogin: ''}); await waitForBatchedUpdatesWithAct(); - expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', true, '/search?q=status:outstanding', undefined); + expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', undefined, true, '/search?q=status:outstanding', undefined); }); it('passes the stored login to the sign-in so it can compare against the signed-in session', async () => { @@ -83,14 +83,14 @@ describe('LogInWithShortLivedAuthTokenPage', () => { renderPage({shortLivedAuthToken: 'token', isSAML: true, shouldForceLogin: ''}); await waitForBatchedUpdatesWithAct(); - expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', true, '/search?q=status:outstanding', 'user@saml.example.com'); + expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', undefined, true, '/search?q=status:outstanding', 'user@saml.example.com'); }); it('signs in with SAML without a landing page when nothing was kept', async () => { renderPage({shortLivedAuthToken: 'token', isSAML: true, shouldForceLogin: ''}); await waitForBatchedUpdatesWithAct(); - expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', true, undefined, undefined); + expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', undefined, true, undefined, undefined); }); it('signs in with a SAML token while the account is still marked loading by a forced re-auth', async () => { @@ -102,7 +102,7 @@ describe('LogInWithShortLivedAuthTokenPage', () => { renderPage({shortLivedAuthToken: 'token', isSAML: true, shouldForceLogin: ''}); await waitForBatchedUpdatesWithAct(); - expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', true, '/search?q=status:outstanding', undefined); + expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', undefined, true, '/search?q=status:outstanding', undefined); }); it('waits for the account while it is loading for a non-SAML sign-in', async () => { @@ -124,6 +124,6 @@ describe('LogInWithShortLivedAuthTokenPage', () => { renderPage({shortLivedAuthToken: 'token', shouldForceLogin: ''}); await waitForBatchedUpdatesWithAct(); - expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', false, undefined, undefined); + expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', undefined, false, undefined, undefined); }); }); diff --git a/tests/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index 7546e6679f08..9621c3343ef3 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -634,6 +634,35 @@ describe('MoneyRequestView edit fields', () => { }); }); + it('shows a negative tax amount for a negative expense on an expense report', async () => { + // Given a negative expense on an expense report. Expense reports store amounts with the opposite sign, + // so a -$9.50 expense with -$0.95 tax is stored as amount 950 and taxAmount 95. + const threadReport = { + ...LHNTestUtils.getFakeReport(), + parentReportID: expenseReportID, + parentReportActionID, + }; + + await setupTestData(); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { + amount: 950, + taxCode: 'TAX_10', + taxAmount: 95, + }); + }); + await waitForBatchedUpdatesWithAct(); + + // When the expense details are rendered + renderMoneyRequestView(threadReport, {tax: {trackingEnabled: true}}); + await waitForBatchedUpdatesWithAct(); + + // Then the tax amount keeps the negative sign instead of being shown as an absolute value + await waitFor(() => { + expect(screen.getByLabelText('iou.taxAmount, USD-95')).toBeOnTheScreen(); + }); + }); + it('does NOT append "Converted" to the Tax amount description when the converted tax is zero (tax exempt)', async () => { const threadReport = { ...LHNTestUtils.getFakeReport(), diff --git a/tests/ui/OnboardingPurpose.tsx b/tests/ui/OnboardingPurpose.tsx index 7266f827bd6a..80b19514c7aa 100644 --- a/tests/ui/OnboardingPurpose.tsx +++ b/tests/ui/OnboardingPurpose.tsx @@ -32,6 +32,7 @@ import React from 'react'; import Onyx from 'react-native-onyx'; import createMock from '../utils/createMock'; +import getOnyxValue from '../utils/getOnyxValue'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -201,6 +202,30 @@ describe('OnboardingPurpose Page', () => { await waitForBatchedUpdatesWithAct(); }); + it('should clear a stale merge-blocked flag when a public-domain user selects Join Workspace', async () => { + // Given a public-domain user whose earlier work email merge was blocked + await TestHelper.signInWithTestUser(1, 'test@gmail.com'); + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {isMergingAccountBlocked: true}); + }); + + const {unmount} = renderOnboardingPurposePage(SCREENS.ONBOARDING.PURPOSE, {backTo: ''}); + await waitForBatchedUpdatesWithAct(); + + // When they select Join Workspace + const user = userEvent.setup(); + await user.press(screen.getByLabelText(translatePurpose(CONST.ONBOARDING_CHOICES.JOIN_WORKSPACE))); + await waitForBatchedUpdatesWithAct(); + + // Then the flag is cleared so the work email step shows the form instead of the blocked view + expect(navigate).toHaveBeenCalledWith(ROUTES.ONBOARDING_WORK_EMAIL.getRoute()); + const onboarding = await getOnyxValue(ONYXKEYS.NVP_ONBOARDING); + expect(onboarding?.isMergingAccountBlocked).toBe(false); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + it('should navigate to personal details page when user selects EMPLOYER and is from public domain', async () => { await TestHelper.signInWithTestUser(); diff --git a/tests/ui/ReportActionMessageEditLayoutTest.tsx b/tests/ui/ReportActionMessageEditLayoutTest.tsx index 24d8f6bf536f..d21e3544074d 100644 --- a/tests/ui/ReportActionMessageEditLayoutTest.tsx +++ b/tests/ui/ReportActionMessageEditLayoutTest.tsx @@ -62,6 +62,13 @@ const wideLayout = createMock>({ isSmallScreen: false, }); +// A report opened in the RHP on a wide screen: shouldUseNarrowLayout is true, but the screen is not small +const wideRHPLayout = createMock>({ + ...wideLayout, + shouldUseNarrowLayout: true, + isInNarrowPaneModal: true, +}); + jest.mock('@libs/getPlatform', () => ({ __esModule: true, default: () => 'web', @@ -375,6 +382,31 @@ describe('ReportActionMessageEdit layout and draft (narrow vs wide)', () => { expect(screen.queryByTestId(testIds.MESSAGE_EDIT_CANCEL_MAIN_COMPOSER)).toBeNull(); }); + it('in the RHP on a wide screen, edits stay inline and typing in the main composer does not change the edit draft', async () => { + // Given a report opened in the RHP on a wide screen, where shouldUseNarrowLayout is true but the screen is not small + await seedReportAndActions(); + await setReportActionDraftWithMessage('RHP inline'); + await waitForBatchedUpdatesWithAct(); + + mockUseResponsiveLayout.mockReturnValue(wideRHPLayout); + render(); + await waitForBatchedUpdatesWithAct(); + + // Then the main composer stays in normal draft mode and does not take the edited message + const mainRoot = screen.getByTestId(testIds.REPORT_ACTION_COMPOSE); + expect(within(mainRoot).getByTestId(testIds.DRAFT_MESSAGE_ACTION_ROW)).toBeOnTheScreen(); + expect(within(mainRoot).queryByTestId(testIds.EDITING_MESSAGE_ACTION_ROW)).toBeNull(); + expect(within(mainRoot).getByTestId(CONST.COMPOSER.NATIVE_ID).props.value).not.toBe('RHP inline'); + expect(screen.getByTestId(testIds.REPORT_ACTION_ITEM_MESSAGE_EDIT)).toBeOnTheScreen(); + + // When the user types in the main composer + fireEvent.changeText(within(mainRoot).getByTestId(CONST.COMPOSER.NATIVE_ID), 'New comment'); + await waitForBatchedUpdatesWithAct(); + + // Then the inline edit draft is unchanged, because the main composer only edits messages on small screens + expect(await getReportActionDraftMessage(defaultReport.reportID, commentAction.reportActionID)).toBe('RHP inline'); + }); + it('switches the editing surface from inline (wide) to main composer (narrow) when layout becomes narrow', async () => { await seedReportAndActions(); await setReportActionDraftWithMessage('Shared draft'); diff --git a/tests/ui/SAMLSignInPageTest.tsx b/tests/ui/SAMLSignInPageTest.tsx index 0e62e258f3c5..b7ed74062a70 100644 --- a/tests/ui/SAMLSignInPageTest.tsx +++ b/tests/ui/SAMLSignInPageTest.tsx @@ -46,7 +46,7 @@ jest.mock('@userActions/Session', () => ({ clearSignInData: jest.fn(), setAccountError: jest.fn(), setIsAuthenticatingWithShortLivedToken: jest.fn(), - signInWithShortLivedAuthToken: jest.fn(), + signInWithShortLivedAuthToken: jest.fn().mockResolvedValue(undefined), })); const mockedOpenAuthSessionAsync = jest.mocked(openAuthSessionAsync); @@ -169,7 +169,7 @@ describe('SAMLSignInPage', () => { renderPage(); await waitForBatchedUpdatesWithAct(); - expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', true, '/search?q=status:outstanding', 'user@saml.example.com'); + expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', undefined, true, '/search?q=status:outstanding', 'user@saml.example.com'); expect(clearSignInData).not.toHaveBeenCalled(); }); @@ -181,6 +181,6 @@ describe('SAMLSignInPage', () => { renderPage(); await waitForBatchedUpdatesWithAct(); - expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', true, '/search?q=status:outstanding', 'user@saml.example.com'); + expect(signInWithShortLivedAuthToken).toHaveBeenCalledWith('token', undefined, true, '/search?q=status:outstanding', 'user@saml.example.com'); }); }); diff --git a/tests/ui/SessionTest.tsx b/tests/ui/SessionTest.tsx index e8fab6024baa..07a2a0ef624f 100644 --- a/tests/ui/SessionTest.tsx +++ b/tests/ui/SessionTest.tsx @@ -1,4 +1,4 @@ -import {act, cleanup, render, waitFor} from '@testing-library/react-native'; +import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; import * as AppActions from '@libs/actions/App'; import * as Device from '@libs/actions/Device'; @@ -18,6 +18,8 @@ import {Str} from 'expensify-common'; import {Linking} from 'react-native'; import Onyx from 'react-native-onyx'; +import type * as MockReanimatedModalModule from '../utils/mockReanimatedModal'; + import {createRandomReport} from '../utils/collections/reports'; import createMock from '../utils/createMock'; import PusherHelper from '../utils/PusherHelper'; @@ -31,6 +33,11 @@ jest.mock('@libs/BootSplash', () => ({ hide: jest.fn().mockResolvedValue(undefined), })); +jest.mock('@components/Modal/ReanimatedModal', () => { + const {default: MockReanimatedModal} = jest.requireActual('../utils/mockReanimatedModal'); + return MockReanimatedModal; +}); + const TEST_USER_ACCOUNT_ID_1 = 123; const TEST_USER_LOGIN_1 = 'test@test.com'; // cspell:disable-next-line @@ -119,7 +126,7 @@ describe('Deep linking', () => { }, [ONYXKEYS.NVP_PRIVATE_PUSH_NOTIFICATION_ID]: 'randomID', }); - return originalSignInWithShortLivedAuthToken(TEST_AUTH_TOKEN_1, false, undefined, undefined); + return originalSignInWithShortLivedAuthToken(TEST_AUTH_TOKEN_1, undefined, false, undefined, undefined); }); // Set the keys the app needs to finish loading rather than going through @@ -227,6 +234,10 @@ describe('Deep linking', () => { await waitForBatchedUpdatesWithAct(); + fireEvent.press(await screen.findByRole(CONST.ROLE.BUTTON, {name: 'Switch accounts'})); + await waitForBatchedUpdatesWithAct(); + await waitForNetworkPromises(); + expect(getCurrentUserEmail()).toBe(TEST_USER_LOGIN_1); await TestHelper.signOutTestUser(); @@ -251,6 +262,104 @@ describe('Deep linking', () => { }, FULL_APP_UI_TEST_TIMEOUT_MS, ); + + // Renders the full App twice to reach the switch-account prompt, so it runs longer than the suite default (see FULL_APP_UI_TEST_TIMEOUT_MS). + it( + 'should show the current and new account emails in the switch account prompt', + async () => { + // Given a user signed in as account 2 + const {unmount: unmount1} = render(); + await TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID_2, TEST_USER_LOGIN_2, undefined, TEST_AUTH_TOKEN_2); + + await waitForBatchedUpdatesWithAct(); + + unmount1(); + await waitForBatchedUpdatesWithAct(); + await waitForNetworkPromises(); + + // When the app remounts because of a deep link that would sign them in as account 1 instead + const url = getInitialURL(); + Linking.setInitialURL(url); + const {unmount: unmount2} = render(); + + await waitForBatchedUpdatesWithAct(); + + // Then the prompt shows both the current and the new account emails, not a blank one + const prompt = await screen.findByText(`You're signed in as ${TEST_USER_LOGIN_2}. This action will sign you in as ${TEST_USER_LOGIN_1} instead.`); + expect(prompt).toBeOnTheScreen(); + + unmount2(); + await waitForBatchedUpdatesWithAct(); + await waitForNetworkPromises(); + }, + FULL_APP_UI_TEST_TIMEOUT_MS, + ); + + // Renders the full App twice to reach the switch-account prompt, so it runs longer than the suite default (see FULL_APP_UI_TEST_TIMEOUT_MS). + it( + 'should stay on the current account when the switch is canceled', + async () => { + // Given a user signed in as account 2 + const {unmount: unmount1} = render(); + await TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID_2, TEST_USER_LOGIN_2, undefined, TEST_AUTH_TOKEN_2); + + await waitForBatchedUpdatesWithAct(); + + unmount1(); + await waitForBatchedUpdatesWithAct(); + await waitForNetworkPromises(); + + // When the app remounts because of a deep link that would sign them in as account 1, and they cancel the switch + const url = getInitialURL(); + Linking.setInitialURL(url); + const {unmount: unmount2} = render(); + + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(await screen.findByRole(CONST.ROLE.BUTTON, {name: TestHelper.translateLocal('deeplinkWrapper.switchAccount.staySignedIn')})); + await waitForBatchedUpdatesWithAct(); + await waitForNetworkPromises(); + + // Then they stay signed in as account 2 and nothing was signed out + expect(hasAuthToken()).toBe(true); + expect(getCurrentUserEmail()).toBe(TEST_USER_LOGIN_2); + + unmount2(); + await waitForBatchedUpdatesWithAct(); + await waitForNetworkPromises(); + }, + FULL_APP_UI_TEST_TIMEOUT_MS, + ); + + // Renders the full App to reach the session mismatch modal, so it runs longer than the suite default (see FULL_APP_UI_TEST_TIMEOUT_MS). + it( + 'should show a session mismatch modal when the shortLivedAuthToken does not match the current session', + async () => { + // Given a user already signed in as account 1 + jest.spyOn(Session, 'signInWithShortLivedAuthToken').mockImplementation(() => + Promise.resolve({type: CONST.ERROR_TYPE.SESSION_MISMATCH} as Awaited>), + ); + await TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID_1, TEST_USER_LOGIN_1, undefined, TEST_AUTH_TOKEN_1); + await waitForBatchedUpdatesWithAct(); + + // When they open the same transition link again + const url = getInitialURL(); + Linking.setInitialURL(url); + const {unmount} = render(); + + await waitForBatchedUpdatesWithAct(); + await waitForNetworkPromises(); + + // Then a modal tells them the link is not valid for their current session + const prompt = await screen.findByText("The link you clicked isn't valid for your current session."); + expect(prompt).toBeOnTheScreen(); + + unmount(); + await waitForBatchedUpdatesWithAct(); + await waitForNetworkPromises(); + }, + FULL_APP_UI_TEST_TIMEOUT_MS, + ); }); describe('Support auth token login', () => { @@ -414,7 +523,7 @@ describe('signInWithShortLivedAuthToken', () => { }), ); - Session.signInWithShortLivedAuthToken('token', true, undefined, undefined); + Session.signInWithShortLivedAuthToken('token', undefined, true, undefined, undefined); await waitForBatchedUpdates(); let isAuthenticating: boolean | undefined; diff --git a/tests/ui/WorkspaceMemberDetailsPageTest.tsx b/tests/ui/WorkspaceMemberDetailsPageTest.tsx index 61ef607f8613..a0f7cf1bce9d 100644 --- a/tests/ui/WorkspaceMemberDetailsPageTest.tsx +++ b/tests/ui/WorkspaceMemberDetailsPageTest.tsx @@ -3,10 +3,11 @@ import {act, fireEvent, render, screen, waitFor, within} from '@testing-library/ import ComposeProviders from '@components/ComposeProviders'; import HTMLEngineProvider from '@components/HTMLEngineProvider'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; -import {ModalProvider} from '@components/Modal/Global/ModalContext'; +import {ModalActions, ModalProvider} from '@components/Modal/Global/ModalContext'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import PersonalDetailsByLoginProvider from '@components/PersonalDetailsByLoginProvider'; +import * as useConfirmModalModule from '@hooks/useConfirmModal'; import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; import * as useResponsiveLayoutModule from '@hooks/useResponsiveLayout'; import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; @@ -29,6 +30,7 @@ import React from 'react'; import Onyx from 'react-native-onyx'; import createMock from '../utils/createMock'; +import getOnyxValue from '../utils/getOnyxValue'; import * as LHNTestUtils from '../utils/LHNTestUtils'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -299,6 +301,40 @@ describe('WorkspaceMemberDetailsPage', () => { await waitForBatchedUpdatesWithAct(); }); + it('should reassign the submitters of a removed approver whose personal details are missing', async () => { + // Given an approver with no personal details loaded, who a member submits to, opened through the route + // accountID derived from their login + const approverWithoutDetails = 'nodetails@example.com'; + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + employeeList: { + [approverWithoutDetails]: {email: approverWithoutDetails, role: CONST.POLICY.ROLE.USER}, + [invitedEmail]: {email: invitedEmail, role: CONST.POLICY.ROLE.USER, submitsTo: approverWithoutDetails}, + }, + }); + }); + + // The admin confirms the removal prompt + const showConfirmModal = jest.fn(() => Promise.resolve({action: ModalActions.CONFIRM})); + jest.spyOn(useConfirmModalModule, 'default').mockReturnValue(createMock>({showConfirmModal})); + const {unmount} = renderPage({policyID: policy.id, accountID: String(generateAccountID(approverWithoutDetails))}); + await waitForBatchedUpdatesWithAct(); + + // When the admin removes that approver + fireEvent.press(await screen.findByText(TestHelper.translateLocal('workspace.people.removeWorkspaceMemberButtonTitle'))); + await waitForBatchedUpdatesWithAct(); + expect(showConfirmModal).toHaveBeenCalledTimes(1); + + // Then the member's `submitsTo` is cleared so the backend sends them to the default approver, instead of + // being left submitting to someone no longer on the workspace + const updatedPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); + expect(updatedPolicy?.employeeList?.[invitedEmail]?.submitsTo).toBe(''); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + it('should show the unable-to-remove modal when the member is a RuleBot enforcing agent rules', async () => { // The invited member acts as the workspace RuleBot with an active agent rule await act(async () => { diff --git a/tests/ui/WorkspaceMembersTest.tsx b/tests/ui/WorkspaceMembersTest.tsx index e393441b028c..a33f55062f73 100644 --- a/tests/ui/WorkspaceMembersTest.tsx +++ b/tests/ui/WorkspaceMembersTest.tsx @@ -4,9 +4,10 @@ import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import ComposeProviders from '@components/ComposeProviders'; import HTMLEngineProvider from '@components/HTMLEngineProvider'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; -import {ModalProvider} from '@components/Modal/Global/ModalContext'; +import {ModalActions, ModalProvider} from '@components/Modal/Global/ModalContext'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import * as useConfirmModalModule from '@hooks/useConfirmModal'; import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; import * as useResponsiveLayoutModule from '@hooks/useResponsiveLayout'; import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; @@ -27,6 +28,7 @@ import React from 'react'; import Onyx from 'react-native-onyx'; import createMock from '../utils/createMock'; +import getOnyxValue from '../utils/getOnyxValue'; import * as LHNTestUtils from '../utils/LHNTestUtils'; import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -571,6 +573,44 @@ describe('WorkspaceMembers', () => { unmount(); }); + + it('should reassign the submitters of a removed approver whose personal details are missing', async () => { + // Given an approver with no personal details loaded, who a member submits to + const approverWithoutDetails = 'nodetails@example.com'; + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, { + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + employeeList: { + [approverWithoutDetails]: {email: approverWithoutDetails, role: CONST.POLICY.ROLE.USER}, + [userEmail]: {email: userEmail, role: CONST.POLICY.ROLE.USER, submitsTo: approverWithoutDetails}, + }, + }); + }); + // The admin confirms the removal prompt + const showConfirmModal = jest.fn(() => Promise.resolve({action: ModalActions.CONFIRM})); + const confirmModalSpy = jest.spyOn(useConfirmModalModule, 'default').mockReturnValue(createMock>({showConfirmModal})); + const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id}); + await waitForBatchedUpdatesWithAct(); + + // When the admin removes that approver + const row = await screen.findByLabelText(new RegExp(`^${approverWithoutDetails}`)); + fireEvent.press(within(row).getByLabelText(TestHelper.translateLocal('common.select'))); + fireEvent.press(await screen.findByTestId('WorkspaceMembersPage-header-dropdown-menu-button')); + await waitForBatchedUpdatesWithAct(); + const removeMenuItem = screen.getByText(TestHelper.translateLocal('workspace.people.removeMembersTitle', {count: 1})); + fireEvent.press(removeMenuItem, {nativeEvent: {}, type: 'press', target: removeMenuItem, currentTarget: removeMenuItem}); + await waitForBatchedUpdatesWithAct(); + expect(showConfirmModal).toHaveBeenCalledTimes(1); + + // Then the approver is removed and the member is moved to the workspace owner, instead of being left + // submitting to someone no longer on the workspace + const updatedPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); + expect(updatedPolicy?.employeeList?.[approverWithoutDetails]).toBeUndefined(); + expect(updatedPolicy?.employeeList?.[userEmail]?.submitsTo).toBe(ownerEmail); + + unmount(); + confirmModalSpy.mockRestore(); + }); }); describe('RuleBot restrictions', () => { diff --git a/tests/ui/WorkspaceWorkflowsApprovalsApproverPageTest.tsx b/tests/ui/WorkspaceWorkflowsApprovalsApproverPageTest.tsx new file mode 100644 index 000000000000..4f4cf3419630 --- /dev/null +++ b/tests/ui/WorkspaceWorkflowsApprovalsApproverPageTest.tsx @@ -0,0 +1,231 @@ +import {act, render} from '@testing-library/react-native'; + +import type {SelectionListApprover} from '@components/ApproverSelectionList'; +import ApproverSelectionList from '@components/ApproverSelectionList'; +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import Navigation from '@libs/Navigation/Navigation'; +import {convertPolicyEmployeesToApprovalWorkflows} from '@libs/WorkflowUtils'; + +import WorkspaceWorkflowsApprovalsApproverPage from '@pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage'; + +import {clearApprovalWorkflowApprover, selectApprovalWorkflowForEdit} from '@userActions/Workflow'; + +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 {PersonalDetailsList} from '@src/types/onyx/PersonalDetails'; +import type {PolicyEmployeeList} from '@src/types/onyx/PolicyEmployee'; + +import type {PropsWithChildren} from 'react'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createMock from '../utils/createMock'; +import getOnyxValue from '../utils/getOnyxValue'; +import {buildPersonalDetails, localeCompare} from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const POLICY_ID = 'workflow-approver-test-policy'; +const ADMIN_EMAIL = 'admin@example.com'; +const ADMIN_ACCOUNT_ID = 1; +const BOB_EMAIL = 'bob@example.com'; +const BOB_ACCOUNT_ID = 2; +const ERIN_EMAIL = 'erin@example.com'; +const ERIN_ACCOUNT_ID = 3; +const DAVE_EMAIL = 'dave@example.com'; +const DAVE_ACCOUNT_ID = 4; +const REMOVED_EMAIL = 'removed@example.com'; +const REMOVED_ACCOUNT_ID = 5; + +// The tests read the rows the page hands the list and tap them through its callback, so the list itself renders nothing. +jest.mock('@components/ApproverSelectionList', () => jest.fn(() => null)); +jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => jest.fn(({children}: PropsWithChildren) => children)); + +jest.mock('@react-navigation/native', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actualNav = jest.requireActual('@react-navigation/native'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actualNav, + useNavigationState: () => [], + }; +}); + +jest.mock('@libs/Navigation/Navigation', () => ({ + goBack: jest.fn(), + navigate: jest.fn(), +})); + +/** Bob approves Dave's reports and forwards to Erin, who still forwards to someone removed from the workspace. */ +function buildEmployeeList(): PolicyEmployeeList { + return { + [ADMIN_EMAIL]: {email: ADMIN_EMAIL, role: CONST.POLICY.ROLE.ADMIN, submitsTo: ADMIN_EMAIL}, + [BOB_EMAIL]: {email: BOB_EMAIL, role: CONST.POLICY.ROLE.USER, submitsTo: ADMIN_EMAIL, forwardsTo: ERIN_EMAIL}, + [ERIN_EMAIL]: {email: ERIN_EMAIL, role: CONST.POLICY.ROLE.USER, submitsTo: ADMIN_EMAIL, forwardsTo: REMOVED_EMAIL}, + [DAVE_EMAIL]: {email: DAVE_EMAIL, role: CONST.POLICY.ROLE.USER, submitsTo: BOB_EMAIL}, + }; +} + +function buildPolicy(employeeList: PolicyEmployeeList): Policy { + return createMock({ + id: POLICY_ID, + name: 'Test Workspace', + type: CONST.POLICY.TYPE.CORPORATE, + role: CONST.POLICY.ROLE.ADMIN, + owner: ADMIN_EMAIL, + approver: ADMIN_EMAIL, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + preventSelfApproval: true, + areWorkflowsEnabled: true, + employeeList, + }); +} + +function buildPersonalDetailsList(): PersonalDetailsList { + return { + [ADMIN_ACCOUNT_ID]: buildPersonalDetails(ADMIN_EMAIL, ADMIN_ACCOUNT_ID, 'admin'), + [BOB_ACCOUNT_ID]: buildPersonalDetails(BOB_EMAIL, BOB_ACCOUNT_ID, 'bob'), + [ERIN_ACCOUNT_ID]: buildPersonalDetails(ERIN_EMAIL, ERIN_ACCOUNT_ID, 'erin'), + [DAVE_ACCOUNT_ID]: buildPersonalDetails(DAVE_EMAIL, DAVE_ACCOUNT_ID, 'dave'), + [REMOVED_ACCOUNT_ID]: buildPersonalDetails(REMOVED_EMAIL, REMOVED_ACCOUNT_ID, 'removed'), + }; +} + +/** Opens Bob > Erin > removed approver for editing, the way the Workflows page builds and selects it. */ +async function editWorkflowWithRemovedApprover() { + const {approvalWorkflows} = convertPolicyEmployeesToApprovalWorkflows({ + policy: buildPolicy(buildEmployeeList()), + personalDetails: buildPersonalDetailsList(), + localeCompare, + currentUserLogin: ADMIN_EMAIL, + }); + const workflow = approvalWorkflows.find((approvalWorkflow) => approvalWorkflow.approvers.at(0)?.email === BOB_EMAIL); + if (!workflow) { + throw new Error("Bob's workflow was not built"); + } + await act(async () => { + selectApprovalWorkflowForEdit({workflow, defaultWorkflowMembers: [], usedApproverEmails: []}); + }); + await waitForBatchedUpdatesWithAct(); +} + +async function renderPage(approverIndex: number) { + const result = render( + + + , + ); + await waitForBatchedUpdatesWithAct(); + return result; +} + +function getRows(): SelectionListApprover[] { + return jest.mocked(ApproverSelectionList).mock.lastCall?.[0].allApprovers ?? []; +} + +/** Taps a row the way ApproverSelectionList does with single selection: a selected row clears the selection. */ +async function tapRow(email: string) { + const listProps = jest.mocked(ApproverSelectionList).mock.lastCall?.[0]; + const row = listProps?.allApprovers.find((approver) => approver.login === email); + if (!listProps || !row) { + throw new Error(`${email} is not in the approver list`); + } + await act(async () => { + listProps.onSelectApprover?.(row.isSelected ? [] : [{...row, isSelected: true}]); + }); + await waitForBatchedUpdatesWithAct(); +} + +async function getApproverEmails() { + const approvalWorkflow = await getOnyxValue(ONYXKEYS.APPROVAL_WORKFLOW); + return approvalWorkflow?.approvers.map((approver) => approver?.email); +} + +describe('WorkspaceWorkflowsApprovalsApproverPage', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.mocked(ApproverSelectionList).mockClear(); + jest.mocked(Navigation.goBack).mockClear(); + await act(async () => { + await Onyx.clear(); + await Onyx.set(ONYXKEYS.HAS_LOADED_APP, true); + await Onyx.set(ONYXKEYS.IS_LOADING_REPORT_DATA, false); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, buildPolicy(buildEmployeeList())); + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, buildPersonalDetailsList()); + await Onyx.merge(ONYXKEYS.SESSION, {email: ADMIN_EMAIL, accountID: ADMIN_ACCOUNT_ID}); + }); + await waitForBatchedUpdatesWithAct(); + await editWorkflowWithRemovedApprover(); + }); + + it('lets the admin drop just the approver who left so the previous approver becomes final', async () => { + // Given the edited workflow Bob > Erin > an approver who left the workspace + expect(await getApproverEmails()).toEqual([BOB_EMAIL, ERIN_EMAIL, REMOVED_EMAIL]); + + // When the admin opens the approver who left + await renderPage(2); + + // Then they are listed once, selected, even though they are no longer on employeeList + const removedRows = getRows().filter((row) => row.login === REMOVED_EMAIL); + expect(removedRows).toHaveLength(1); + expect(removedRows.at(0)?.isSelected).toBe(true); + + // When the admin taps that selected row + await tapRow(REMOVED_EMAIL); + + // Then only that approver is dropped, so Erin is the final approver, and the admin is taken back to the workflow + expect(await getApproverEmails()).toEqual([BOB_EMAIL, ERIN_EMAIL]); + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(POLICY_ID, BOB_EMAIL, undefined), {compareParams: false}); + }); + + it('never offers the approver who left as a new pick', async () => { + // Given the edited workflow Bob > Erin > an approver who left the workspace + + // When the admin opens Erin's slot + const {unmount} = await renderPage(1); + + // Then the approver who left can't be picked to replace Erin + expect(getRows().some((row) => row.login === REMOVED_EMAIL)).toBe(false); + unmount(); + + // When the approver who left is removed from their slot and the admin opens that now empty slot + await act(async () => { + clearApprovalWorkflowApprover({approverIndex: 2, currentApprovalWorkflow: await getOnyxValue(ONYXKEYS.APPROVAL_WORKFLOW)}); + }); + await waitForBatchedUpdatesWithAct(); + await renderPage(2); + + // Then they can't be picked again + expect(getRows().some((row) => row.login === REMOVED_EMAIL)).toBe(false); + }); + + it('lists a flagged approver only once when they are back on the workspace', async () => { + // Given the approver who left was invited back while the edited workflow still has them flagged + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, { + employeeList: {[REMOVED_EMAIL]: {email: REMOVED_EMAIL, role: CONST.POLICY.ROLE.USER, submitsTo: ADMIN_EMAIL}}, + }); + }); + await waitForBatchedUpdatesWithAct(); + + // When the admin opens their slot + await renderPage(2); + + // Then they appear as a single selected row rather than once from employeeList and once as the flagged approver + const removedRows = getRows().filter((row) => row.login === REMOVED_EMAIL); + expect(removedRows).toHaveLength(1); + expect(removedRows.at(0)?.isSelected).toBe(true); + }); +}); diff --git a/tests/ui/WorkspaceWorkflowsApprovalsEditPageTest.tsx b/tests/ui/WorkspaceWorkflowsApprovalsEditPageTest.tsx index 38e46d731a04..553487ac45a4 100644 --- a/tests/ui/WorkspaceWorkflowsApprovalsEditPageTest.tsx +++ b/tests/ui/WorkspaceWorkflowsApprovalsEditPageTest.tsx @@ -1,6 +1,7 @@ import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; import ComposeProviders from '@components/ComposeProviders'; +import HTMLEngineProvider from '@components/HTMLEngineProvider'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; @@ -96,7 +97,7 @@ const renderEditPage = (route = mockRoute) => {() => ( - + { expect(approvalWorkflows.at(0)?.approvers.map((approver) => approver.email)).toEqual([bobEmail]); }); + describe('approver no longer on the workspace', () => { + it('flags the approver as soon as the editor opens and blocks saving', async () => { + // Given Bob still submits to someone who was removed from the workspace + const bobEmail = 'bob@example.com'; + const removedEmail = 'removed@example.com'; + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {employeeList: {[bobEmail]: {email: bobEmail, submitsTo: removedEmail}}}); + await waitForBatchedUpdatesWithAct(); + }); + + // When the admin opens that workflow in the editor + renderEditPage({...mockRoute, params: {policyID: POLICY_ID, firstApproverEmail: removedEmail}}); + await waitForBatchedUpdatesWithAct(); + + // Then the approver shows the error right away, before any Save attempt + expect(await screen.findByText(translateLocal('workflowsPage.approverNotWorkspaceMember'))).toBeOnTheScreen(); + + // And saving is blocked until the admin picks a new approver or deletes the workflow + jest.mocked(Navigation.dismissModal).mockClear(); + fireEvent.press(screen.getByText(translateLocal('common.save'))); + await waitForBatchedUpdatesWithAct(); + expect(Navigation.dismissModal).not.toHaveBeenCalled(); + }); + }); + describe('shared approver hint', () => { const aliceApprover: Approver = { email: ALICE_EMAIL, diff --git a/tests/unit/ApprovalWorkflowSectionTest.tsx b/tests/unit/ApprovalWorkflowSectionTest.tsx index d28f7fe907a7..451eb050258a 100644 --- a/tests/unit/ApprovalWorkflowSectionTest.tsx +++ b/tests/unit/ApprovalWorkflowSectionTest.tsx @@ -78,4 +78,62 @@ describe('ApprovalWorkflowSection', () => { expect(screen.queryByLabelText('Edit')).toBeNull(); }); + + it('shows an error under an approver who is no longer a workspace member', () => { + // Given a workflow whose approver was removed from the workspace without reassigning its members + const brokenWorkflow: ApprovalWorkflow = {...baseWorkflow, approvers: [{email: 'removed@example.com', displayName: 'removed@example.com', isNotWorkspaceMember: true}]}; + + // When the workflow card renders + renderSection({approvalWorkflow: brokenWorkflow, onPress: jest.fn()}); + + // Then the admin sees an error telling them to fix it + expect(screen.getByText('workflowsPage.approverNotWorkspaceMember')).toBeTruthy(); + }); + + it('does not show the non-member error for a regular approver', () => { + // Given a workflow whose approver is a workspace member + // When the workflow card renders + renderSection({approvalWorkflow: baseWorkflow, onPress: jest.fn()}); + + // Then no error is shown + expect(screen.queryByText('workflowsPage.approverNotWorkspaceMember')).toBeNull(); + }); + + it('reads the non-member error out through the Edit button, since the approver rows are hidden from screen readers', () => { + // Given a workflow whose approver was removed from the workspace + const brokenWorkflow: ApprovalWorkflow = {...baseWorkflow, approvers: [{email: 'removed@example.com', displayName: 'removed@example.com', isNotWorkspaceMember: true}]}; + + // When the workflow card renders + renderSection({approvalWorkflow: brokenWorkflow, onPress: jest.fn()}); + + // Then the Edit button's label carries the error, so screen reader users hear what needs fixing + expect(screen.getByLabelText('Edit, workflowsPage.approverNotWorkspaceMember')).toBeTruthy(); + }); + + it('does not offer deleting the default workflow when its approver is no longer a workspace member', () => { + // Given the default workflow, whose approver was removed from the workspace + const brokenDefaultWorkflow: ApprovalWorkflow = { + ...baseWorkflow, + isDefault: true, + approvers: [{email: 'removed@example.com', displayName: 'removed@example.com', isNotWorkspaceMember: true}], + }; + + // When the workflow card renders + renderSection({approvalWorkflow: brokenDefaultWorkflow, onPress: jest.fn()}); + + // Then the error only asks for a new approver, since the default workflow can't be deleted + expect(screen.getByText('workflowsPage.defaultWorkflowApproverNotWorkspaceMember')).toBeTruthy(); + expect(screen.queryByText('workflowsPage.approverNotWorkspaceMember')).toBeNull(); + }); + + it('does not show the non-member error to someone who cannot edit the workflow', () => { + // Given a workflow whose approver was removed from the workspace, shown read-only + const brokenWorkflow: ApprovalWorkflow = {...baseWorkflow, approvers: [{email: 'removed@example.com', displayName: 'removed@example.com', isNotWorkspaceMember: true}]}; + + // When the workflow card renders disabled + renderSection({approvalWorkflow: brokenWorkflow, onPress: jest.fn(), isDisabled: true}); + + // Then no error is shown, since there is no Edit button to act on it + expect(screen.queryByText('workflowsPage.approverNotWorkspaceMember')).toBeNull(); + }); }); diff --git a/tests/unit/CardUtilsTest.ts b/tests/unit/CardUtilsTest.ts index 78c5d3cf0466..ec0f91c5d563 100644 --- a/tests/unit/CardUtilsTest.ts +++ b/tests/unit/CardUtilsTest.ts @@ -613,6 +613,17 @@ describe('CardUtils', () => { expect(isCustomFeed).toBe(true); }); + it('Should return true for the mock commercial feed', () => { + // Given a mock commercial feed. + const customFeed = CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK; + + // When checking whether it is a custom feed. + const isCustomFeed = isCustomFeedCardUtils(customFeed); + + // Then it is treated as a custom feed. + expect(isCustomFeed).toBe(true); + }); + test.each(directFeedBanks)('Should return false for the direct feed %s', (directFeed) => { const isCustomFeed = isCustomFeedCardUtils(directFeed); expect(isCustomFeed).toBe(false); @@ -2063,6 +2074,19 @@ describe('CardUtils', () => { const feedType = getFeedType('vcf', companyCardsCustomVisaFeedSettingsWithNumbers); expect(feedType).toBe('vcf2'); }); + + it('should number mock commercial feeds', () => { + // Given an existing numbered mock commercial feed. + const cardFeeds = createMock({ + [`${CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK}1`]: {}, + }); + + // When finding the next feed type. + const feedType = getFeedType(CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK, cardFeeds); + + // Then the next number is returned. + expect(feedType).toBe(`${CONST.COMPANY_CARD.FEED_BANK_NAME.VCF_MOCK}2`); + }); }); describe('getCSVFeedType', () => { diff --git a/tests/unit/ClearReportActionErrorsTest.ts b/tests/unit/ClearReportActionErrorsTest.ts index 47e4eb45864b..b56a5ec8e2c0 100644 --- a/tests/unit/ClearReportActionErrorsTest.ts +++ b/tests/unit/ClearReportActionErrorsTest.ts @@ -1,5 +1,6 @@ import {clearAllRelatedReportActionErrors} from '@libs/actions/ClearReportActionErrors'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportActions} from '@src/types/onyx'; @@ -171,6 +172,42 @@ describe('ClearReportActionErrors', () => { expect(parentReportActions?.[PARENT_REPORT_ACTION_ID]?.errors).toEqual({}); }); + it('should clear the mirrored pay error on the chat preview without deleting the preview itself', async () => { + // Given a failed payment: the optimistic pay action inside the IOU report and the chat's report preview + // both carry the same error key, which is what links them. + const iouReport = createMockReport({ + parentReportID: PARENT_REPORT_ID, + parentReportActionID: PARENT_REPORT_ACTION_ID, + }); + const payAction = getFakeReportAction(Number(REPORT_ACTION_ID), { + // A failed optimistic pay action keeps its ADD pending state, so dismissing it removes the action. + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + errors: {[CONST.IOU.PAY_FAILURE_PREVIEW_ERROR_KEY]: 'Unexpected error. Please try again later.'}, + }); + const reportPreviewAction = getFakeReportAction(Number(PARENT_REPORT_ACTION_ID), { + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + errors: {[CONST.IOU.PAY_FAILURE_PREVIEW_ERROR_KEY]: 'Unexpected error. Please try again later.'}, + }); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, iouReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {[REPORT_ACTION_ID]: payAction}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${PARENT_REPORT_ID}`, {[PARENT_REPORT_ACTION_ID]: reportPreviewAction}); + await waitForBatchedUpdates(); + + // When the payer dismisses the error from inside the expense report + clearAllRelatedReportActionErrors(REPORT_ID, payAction, REPORT_ID, false); + await waitForBatchedUpdates(); + + // Then the copy on the chat preview is cleared too, so a dismissed error cannot resurface later when the + // expense is deleted and that preview becomes visible again. + const parentReportActions = await getReportActionsFromOnyx(PARENT_REPORT_ID); + expect(parentReportActions?.[PARENT_REPORT_ACTION_ID]?.errors).toEqual({}); + + // And the preview survives. It is a real server action, so it must not take the optimistic-delete path. + expect(parentReportActions?.[PARENT_REPORT_ACTION_ID]).toBeTruthy(); + expect(parentReportActions?.[PARENT_REPORT_ACTION_ID]?.actionName).toBe(CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW); + }); + it('should not clear parent errors when ignore is set to parent', async () => { // Given a child report with parent reference and both have errors with matching keys const report = createMockReport({ diff --git a/tests/unit/ContextMenuActionsDeleteTest.ts b/tests/unit/ContextMenuActionsDeleteTest.ts new file mode 100644 index 000000000000..b2e060fa2a63 --- /dev/null +++ b/tests/unit/ContextMenuActionsDeleteTest.ts @@ -0,0 +1,105 @@ +import type * as ContextMenuActionsModule from '@pages/inbox/report/ContextMenu/ContextMenuActions'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportAction} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import createRandomReportAction from '../utils/collections/reportActions'; +import createRandomTransaction from '../utils/collections/transaction'; +import createMock from '../utils/createMock'; + +jest.mock( + 'expo-web-browser', + () => ({ + openAuthSessionAsync: jest.fn(), + }), + {virtual: true}, +); + +jest.mock('@components/Reactions/MiniQuickEmojiReactions', () => 'MiniQuickEmojiReactions'); +jest.mock('@components/Reactions/QuickEmojiReactions', () => 'QuickEmojiReactions'); + +const mockShowDeleteModal = jest.fn(); + +jest.mock('@pages/inbox/report/ContextMenu/ReportActionContextMenu', () => ({ + hideContextMenu: jest.fn(), + showDeleteModal: (...args: unknown[]) => { + mockShowDeleteModal(...args); + }, +})); + +const {default: ContextMenuActions} = jest.requireActual('@pages/inbox/report/ContextMenu/ContextMenuActions'); + +const deleteAction = ContextMenuActions.find((action) => 'sentryLabel' in action && action.sentryLabel === CONST.SENTRY_LABEL.CONTEXT_MENU.DELETE); +if (!deleteAction || !('onPress' in deleteAction)) { + throw new Error('Delete context menu action was not found'); +} + +type DeleteActionVisibilityPayload = Parameters[0]; + +describe('ContextMenuActions delete action', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('hides Delete from a member who owns an Expensify Card expense', async () => { + // Given a member viewing the report preview for their own Expensify Card expense + const currentUserAccountID = 1; + const parentReportID = '20'; + const expenseReportID = '21'; + const transactionID = '22'; + const expenseReport: Report = { + reportID: expenseReportID, + type: CONST.REPORT.TYPE.EXPENSE, + ownerAccountID: currentUserAccountID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + const reportPreviewAction: ReportAction = { + ...createRandomReportAction(20), + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + childReportID: expenseReportID, + }; + const moneyRequestAction: ReportAction = { + ...createRandomReportAction(21), + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: currentUserAccountID, + reportID: expenseReportID, + originalMessage: { + IOUTransactionID: transactionID, + amount: 100, + currency: CONST.CURRENCY.USD, + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + }, + }; + const iouTransaction = { + ...createRandomTransaction(Number(transactionID)), + transactionID, + reportID: expenseReportID, + managedCard: false, + bank: CONST.EXPENSIFY_CARD.BANK, + comment: {liabilityType: CONST.TRANSACTION.LIABILITY_TYPE.RESTRICT}, + }; + const payload = createMock({ + type: CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, + reportID: parentReportID, + reportAction: reportPreviewAction, + moneyRequestAction, + currentUserAccountID, + iouTransaction, + transactions: {[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: iouTransaction}, + childReportActions: {[moneyRequestAction.reportActionID]: moneyRequestAction}, + isArchivedRoom: false, + isChronosReport: false, + }); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, expenseReport); + + // When the context menu determines which actions to show + const shouldShowDelete = deleteAction.shouldShow(payload); + + // Then Delete is hidden because Auth does not allow a member to delete an Expensify Card expense or its report + expect(shouldShowDelete).toBe(false); + }); +}); diff --git a/tests/unit/HrUtilsTest.ts b/tests/unit/HrUtilsTest.ts index 8756c9ef9dba..bdb56616bc40 100644 --- a/tests/unit/HrUtilsTest.ts +++ b/tests/unit/HrUtilsTest.ts @@ -1,6 +1,16 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; -import {getConnectedHRProvider, getHRApprovalMode, isAnyHRConnected, isAnyHRReadOnlyWorkflowMode, isMergeHRCompleteSetupNeeded, shouldShowHRConnectionError} from '@libs/merge/HRUtils'; +import { + getConnectedHRProvider, + getHRApprovalMode, + getNonRenderableMergeHRGroupIDs, + getValidMergeHRGroupIDs, + hasStaleMergeHRGroups, + isAnyHRConnected, + isAnyHRReadOnlyWorkflowMode, + isMergeHRCompleteSetupNeeded, + shouldShowHRConnectionError, +} from '@libs/merge/HRUtils'; import {getApprovalModeLabel, getHRCards, getHRCardState} from '@pages/workspace/hr/utils'; import type {MergeProviderCardDescriptor} from '@pages/workspace/merge/types'; @@ -321,6 +331,197 @@ describe('HRUtils', () => { }); }); + describe('hasStaleMergeHRGroups', () => { + it('returns false when not connected', () => { + expect(hasStaleMergeHRGroups(makePolicy())).toBe(false); + }); + + it('returns false when the admin has not chosen groups yet', () => { + const policy = makePolicy({ + connections: { + [MERGE_HR]: makeMergeHRConnection({data: {groups: [{id: 'g1', name: 'Eng', type: 'Department'}]}}), + }, + }); + expect(hasStaleMergeHRGroups(policy)).toBe(false); + }); + + it('returns false when the cache has never synced', () => { + const policy = makePolicy({ + connections: { + [MERGE_HR]: makeMergeHRConnection({config: {groups: ['g1']}, data: {}}), + }, + }); + expect(hasStaleMergeHRGroups(policy)).toBe(false); + }); + + it('returns true when a synced cache legitimately has zero groups', () => { + const policy = makePolicy({ + connections: { + [MERGE_HR]: makeMergeHRConnection({config: {groups: ['g1']}, data: {allGroupIDs: []}}), + }, + }); + expect(hasStaleMergeHRGroups(policy)).toBe(true); + }); + + it('returns false when every selected group still exists', () => { + const policy = makePolicy({ + connections: { + [MERGE_HR]: makeMergeHRConnection({ + config: {groups: ['g1', 'g2']}, + data: {allGroupIDs: ['g1', 'g2']}, + }), + }, + }); + expect(hasStaleMergeHRGroups(policy)).toBe(false); + }); + + it('returns true when a selected group is missing from the cached list', () => { + const policy = makePolicy({ + connections: { + [MERGE_HR]: makeMergeHRConnection({ + config: {groups: ['g1', 'g-deleted']}, + data: {allGroupIDs: ['g1']}, + }), + }, + }); + expect(hasStaleMergeHRGroups(policy)).toBe(true); + }); + + it('returns false when a selected group still exists but lost its display name/type', () => { + const policy = makePolicy({ + connections: { + [MERGE_HR]: makeMergeHRConnection({ + config: {groups: ['g1']}, + data: {groups: [], allGroupIDs: ['g1']}, + }), + }, + }); + expect(hasStaleMergeHRGroups(policy)).toBe(false); + }); + }); + + describe('getValidMergeHRGroupIDs', () => { + it('returns an empty list when the admin has not chosen groups yet', () => { + expect(getValidMergeHRGroupIDs(makePolicy())).toEqual([]); + }); + + it('keeps the selection intact when every selected group is still in the cached list', () => { + const policy = makePolicy({ + connections: { + [MERGE_HR]: makeMergeHRConnection({ + config: {groups: ['g1', 'g2']}, + data: { + groups: [ + {id: 'g1', name: 'Eng', type: 'Department'}, + {id: 'g2', name: 'Sales', type: 'Department'}, + ], + }, + }), + }, + }); + expect(getValidMergeHRGroupIDs(policy)).toEqual(['g1', 'g2']); + }); + + it('drops a selected group the cached list no longer has', () => { + const policy = makePolicy({ + connections: { + [MERGE_HR]: makeMergeHRConnection({ + config: {groups: ['g1', 'g-deleted']}, + data: {groups: [{id: 'g1', name: 'Eng', type: 'Department'}], allGroupIDs: ['g1']}, + }), + }, + }); + expect(getValidMergeHRGroupIDs(policy)).toEqual(['g1']); + }); + + it('keeps a selected group that still exists upstream even when it has no renderable row', () => { + const policy = makePolicy({ + connections: { + [MERGE_HR]: makeMergeHRConnection({ + config: {groups: ['g1', 'g2']}, + data: {groups: [{id: 'g1', name: 'Eng', type: 'Department'}], allGroupIDs: ['g1', 'g2']}, + }), + }, + }); + expect(getValidMergeHRGroupIDs(policy)).toEqual(['g1', 'g2']); + }); + + it('leaves the selection alone when the cache has never synced', () => { + const policy = makePolicy({ + connections: {[MERGE_HR]: makeMergeHRConnection({config: {groups: ['g1', 'g2']}, data: {}})}, + }); + expect(getValidMergeHRGroupIDs(policy)).toEqual(['g1', 'g2']); + }); + + it('drops the whole selection when a synced cache legitimately has zero groups and allGroupIDs has not synced', () => { + const policy = makePolicy({ + connections: {[MERGE_HR]: makeMergeHRConnection({config: {groups: ['g1', 'g2']}, data: {groups: []}})}, + }); + expect(getValidMergeHRGroupIDs(policy)).toEqual([]); + }); + }); + + describe('getNonRenderableMergeHRGroupIDs', () => { + it('returns an empty list when the admin has not chosen groups yet', () => { + expect(getNonRenderableMergeHRGroupIDs(makePolicy())).toEqual([]); + }); + + it('returns an empty list when every selected group has a renderable row', () => { + const policy = makePolicy({ + connections: { + [MERGE_HR]: makeMergeHRConnection({ + config: {groups: ['g1']}, + data: {groups: [{id: 'g1', name: 'Eng', type: 'Department'}], allGroupIDs: ['g1']}, + }), + }, + }); + expect(getNonRenderableMergeHRGroupIDs(policy)).toEqual([]); + }); + + it('keeps a selected group that still exists upstream but has no renderable row', () => { + const policy = makePolicy({ + connections: { + [MERGE_HR]: makeMergeHRConnection({ + config: {groups: ['g1', 'g2']}, + data: {groups: [{id: 'g1', name: 'Eng', type: 'Department'}], allGroupIDs: ['g1', 'g2']}, + }), + }, + }); + expect(getNonRenderableMergeHRGroupIDs(policy)).toEqual(['g2']); + }); + + it('drops a selected group that no longer exists anywhere upstream', () => { + const policy = makePolicy({ + connections: { + [MERGE_HR]: makeMergeHRConnection({ + config: {groups: ['g1', 'g-deleted']}, + data: {groups: [{id: 'g1', name: 'Eng', type: 'Department'}], allGroupIDs: ['g1']}, + }), + }, + }); + expect(getNonRenderableMergeHRGroupIDs(policy)).toEqual([]); + }); + + it('falls back to data.groups for validity when allGroupIDs has not synced yet', () => { + const policy = makePolicy({ + connections: { + [MERGE_HR]: makeMergeHRConnection({ + config: {groups: ['g1', 'g-deleted']}, + data: {groups: [{id: 'g1', name: 'Eng', type: 'Department'}]}, + }), + }, + }); + expect(getNonRenderableMergeHRGroupIDs(policy)).toEqual([]); + }); + + it('leaves the selection alone when the cache has never synced', () => { + const policy = makePolicy({ + connections: {[MERGE_HR]: makeMergeHRConnection({config: {groups: ['g1', 'g2']}, data: {}})}, + }); + expect(getNonRenderableMergeHRGroupIDs(policy)).toEqual([]); + }); + }); + describe('shouldShowHRConnectionError', () => { it('returns false when user is not an admin', () => { const policy = makePolicy({ @@ -351,6 +552,32 @@ describe('HRUtils', () => { expect(shouldShowHRConnectionError(policy, false, true)).toBe(true); }); + it('returns false when the Merge HR failure came from its own settings and they are now valid', () => { + const policy = makePolicy({ + connections: { + [MERGE_HR]: makeMergeHRConnection({ + config: {integration: 'workday', groups: ['g1']}, + data: {allGroupIDs: ['g1']}, + lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.FAILED, isConfigurationError: true}, + }), + }, + }); + expect(shouldShowHRConnectionError(policy, false, true)).toBe(false); + }); + + it('returns true when the Merge HR settings failure is still unresolved', () => { + const policy = makePolicy({ + connections: { + [MERGE_HR]: makeMergeHRConnection({ + config: {integration: 'workday', groups: ['g-deleted']}, + data: {allGroupIDs: ['g1']}, + lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.FAILED, isConfigurationError: true}, + }), + }, + }); + expect(shouldShowHRConnectionError(policy, false, true)).toBe(true); + }); + it('returns false when Merge HR sync status is DONE', () => { const policy = makePolicy({ connections: {[MERGE_HR]: makeMergeHRConnection({config: {integration: 'workday'}, lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.DONE}})}, @@ -754,6 +981,62 @@ describe('getHRCards', () => { expect(bamboo?.lastSyncErrorMessage).toBe('Auth failed'); }); + it('keeps the generic sync error off the Merge card when the failure came from its own settings', () => { + const policy = makePolicy({ + connections: { + [MERGE_HR]: makeMergeHRConnection({ + config: {integration: 'bamboohr', groups: ['g1']}, + data: {groups: [{id: 'g1', name: 'Eng', type: 'Department'}], allGroupIDs: ['g1']}, + lastSync: { + syncStatus: CONST.MERGE.SYNC_STATUS.FAILED, + errorMessage: 'None of the selected groups exist in your HR system anymore.', + isConfigurationError: true, + }, + }), + }, + }); + const cards = getHRCards(makeGetHRCardsParams({policy})); + + const bamboo = cards.find((c) => c.key === 'merge_bamboohr'); + expect(bamboo?.hasError).toBe(false); + expect(bamboo?.lastSyncErrorMessage).toBeUndefined(); + expect(bamboo?.staleGroupsRoute).toBeUndefined(); + }); + + it('points the connected Merge card at the group selector when a selected group no longer exists', () => { + const policy = makePolicy({ + connections: { + [MERGE_HR]: makeMergeHRConnection({ + config: {integration: 'bamboohr', groups: ['g1', 'g-deleted']}, + data: {groups: [{id: 'g1', name: 'Eng', type: 'Department'}], allGroupIDs: ['g1']}, + lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.DONE}, + }), + }, + }); + const cards = getHRCards(makeGetHRCardsParams({policy})); + + const bamboo = cards.find((c) => c.key === 'merge_bamboohr'); + expect(bamboo?.staleGroupsRoute).toBe(ROUTES.WORKSPACE_HR_MERGE_GROUPS.getRoute(POLICY_ID)); + expect(getRow(bamboo, 'groups')?.hasInvalidValue).toBe(true); + }); + + it('leaves the connected Merge card alone when every selected group still exists', () => { + const policy = makePolicy({ + connections: { + [MERGE_HR]: makeMergeHRConnection({ + config: {integration: 'bamboohr', groups: ['g1']}, + data: {groups: [{id: 'g1', name: 'Eng', type: 'Department'}]}, + lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.DONE}, + }), + }, + }); + const cards = getHRCards(makeGetHRCardsParams({policy})); + + const bamboo = cards.find((c) => c.key === 'merge_bamboohr'); + expect(bamboo?.staleGroupsRoute).toBeUndefined(); + expect(getRow(bamboo, 'groups')?.hasInvalidValue).toBe(false); + }); + it('disconnected Merge cards do not inherit error state from the connection', () => { const policy = makePolicy({ connections: { diff --git a/tests/unit/MergeActionTest.ts b/tests/unit/MergeActionTest.ts index 56b27b7c1038..8d9cb6d04f9a 100644 --- a/tests/unit/MergeActionTest.ts +++ b/tests/unit/MergeActionTest.ts @@ -1,14 +1,17 @@ -import {setMergeInitialSyncModalShown, updateMergeApprovalMode} from '@libs/actions/connections/merge'; +import {setMergeInitialSyncModalShown, syncMerge, updateMergeApprovalMode} from '@libs/actions/connections/merge'; import {write} from '@libs/API'; import {WRITE_COMMANDS} from '@libs/API/types'; import {toIndexMap} from '@libs/RuleUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type Policy from '@src/types/onyx/Policy'; import type Rule from '@src/types/onyx/Rule'; import Onyx from 'react-native-onyx'; +import createRandomPolicy from '../utils/collections/policies'; + jest.mock('@libs/API'); const mockErrorTimestamp = 123; @@ -21,6 +24,16 @@ const mockWrite = jest.mocked(write); const policyID = 'policyID'; const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}`; const error = {[mockErrorTimestamp]: 'common.genericErrorMessage'}; +const connectionName = CONST.POLICY.CONNECTIONS.NAME.MERGE_HR; + +function makePolicy(overrides: Partial = {}): Policy { + return { + ...createRandomPolicy(1), + id: policyID, + ...overrides, + }; +} + const approvalWorkflowRuleKey = `${ONYXKEYS.COLLECTION.RULE}1`; /** A rule that forwards the reports of the given workspace, which makes it one of its approval workflow rules */ @@ -43,6 +56,66 @@ describe('MergeActions', () => { jest.clearAllMocks(); }); + describe('syncMerge', () => { + it('clears a leftover isConfigurationError so an unrelated failure is not mistaken for an already-fixed one', () => { + // Given a connection whose last sync failed because of its settings + const policy = makePolicy({ + connections: { + [connectionName]: { + config: {integration: 'workday', approvalMode: null, finalApprover: null, groups: null}, + lastSync: {isAuthenticationError: false, isSuccessful: false, source: 'NEWEXPENSIFY', isConfigurationError: true}, + }, + }, + }); + + // When the admin manually syncs again + syncMerge(policy, connectionName); + + // Then both the optimistic and failure updates clear the flag, so a later unrelated failure is reported + expect(mockWrite).toHaveBeenCalledWith( + WRITE_COMMANDS.SYNC_POLICY_TO_MERGE, + {policyID, connectionName}, + expect.objectContaining({ + optimisticData: [ + expect.objectContaining({ + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: expect.objectContaining({ + connections: { + [connectionName]: expect.objectContaining({ + lastSync: expect.objectContaining({isConfigurationError: false}), + }), + }, + }), + }), + ], + failureData: [ + expect.objectContaining({ + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: expect.objectContaining({ + connections: { + [connectionName]: expect.objectContaining({ + lastSync: expect.objectContaining({isConfigurationError: false}), + }), + }, + }), + }), + ], + }), + ); + }); + + it('does nothing when the policy has no id', () => { + // Given a policy that has not finished loading + // When a sync is attempted + syncMerge(undefined, connectionName); + + // Then no API call is made + expect(mockWrite).not.toHaveBeenCalled(); + }); + }); + describe('updateMergeApprovalMode', () => { it('sends only the approval mode when no approver fields are passed', () => { // Given a Merge HR connection, which saves its final approver in its own request diff --git a/tests/unit/MiddlewareTest.ts b/tests/unit/MiddlewareTest.ts index d6a6d79da16d..53629bca8c04 100644 --- a/tests/unit/MiddlewareTest.ts +++ b/tests/unit/MiddlewareTest.ts @@ -1,4 +1,4 @@ -import {READ_COMMANDS} from '@libs/API/types'; +import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; import SaveResponseInOnyx from '@libs/Middleware/SaveResponseInOnyx'; import CONST from '@src/CONST'; @@ -104,7 +104,7 @@ describe('Middleware', () => { jest.spyOn(HttpUtils, 'xhr').mockResolvedValueOnce({jsonCode: 200, lastUpdateID: 501, previousUpdateID: 500}); await Request.processWithMiddleware({ - command: READ_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN, + command: SIDE_EFFECT_REQUEST_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN, data: {apiRequestType: CONST.API_REQUEST_TYPE.READ}, finallyData: [ { diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index 1c73ddb46634..faa97ef3cd73 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -2920,6 +2920,157 @@ describe('ReportActionsUtils', () => { }); }); + describe('isDeletedReportPreviewWithError', () => { + const errorKey = CONST.IOU.PAY_FAILURE_PREVIEW_ERROR_KEY; + // An unrelated error, keyed by the microtime it was recorded at. + const otherErrorKey = 1737000000000; + + /** A report preview marked deleted the way the server marks one when its report is removed. */ + function buildDeletedReportPreview(errors?: ReportAction['errors']): ReportAction { + return { + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + reportActionID: '1', + created: '2025-09-29', + message: [{html: 'owes $10.00', type: 'COMMENT', text: 'owes $10.00', deleted: '2025-09-30 10:00:00.000'}], + originalMessage: {linkedReportID: '2'}, + errors, + }; + } + + it('should return true for a deleted report preview that still carries an error', () => { + // Given a report preview whose report was deleted while the payer's payment sat in the offline queue, + // leaving the payment failure error behind on the preview + const reportAction = buildDeletedReportPreview({[errorKey]: 'This payment failed because the expense was deleted.'}); + + // When we ask whether it is an errored deleted preview + const actual = ReportActionsUtils.isDeletedReportPreviewWithError(reportAction); + + // Then it is, because that error is the payer's only feedback and needs somewhere to render + expect(actual).toBe(true); + }); + + it('should return false for a deleted report preview with no error', () => { + // Given an ordinary deleted report preview, i.e. the expense was deleted and nothing failed + const reportAction = buildDeletedReportPreview(); + + // When we ask whether it is an errored deleted preview + const actual = ReportActionsUtils.isDeletedReportPreviewWithError(reportAction); + + // Then it is not, so it stays hidden as before + expect(actual).toBe(false); + }); + + it('should return false for a deleted report preview whose errors were dismissed', () => { + // Given a preview whose error the payer already dismissed, which clears the values rather than the keys + const reportAction = buildDeletedReportPreview({[errorKey]: null}); + + // When we ask whether it is an errored deleted preview + const actual = ReportActionsUtils.isDeletedReportPreviewWithError(reportAction); + + // Then it is not, so dismissing removes the row instead of leaving an empty one behind + expect(actual).toBe(false); + }); + + it('should return false for a deleted preview carrying an unrelated error', () => { + // Given a deleted preview whose only error came from something other than a failed payment + const reportAction = buildDeletedReportPreview({[otherErrorKey]: 'Some other error'}); + + // When we ask whether it is an errored deleted preview + const actual = ReportActionsUtils.isDeletedReportPreviewWithError(reportAction); + + // Then it is not, so it is neither revived nor relabelled as a payment failure + expect(actual).toBe(false); + }); + + it('should hide the payment failure on a preview whose report still exists', () => { + // Given a live report preview carrying the payment failure, i.e. the payment was rejected for some reason + // other than the expense being deleted + const reportAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + reportActionID: '1', + created: '2025-09-29', + message: [{html: 'owes $10.00', type: 'COMMENT', text: 'owes $10.00'}], + originalMessage: {linkedReportID: '2'}, + errors: {[errorKey]: 'Unexpected error. Please try again later.'}, + }; + + // When we ask which errors a consumer should act on + const actual = ReportActionsUtils.getVisibleReportActionErrors(reportAction); + + // Then the payment failure is dropped, because the error still has a home on the pay action inside the + // expense report and the chat should not be red-dotted twice + expect(actual).toEqual({}); + }); + + it('should keep other errors on a preview whose report still exists', () => { + // Given a live preview carrying the error left by a failed delete + const reportAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + reportActionID: '1', + created: '2025-09-29', + message: [{html: 'owes $10.00', type: 'COMMENT', text: 'owes $10.00'}], + originalMessage: {linkedReportID: '2'}, + errors: {[otherErrorKey]: 'Unexpected error deleting this expense. Please try again later.'}, + }; + + // When we ask which errors a consumer should act on + const actual = ReportActionsUtils.getVisibleReportActionErrors(reportAction); + + // Then it is untouched, i.e. the suppression is scoped to the payment failure + expect(actual).toEqual({[otherErrorKey]: 'Unexpected error deleting this expense. Please try again later.'}); + }); + + it('should keep the payment failure once the preview is deleted', () => { + // Given the same payment failure on a preview the server has since marked deleted + const reportAction = buildDeletedReportPreview({[errorKey]: 'Unexpected error. Please try again later.'}); + + // When we ask which errors a consumer should act on + const actual = ReportActionsUtils.getVisibleReportActionErrors(reportAction); + + // Then it survives, because this is the payer's only remaining feedback + expect(actual).toEqual({[errorKey]: 'Unexpected error. Please try again later.'}); + }); + + it('should return false for a non-preview deleted action that carries an error', () => { + // Given a deleted comment with an error, which must not be revived by this rule + const reportAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + reportActionID: '1', + created: '2025-09-29', + message: [{html: '', type: 'COMMENT', text: ''}], + errors: {[errorKey]: 'Some error'}, + }; + + // When we ask whether it is an errored deleted preview + const actual = ReportActionsUtils.isDeletedReportPreviewWithError(reportAction); + + // Then it is not, because the rule is scoped to report previews + expect(actual).toBe(false); + }); + + it('should keep an errored deleted report preview visible', () => { + // Given a deleted report preview carrying a payment failure error + const reportAction = buildDeletedReportPreview({[errorKey]: 'This payment failed because the expense was deleted.'}); + + // When the report view decides whether to render it + const actual = ReportActionsUtils.shouldReportActionBeVisible(reportAction, reportAction.reportActionID, true); + + // Then it is rendered, so the RBR beneath it has a preview to attach to + expect(actual).toBe(true); + }); + + it('should keep hiding a deleted report preview with no error', () => { + // Given the same preview without an error + const reportAction = buildDeletedReportPreview(); + + // When the report view decides whether to render it + const actual = ReportActionsUtils.shouldReportActionBeVisible(reportAction, reportAction.reportActionID, true); + + // Then it stays hidden, i.e. this fix did not make every deleted preview visible + expect(actual).toBe(false); + }); + }); + describe('getPolicyChangeLogUpdateEmployee', () => { it('should remove SMS domain when the email is a phone number', () => { const email = '+919383833920@expensify.sms'; diff --git a/tests/unit/SearchEditMultipleTagPageTest.tsx b/tests/unit/SearchEditMultipleTagPageTest.tsx index 75145d17d75f..c0a249b444a2 100644 --- a/tests/unit/SearchEditMultipleTagPageTest.tsx +++ b/tests/unit/SearchEditMultipleTagPageTest.tsx @@ -17,6 +17,7 @@ import React from 'react'; import Onyx from 'react-native-onyx'; import createRandomPolicy from '../utils/collections/policies'; +import {getGlobalFetchMock} from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; const POLICY_ID = 'A1B2C3'; @@ -122,6 +123,8 @@ async function renderAndTap({ describe('SearchEditMultipleTagPage saveTag (bulk-edit tag deselect, #100538)', () => { beforeEach(async () => { + // The tag picker sends OpenPolicyTagsPage when it mounts, so mock fetch to keep that request off the real network. + global.fetch = getGlobalFetchMock(); mockSelectionList.mockClear(); mockUpdateBulkEditDraftTransaction.mockClear(); await Onyx.clear(); diff --git a/tests/unit/SessionUtilsTest.ts b/tests/unit/SessionUtilsTest.ts index 392dd016dc60..6c6b89e430bc 100644 --- a/tests/unit/SessionUtilsTest.ts +++ b/tests/unit/SessionUtilsTest.ts @@ -1,4 +1,4 @@ -import {checkIfShouldUseNewPartnerName, getPartnerCredentials, isAgentEmail, isLoggingInAsDelegate} from '@src/libs/SessionUtils'; +import {checkIfShouldUseNewPartnerName, getEmailFromTransitionURL, getPartnerCredentials, isAgentEmail, isLoggingInAsDelegate, isLoggingInAsNewUser} from '@src/libs/SessionUtils'; function mockHybridAppConfig(isHybridApp: boolean): () => void { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -111,4 +111,51 @@ describe('SessionUtils', () => { expect(isLoggingInAsDelegate(transitionURL)).toBe(expectedResult); }); }); + + describe('isLoggingInAsNewUser', () => { + test.each([ + ['should return false when email matches sessionEmail directly', '?email=user@example.com', 'user@example.com', false], + ['should return false when delegatorEmail matches sessionEmail directly', '?delegatorEmail=user@example.com', 'user@example.com', false], + ['should return true when email does not match sessionEmail', '?email=other@example.com', 'user@example.com', true], + ['should return true when transitionURL has no email or delegatorEmail param', '?shortLivedAuthToken=abc123', 'user@example.com', true], + ['should return true when transitionURL is undefined', undefined, 'user@example.com', true], + [ + 'should return false for full URL where URLSearchParams mangles the first param key but the regex-extracted email matches', + 'https://example.com?email=user@example.com', + 'user@example.com', + false, + ], + ['should return true for full URL where the regex-extracted email does not match sessionEmail', 'https://example.com?email=other@example.com', 'user@example.com', true], + [ + 'should return false for full URL where the regex-extracted delegatorEmail matches sessionEmail', + 'https://example.com?delegatorEmail=user@example.com', + 'user@example.com', + false, + ], + ['should return true when the encoded email does not match sessionEmail', '?email=other%40example.com', 'user@example.com', true], + ['should return false when the encoded email matches sessionEmail after decoding', '?email=user%40example.com', 'user@example.com', false], + ])('%s', (_description, transitionURL, sessionEmail, expectedResult) => { + expect(isLoggingInAsNewUser(transitionURL, sessionEmail)).toBe(expectedResult); + }); + }); + + describe('getEmailFromTransitionURL', () => { + test.each([ + ['should return undefined when url is undefined', undefined, undefined], + ['should return undefined when url is empty', '', undefined], + ['should return undefined when url has no query string', 'https://example.com/transition', undefined], + ['should return undefined when email param is absent', '?shortLivedAuthToken=abc123', undefined], + ['should return the email when present as the only param', '?email=user@example.com', 'user@example.com'], + ['should return the email when it is a subsequent param', '?shortLivedAuthToken=abc123&email=user@example.com', 'user@example.com'], + ['should stop at the next query param delimiter', '?email=user@example.com&exitTo=%2Fhome', 'user@example.com'], + ['should return the decoded email for a full URL where URLSearchParams would mangle the first param key', 'https://example.com?email=user%40example.com', 'user@example.com'], + ['should decode a plus-encoded email', '?email=user%2Btag%40example.com', 'user+tag@example.com'], + ['should return the raw value unchanged when it contains an un-encoded percent that is not a valid escape sequence', '?email=100%off@example.com', '100%off@example.com'], + ['should ignore a fragment after the query string', '?email=user@example.com#section', 'user@example.com'], + ['should return undefined when email only appears after a hash fragment', '?shortLivedAuthToken=abc123#email=user@example.com', undefined], + ['should strip a zero-width space from the email', '?email=user@exa​mple.com', 'user@example.com'], + ])('%s', (_description, url, expectedResult) => { + expect(getEmailFromTransitionURL(url)).toBe(expectedResult); + }); + }); }); diff --git a/tests/unit/SignUpWelcomeFormTest.tsx b/tests/unit/SignUpWelcomeFormTest.tsx index 251eaff59663..57630c297766 100644 --- a/tests/unit/SignUpWelcomeFormTest.tsx +++ b/tests/unit/SignUpWelcomeFormTest.tsx @@ -107,7 +107,7 @@ describe('SignUpWelcomeForm', () => { fireEvent.press(screen.getByLabelText('I agree to receive marketing texts from Expensify')); fireEvent.press(screen.getByText('Join')); - expect(signUpUserSpy).toHaveBeenCalledWith(PHONE_LOGIN, undefined, true); + expect(signUpUserSpy).toHaveBeenCalledWith(PHONE_LOGIN, undefined, true, undefined); }); it('passes hasSMSMarketingConsent=false to signUpUser when checkbox is unchecked and Join is pressed', async () => { @@ -117,7 +117,7 @@ describe('SignUpWelcomeForm', () => { fireEvent.press(screen.getByText('Join')); - expect(signUpUserSpy).toHaveBeenCalledWith(PHONE_LOGIN, undefined, false); + expect(signUpUserSpy).toHaveBeenCalledWith(PHONE_LOGIN, undefined, false, undefined); }); it('passes hasSMSMarketingConsent=false when checkbox is checked then unchecked', async () => { @@ -129,7 +129,7 @@ describe('SignUpWelcomeForm', () => { fireEvent.press(screen.getByLabelText('I agree to receive marketing texts from Expensify')); fireEvent.press(screen.getByText('Join')); - expect(signUpUserSpy).toHaveBeenCalledWith(PHONE_LOGIN, undefined, false); + expect(signUpUserSpy).toHaveBeenCalledWith(PHONE_LOGIN, undefined, false, undefined); }); it('omits the consent param when signing up with an email', async () => { @@ -139,6 +139,24 @@ describe('SignUpWelcomeForm', () => { fireEvent.press(screen.getByText('Join')); - expect(signUpUserSpy).toHaveBeenCalledWith(EMAIL_LOGIN, undefined, undefined); + expect(signUpUserSpy).toHaveBeenCalledWith(EMAIL_LOGIN, undefined, undefined, undefined); + }); + + it('passes the stored marketing attribution to signUpUser', async () => { + // Given marketing attribution captured from a Google ad + // eslint-disable-next-line @typescript-eslint/naming-convention + const attribution = {utm_source: 'google', gclid: 'testGclid'}; + await setCredentialsLogin(EMAIL_LOGIN); + await act(async () => { + await Onyx.set(ONYXKEYS.MARKETING_ATTRIBUTION, attribution); + }); + await renderForm(); + await waitForBatchedUpdatesWithAct(); + + // When the user presses Join + fireEvent.press(screen.getByText('Join')); + + // Then the attribution is passed to signUpUser + expect(signUpUserSpy).toHaveBeenCalledWith(EMAIL_LOGIN, undefined, undefined, attribution); }); }); diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 6dca6b46da92..4c578eb45c51 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -4038,6 +4038,41 @@ describe('TransactionUtils', () => { }); }); + describe('getConvertedTaxAmount', () => { + it('should return the absolute converted tax amount if the transaction is not from an expense report', () => { + // Given an IOU transaction whose converted tax is stored as a negative value + const transaction = generateTransaction({convertedTaxAmount: -100}); + + // When we read the converted tax amount for a non-expense report + const convertedTaxAmount = TransactionUtils.getConvertedTaxAmount(transaction, false); + + // Then it is returned unsigned, because IOU requests cannot have negative values + expect(convertedTaxAmount).toBe(100); + }); + + it('should return the opposite sign if the transaction is from an expense report', () => { + // Given an expense-report transaction whose converted tax is stored with the opposite sign of what we display + const transaction = generateTransaction({convertedTaxAmount: 182}); + + // When we read the converted tax amount for an expense report + const convertedTaxAmount = TransactionUtils.getConvertedTaxAmount(transaction, true); + + // Then the sign is flipped back, so a refund's tax displays as negative + expect(convertedTaxAmount).toBe(-182); + }); + + it('should return 0 instead of -0 when there is no converted tax amount', () => { + // Given an expense-report transaction with no converted tax + const transaction = generateTransaction({convertedTaxAmount: 0}); + + // When we read the converted tax amount + const convertedTaxAmount = TransactionUtils.getConvertedTaxAmount(transaction, true); + + // Then we get 0 rather than -0, so we never render "-0.00" + expect(Object.is(convertedTaxAmount, 0)).toBe(true); + }); + }); + describe('hasTaxRateWithMatchingValue', () => { const policy: Policy = { ...createRandomPolicy(0), diff --git a/tests/unit/WorkflowUtilsTest.ts b/tests/unit/WorkflowUtilsTest.ts index b69ef798b0c1..9c7fe114808f 100644 --- a/tests/unit/WorkflowUtilsTest.ts +++ b/tests/unit/WorkflowUtilsTest.ts @@ -14,10 +14,12 @@ import { getApprovalLimitDescription, getOpenConnectedToPolicyBusinessBankAccounts, getApprovalWorkflowSource, + getNonMemberApproverError, getOverLimitForwardsToDisplayName, getRulesSubmitterToFirstApprover, getRulesSubmitterToWorkflowKey, hasApprovalWorkflowRules, + hasApprovalWorkflowWithNonMemberApprover, includesEveryWorkspaceMember, isApprovalWorkflowLockedByIntegration, mergeWorkflowMembersWithAvailableMembers, @@ -366,6 +368,28 @@ describe('WorkflowUtils', () => { expect(approvers).toEqual([buildApprover(1, {approvalLimit: null, overLimitForwardsTo: ''})]); }); + + it('Should keep an approver who left the workspace at the end of the chain, flagged, when the policy is passed', () => { + // Given a chain 1 > 2 > 9 where 9 is no longer on the workspace + const employees: PolicyEmployeeList = { + '1@example.com': {email: '1@example.com', forwardsTo: '2@example.com'}, + '2@example.com': {email: '2@example.com', forwardsTo: '9@example.com'}, + }; + const policy = createMock({id: '1', owner: '1@example.com', employeeList: employees}); + + // When the chain is calculated with and without the policy + const approversWithPolicy = calculateApprovers({employees, firstEmail: '1@example.com', personalDetailsByEmail, policy}); + const approversWithoutPolicy = calculateApprovers({employees, firstEmail: '1@example.com', personalDetailsByEmail}); + + // Then with the policy, 9 is kept and flagged so the admin can see 2 still forwards to them, and without it + // the chain stops before 9 as callers that don't show flags expect + expect(approversWithPolicy).toEqual([ + buildApprover(1, {forwardsTo: '2@example.com'}), + buildApprover(2, {forwardsTo: '9@example.com'}), + buildApprover(9, {isNotWorkspaceMember: true}), + ]); + expect(approversWithoutPolicy).toEqual([buildApprover(1, {forwardsTo: '2@example.com'}), buildApprover(2, {forwardsTo: '9@example.com'})]); + }); }); describe('getOverLimitForwardsToDisplayName', () => { @@ -421,7 +445,8 @@ describe('WorkflowUtils', () => { expect(approvalWorkflows).toEqual([]); }); - it('Should not include users that submit to non-employee user', () => { + it('Should show users that submit to a non-employee user under that approver, flagged as not a workspace member', () => { + // Given a member who submits to someone no longer on the workspace const employees: PolicyEmployeeList = { '1@example.com': { email: '1@example.com', @@ -432,13 +457,103 @@ describe('WorkflowUtils', () => { const defaultApprover = '1@example.com'; const policy = createMockPolicy(employees, defaultApprover); - const {approvalWorkflows} = convertPolicyEmployeesToApprovalWorkflows({ - policy, - personalDetails, - localeCompare, + // When the workflows are built + const {approvalWorkflows} = convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, localeCompare}); + + // Then the member shows in a workflow under that approver, flagged so the admin can see and fix it, + // instead of being hidden from every workflow + expect(approvalWorkflows).toEqual([ + buildWorkflow([], [1], {isDefault: true}), + {members: [buildMember(1)], approvers: [buildApprover(2, {isNotWorkspaceMember: true})], isDefault: false, pendingAction: undefined}, + ]); + }); + + it('Should not flag a non-employee approver in HR advanced mode', () => { + // Given an HR integration in advanced (manager) mode, whose final approver doesn't have to be a member + const employees: PolicyEmployeeList = { + '1@example.com': { + email: '1@example.com', + submitsTo: '2@example.com', + }, + }; + const policy = createMock({ + ...createMockPolicy(employees, '1@example.com'), + connections: { + [CONST.POLICY.CONNECTIONS.NAME.MERGE_HR]: { + config: {approvalMode: CONST.MERGE.APPROVAL_MODE.MANAGER, finalApprover: '3@example.com', integration: 'workday', groups: []}, + }, + }, }); - expect(approvalWorkflows).toEqual([]); + // When the workflows are built + const {approvalWorkflows} = convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, localeCompare}); + + // Then the non-member approver isn't flagged, since HR mode routes outside employeeList on purpose + const approvers = approvalWorkflows.flatMap((workflow) => workflow.approvers); + expect(approvers.some((approver) => approver.email === '2@example.com')).toBe(true); + expect(approvers.some((approver) => approver.isNotWorkspaceMember)).toBe(false); + }); + + it('Should keep a non-employee Expensify team approver when Expensify team members are filtered out', () => { + // Given a customer workspace where a member submits to an Expensify team email that is no longer a member + const employees: PolicyEmployeeList = { + 'alice@example.com': { + email: 'alice@example.com', + submitsTo: 'guide@expensify.com', + }, + 'bob@example.com': { + email: 'bob@example.com', + submitsTo: 'bob@example.com', + }, + }; + const policy = createMock({ + ...createMockPolicy(employees, 'bob@example.com'), + owner: 'bob@example.com', + }); + + // When the workflows are built for a non-Expensify user + const {approvalWorkflows} = convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, localeCompare, currentUserLogin: 'bob@example.com'}); + + // Then the broken workflow keeps its flagged approver instead of ending up with no approvers + const brokenWorkflow = approvalWorkflows.find((workflow) => workflow.members.some((member) => member.email === 'alice@example.com')); + expect(brokenWorkflow?.approvers).toHaveLength(1); + expect(brokenWorkflow?.approvers.at(0)).toMatchObject({email: 'guide@expensify.com', isNotWorkspaceMember: true}); + }); + + it('Should flag an approver later in the chain who is no longer a workspace member', () => { + // Given a workflow 1 > 2 > 9 for member 3, where 9 was removed without 2's forwardsTo being updated + const employees: PolicyEmployeeList = { + '1@example.com': {email: '1@example.com', submitsTo: '1@example.com', forwardsTo: '2@example.com'}, + '2@example.com': {email: '2@example.com', submitsTo: '1@example.com', forwardsTo: '9@example.com'}, + '3@example.com': {email: '3@example.com', submitsTo: '1@example.com'}, + }; + const policy = createMockPolicy(employees, '1@example.com'); + + // When the workflows are built + const {approvalWorkflows} = convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, localeCompare}); + + // Then 9 is shown flagged at the end of the chain, instead of the workflow looking like it ends at 2 + expect(approvalWorkflows.at(0)?.approvers).toEqual([ + buildApprover(1, {forwardsTo: '2@example.com'}), + buildApprover(2, {forwardsTo: '9@example.com'}), + buildApprover(9, {isNotWorkspaceMember: true}), + ]); + }); + + it('Should not flag the owner as a non-member approver when their employee entry is missing', () => { + // Given a member who submits to the owner, whose employeeList entry is missing + const employees: PolicyEmployeeList = { + '1@example.com': {email: '1@example.com', submitsTo: 'owner@example.com'}, + }; + const policy = createMockPolicy(employees, '1@example.com'); + + // When the workflows are built + const {approvalWorkflows} = convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, localeCompare}); + + // Then the member is shown under the owner, who isn't flagged since the owner always belongs to the workspace + const ownerWorkflow = approvalWorkflows.find((workflow) => workflow.members.some((member) => member.email === '1@example.com')); + expect(ownerWorkflow?.approvers.map((approver) => approver.email)).toEqual(['owner@example.com']); + expect(ownerWorkflow?.approvers.at(0)?.isNotWorkspaceMember).toBeUndefined(); }); it('Should transform all users into one default workflow', () => { @@ -703,8 +818,7 @@ describe('WorkflowUtils', () => { }); it('Should include members with orphaned submitsTo in availableMembers', () => { - // Member with submitsTo pointing to non-member (not in employeeList) won't appear in any - // workflow, but should still appear in availableMembers so admins can fix the chain. + // Given a member with submitsTo pointing to a non-member (not in employeeList) const employees: PolicyEmployeeList = { 'alice@example.com': { email: 'alice@example.com', @@ -729,14 +843,18 @@ describe('WorkflowUtils', () => { }, }; + // When the workflows are built const {approvalWorkflows, availableMembers} = convertPolicyEmployeesToApprovalWorkflows({ policy, personalDetails: personalDetailsForTest, localeCompare, }); - expect(approvalWorkflows).toHaveLength(1); - expect(approvalWorkflows.at(0)?.members).toHaveLength(1); + // Then the member shows in their own flagged workflow and is still selectable in availableMembers, so admins can fix the chain + expect(approvalWorkflows).toHaveLength(2); + expect(approvalWorkflows.at(0)?.members.map((member) => member.email)).toEqual(['bob@example.com']); + expect(approvalWorkflows.at(1)?.members.map((member) => member.email)).toEqual(['alice@example.com']); + expect(approvalWorkflows.at(1)?.approvers.at(0)).toMatchObject({email: 'nonexistent@example.com', isNotWorkspaceMember: true}); const memberEmails = availableMembers.map((m) => m.email).sort(); expect(memberEmails).toEqual(['alice@example.com', 'bob@example.com']); }); @@ -1392,9 +1510,51 @@ describe('WorkflowUtils', () => { overLimitForwardsTo: 'update', }); }); + + it('Should not send an employee entry for an approver who is not a workspace member', () => { + // Given a workflow whose approver is no longer on the workspace, as the Members pages pass it when another approver is removed + const previousEmployeeList: PolicyEmployeeList = { + '1@example.com': {email: '1@example.com', submitsTo: '9@example.com'}, + }; + const approvalWorkflow: ApprovalWorkflow = { + members: [buildMember(1)], + approvers: [buildApprover(9, {isNotWorkspaceMember: true})], + isDefault: false, + }; + + // When it is converted for an update, and again with that approver being replaced + const convertedEmployees = convertApprovalWorkflowToPolicyEmployees({previousEmployeeList, approvalWorkflow, type: 'update'}); + const replacedEmployees = convertApprovalWorkflowToPolicyEmployees({ + previousEmployeeList, + approvalWorkflow: {...approvalWorkflow, approvers: [buildApprover(2)]}, + approversToRemove: approvalWorkflow.approvers, + type: 'update', + }); + + // Then no entry is created for the non-member, which would otherwise add a fake employee + expect(convertedEmployees).toEqual({}); + expect(replacedEmployees['9@example.com']).toBeUndefined(); + expect(replacedEmployees['1@example.com']?.submitsTo).toBe('2@example.com'); + }); }); describe('updateWorkflowDataOnApproverRemoval', () => { + it('Should fall back to the owner email when the owner has no personal details', () => { + // Given a workflow whose only approver is removed, while the owner's personal details aren't loaded + const approvalWorkflow1 = buildWorkflow([], [3], {isDefault: true}); + const approvalWorkflow2 = buildWorkflow([1], [2]); + + // When the approver is removed + const result = updateWorkflowDataOnApproverRemoval({ + approvalWorkflows: [approvalWorkflow1, approvalWorkflow2], + removedApproverEmail: '2@example.com', + ownerEmail: 'owner@example.com', + }); + + // Then the owner still replaces the removed approver, so the members are reassigned instead of left on a non-member + expect(result.at(1)?.approvers).toEqual([{...buildApprover(2), email: 'owner@example.com', avatar: '', displayName: 'owner@example.com'}]); + }); + it('Should remove Workflow 2 if its approvers are removed and it has no approvers, with Workspace (default) having the approver as the Workspace Owner.', () => { const approvalWorkflow1: ApprovalWorkflow = { members: [buildMember(1), buildMember(2)], @@ -1416,7 +1576,8 @@ describe('WorkflowUtils', () => { const updateWorkflowDataOnApproverRemovalResult = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1, approvalWorkflow2], - removedApprover, + removedApproverEmail: removedApprover.login ?? '', + ownerEmail: ownerDetails.login ?? '', ownerDetails, }); @@ -1443,7 +1604,8 @@ describe('WorkflowUtils', () => { const updateWorkflowDataOnApproverRemovalResult = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1, approvalWorkflow2], - removedApprover, + removedApproverEmail: removedApprover.login ?? '', + ownerEmail: ownerDetails.login ?? '', ownerDetails, }); @@ -1470,7 +1632,8 @@ describe('WorkflowUtils', () => { const updateWorkflowDataOnApproverRemovalResult = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1, approvalWorkflow2], - removedApprover, + removedApproverEmail: removedApprover.login ?? '', + ownerEmail: ownerDetails.login ?? '', ownerDetails, }); @@ -1500,7 +1663,8 @@ describe('WorkflowUtils', () => { const updateWorkflowDataOnApproverRemovalResult = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1, approvalWorkflow2], - removedApprover, + removedApproverEmail: removedApprover.login ?? '', + ownerEmail: ownerDetails.login ?? '', ownerDetails, }); @@ -1533,7 +1697,8 @@ describe('WorkflowUtils', () => { const updateWorkflowDataOnApproverRemovalResult = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1, approvalWorkflow2], - removedApprover, + removedApproverEmail: removedApprover.login ?? '', + ownerEmail: ownerDetails.login ?? '', ownerDetails, }); @@ -1566,7 +1731,8 @@ describe('WorkflowUtils', () => { const updateWorkflowDataOnApproverRemovalResult = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1, approvalWorkflow2], - removedApprover, + removedApproverEmail: removedApprover.login ?? '', + ownerEmail: ownerDetails.login ?? '', ownerDetails, }); @@ -1590,7 +1756,8 @@ describe('WorkflowUtils', () => { const result = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1], - removedApprover, + removedApproverEmail: removedApprover.login ?? '', + ownerEmail: ownerDetails.login ?? '', ownerDetails, }); @@ -1626,7 +1793,8 @@ describe('WorkflowUtils', () => { const result = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1], - removedApprover, + removedApproverEmail: removedApprover.login ?? '', + ownerEmail: ownerDetails.login ?? '', ownerDetails, }); @@ -1654,7 +1822,8 @@ describe('WorkflowUtils', () => { const result = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1], - removedApprover, + removedApproverEmail: removedApprover.login ?? '', + ownerEmail: ownerDetails.login ?? '', ownerDetails, }); @@ -1690,7 +1859,8 @@ describe('WorkflowUtils', () => { const result = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1], - removedApprover, + removedApproverEmail: removedApprover.login ?? '', + ownerEmail: ownerDetails.login ?? '', ownerDetails, }); @@ -1718,7 +1888,8 @@ describe('WorkflowUtils', () => { const result = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1], - removedApprover, + removedApproverEmail: removedApprover.login ?? '', + ownerEmail: ownerDetails.login ?? '', ownerDetails, }); @@ -1755,7 +1926,8 @@ describe('WorkflowUtils', () => { const result = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1], - removedApprover, + removedApproverEmail: removedApprover.login ?? '', + ownerEmail: ownerDetails.login ?? '', ownerDetails, }); @@ -1799,7 +1971,8 @@ describe('WorkflowUtils', () => { const result = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [approvalWorkflow1], - removedApprover, + removedApproverEmail: removedApprover.login ?? '', + ownerEmail: ownerDetails.login ?? '', ownerDetails, }); @@ -1833,7 +2006,8 @@ describe('WorkflowUtils', () => { const result = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: [emptyDefaultWorkflow, approvalWorkflow], - removedApprover, + removedApproverEmail: removedApprover.login ?? '', + ownerEmail: ownerDetails.login ?? '', ownerDetails, }); @@ -2526,6 +2700,25 @@ describe('WorkflowUtils', () => { approver: defaultApprover, }); + it('Should show a member who submits to a non-member under that approver, flagged', () => { + // Given a member no rule covers, whose submitsTo points at someone no longer on the workspace + const employees: PolicyEmployeeList = { + '1@example.com': {email: '1@example.com', submitsTo: '1@example.com'}, + '5@example.com': {email: '5@example.com', submitsTo: '9@example.com'}, + }; + const policy = createPolicy(employees, '1@example.com'); + + // When the workflows are built from rules + const {approvalWorkflows} = convertApprovalWorkflowRulesToWorkflows({policy, personalDetails, localeCompare, rules: {}}); + + // Then the member is shown under the non-member approver, flagged, instead of being hidden + const brokenWorkflow = approvalWorkflows.find((workflow) => workflow.members.some((member) => member.email === '5@example.com')); + expect(brokenWorkflow?.isDefault).toBe(false); + expect(brokenWorkflow?.approvers).toHaveLength(1); + expect(brokenWorkflow?.approvers.at(0)).toMatchObject({email: '9@example.com', isNotWorkspaceMember: true}); + expect(approvalWorkflows.find((workflow) => workflow.isDefault)?.approvers.at(0)?.isNotWorkspaceMember).toBeUndefined(); + }); + it('Should reconstruct a multi-approver chain from rules', () => { const rules = keyRules(buildApprovalWorkflowRules(buildWorkflow([5], [1, 2]))); const employees: PolicyEmployeeList = { @@ -3017,6 +3210,241 @@ describe('WorkflowUtils', () => { }); }); + describe('hasApprovalWorkflowWithNonMemberApprover', () => { + const ownerEmail = 'owner@example.com'; + const removedEmail = '9@example.com'; + const buildPolicy = (policy: Partial = {}) => + createMock({ + id: '1', + type: CONST.POLICY.TYPE.CORPORATE, + role: CONST.POLICY.ROLE.ADMIN, + owner: ownerEmail, + approver: ownerEmail, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + employeeList: { + [ownerEmail]: {email: ownerEmail, role: CONST.POLICY.ROLE.ADMIN, submitsTo: ownerEmail}, + '1@example.com': {email: '1@example.com', role: CONST.POLICY.ROLE.USER, submitsTo: removedEmail}, + }, + ...policy, + }); + const keyRules = (rules: ApprovalWorkflowRule[]): Record => Object.fromEntries(rules.map((rule, index) => [`rule${index}`, rule])); + + it('flags a workspace where a member submits to someone no longer on it', () => { + // Given an advanced workspace where member 1's approver was removed without reassigning them + const policy = buildPolicy(); + + // When the owner checks whether a workflow needs fixing + // Then it does, since the Workflows page shows that workflow flagged + expect(hasApprovalWorkflowWithNonMemberApprover({policy, currentUserLogin: ownerEmail, isMultipleApproversBetaEnabled: false})).toBe(true); + }); + + it('flags an approver later in the chain who is no longer on the workspace', () => { + // Given everyone submits to the owner, who forwards to someone no longer on the workspace + const policy = buildPolicy({ + employeeList: { + [ownerEmail]: {email: ownerEmail, role: CONST.POLICY.ROLE.ADMIN, submitsTo: ownerEmail, forwardsTo: removedEmail}, + '1@example.com': {email: '1@example.com', role: CONST.POLICY.ROLE.USER, submitsTo: ownerEmail}, + }, + }); + + // When the owner checks whether a workflow needs fixing + // Then it does, matching the flagged approver shown at the end of the chain + expect(hasApprovalWorkflowWithNonMemberApprover({policy, currentUserLogin: ownerEmail, isMultipleApproversBetaEnabled: false})).toBe(true); + }); + + it('flags a non-member that only a later approver forwards to', () => { + // Given everyone submits to the owner, who forwards to member 1, and only member 1 forwards to someone no + // longer on the workspace, so the non-member is reachable through a forwardsTo alone + const policy = buildPolicy({ + employeeList: { + [ownerEmail]: {email: ownerEmail, role: CONST.POLICY.ROLE.ADMIN, submitsTo: ownerEmail, forwardsTo: '1@example.com'}, + '1@example.com': {email: '1@example.com', role: CONST.POLICY.ROLE.ADMIN, submitsTo: ownerEmail, forwardsTo: removedEmail}, + '2@example.com': {email: '2@example.com', role: CONST.POLICY.ROLE.USER, submitsTo: ownerEmail}, + }, + }); + + // When the owner checks whether a workflow needs fixing + // Then it does, since the quick check over every submitsTo and forwardsTo still finds the non-member + expect(hasApprovalWorkflowWithNonMemberApprover({policy, currentUserLogin: ownerEmail, isMultipleApproversBetaEnabled: false})).toBe(true); + }); + + it('flags a non-member that reports over an approval limit forward to', () => { + // Given every submitsTo and forwardsTo points at a member, but reports over the owner's approval limit go + // to someone no longer on the workspace + const policy = buildPolicy({ + employeeList: { + [ownerEmail]: {email: ownerEmail, role: CONST.POLICY.ROLE.ADMIN, submitsTo: ownerEmail, approvalLimit: 1000, overLimitForwardsTo: removedEmail}, + '1@example.com': {email: '1@example.com', role: CONST.POLICY.ROLE.USER, submitsTo: ownerEmail}, + }, + }); + + // When the owner checks whether a workflow needs fixing + // Then it does, since reports over the limit still route to the non-member + expect(hasApprovalWorkflowWithNonMemberApprover({policy, currentUserLogin: ownerEmail, isMultipleApproversBetaEnabled: false})).toBe(true); + + // And the workflow flags that approver so the page shows the error on them + const {approvalWorkflows} = convertPolicyEmployeesToApprovalWorkflows({policy, personalDetails, localeCompare}); + expect(approvalWorkflows.at(0)?.approvers.at(0)).toMatchObject({email: ownerEmail, isOverLimitForwardsToNotWorkspaceMember: true}); + }); + + it('does not flag a workspace where every route points at a member', () => { + // Given the owner isn't on employeeList but is still the default approver, and every submitsTo and + // forwardsTo points at someone on the workspace. The same workspace with one forwardsTo pointing at a + // removed approver is the control, so the result isn't coming from the permission checks. + const employeeList: PolicyEmployeeList = { + '1@example.com': {email: '1@example.com', role: CONST.POLICY.ROLE.ADMIN, submitsTo: ownerEmail, forwardsTo: '2@example.com'}, + '2@example.com': {email: '2@example.com', role: CONST.POLICY.ROLE.USER, submitsTo: '1@example.com'}, + }; + const policy = buildPolicy({employeeList}); + const brokenPolicy = buildPolicy({employeeList: {...employeeList, '2@example.com': {...employeeList['2@example.com'], forwardsTo: removedEmail}}}); + + // When an admin checks whether a workflow needs fixing + // Then only the control is flagged, since the owner always counts as a member and no other route points + // at a non-member + expect(hasApprovalWorkflowWithNonMemberApprover({policy, currentUserLogin: '1@example.com', isMultipleApproversBetaEnabled: false})).toBe(false); + expect(hasApprovalWorkflowWithNonMemberApprover({policy: brokenPolicy, currentUserLogin: '1@example.com', isMultipleApproversBetaEnabled: false})).toBe(true); + }); + + it('does not flag an Expensify team member that a customer admin does not see', () => { + // Given a guide who submits to someone no longer on a customer's workspace + const policy = buildPolicy({ + employeeList: { + [ownerEmail]: {email: ownerEmail, role: CONST.POLICY.ROLE.ADMIN, submitsTo: ownerEmail}, + 'guide@team.expensify.com': {email: 'guide@team.expensify.com', role: CONST.POLICY.ROLE.ADMIN, submitsTo: removedEmail}, + }, + }); + + // When the customer owner and an Expensify agent check whether a workflow needs fixing + // Then only the agent, who sees the guide's workflow, is told to fix it + expect(hasApprovalWorkflowWithNonMemberApprover({policy, currentUserLogin: ownerEmail, isMultipleApproversBetaEnabled: false})).toBe(false); + expect(hasApprovalWorkflowWithNonMemberApprover({policy, currentUserLogin: 'agent@expensify.com', isMultipleApproversBetaEnabled: false})).toBe(true); + }); + + it('only flags the workflows the approval mode enforces', () => { + // Given a basic workspace where a member still submits to someone no longer on it, and another where the + // default approver itself is no longer on it + const basicPolicy = buildPolicy({approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC}); + const basicPolicyWithRemovedDefaultApprover = buildPolicy({ + approvalMode: CONST.POLICY.APPROVAL_MODE.BASIC, + approver: removedEmail, + employeeList: { + [ownerEmail]: {email: ownerEmail, role: CONST.POLICY.ROLE.ADMIN, submitsTo: ownerEmail}, + }, + }); + + // When the owner checks whether a workflow needs fixing + // Then only the removed default approver is flagged, since basic mode runs only the default workflow + expect(hasApprovalWorkflowWithNonMemberApprover({policy: basicPolicy, currentUserLogin: ownerEmail, isMultipleApproversBetaEnabled: false})).toBe(false); + expect(hasApprovalWorkflowWithNonMemberApprover({policy: basicPolicyWithRemovedDefaultApprover, currentUserLogin: ownerEmail, isMultipleApproversBetaEnabled: false})).toBe(true); + }); + + it('routes from the rules under the multiple approvers beta', () => { + // Given member 1 has a stale submitsTo but a rule routing them to the owner, and member 2 has a rule chain + // whose second approver is no longer on the workspace + const coveredPolicy = buildPolicy(); + const coveredRules = keyRules(buildApprovalWorkflowRules({members: [buildMember(1)], approvers: [buildApprover(1, {email: ownerEmail})], isDefault: false})); + const brokenChainPolicy = buildPolicy({ + employeeList: { + [ownerEmail]: {email: ownerEmail, role: CONST.POLICY.ROLE.ADMIN, submitsTo: ownerEmail}, + '2@example.com': {email: '2@example.com', role: CONST.POLICY.ROLE.USER, submitsTo: ownerEmail}, + }, + }); + const brokenChainRules = keyRules( + buildApprovalWorkflowRules({members: [buildMember(2)], approvers: [buildApprover(1, {email: ownerEmail}), buildApprover(9)], isDefault: false}), + ); + + // When the owner checks whether a workflow needs fixing + // Then the rules decide: the covered member isn't flagged, and the non-member in the chain is + expect(hasApprovalWorkflowWithNonMemberApprover({policy: coveredPolicy, currentUserLogin: ownerEmail, rules: coveredRules, isMultipleApproversBetaEnabled: true})).toBe(false); + expect(hasApprovalWorkflowWithNonMemberApprover({policy: brokenChainPolicy, currentUserLogin: ownerEmail, rules: brokenChainRules, isMultipleApproversBetaEnabled: true})).toBe( + true, + ); + }); + + it('flags a rule that sends reports over the limit to a non-member under the multiple approvers beta', () => { + // Given member 2 is routed by a rule to the owner, whose reports over the limit go to someone no longer on + // the workspace + const policy = buildPolicy({ + employeeList: { + [ownerEmail]: {email: ownerEmail, role: CONST.POLICY.ROLE.ADMIN, submitsTo: ownerEmail}, + '2@example.com': {email: '2@example.com', role: CONST.POLICY.ROLE.USER, submitsTo: ownerEmail}, + }, + }); + const rules = keyRules( + buildApprovalWorkflowRules({ + members: [buildMember(2)], + approvers: [buildApprover(1, {email: ownerEmail, approvalLimit: 1000, overLimitForwardsTo: removedEmail})], + isDefault: false, + }), + ); + + // When the owner checks whether a workflow needs fixing + // Then it does, since reports over the limit still route to the non-member + expect(hasApprovalWorkflowWithNonMemberApprover({policy, currentUserLogin: ownerEmail, rules, isMultipleApproversBetaEnabled: true})).toBe(true); + }); + + it('does not flag a workspace the user cannot fix', () => { + // Given the same broken workflow, seen by a member without write access to approvals, with approvals off, + // and with the workflows owned by a connected recruiting integration + const policy = buildPolicy({ + employeeList: { + [ownerEmail]: {email: ownerEmail, role: CONST.POLICY.ROLE.ADMIN, submitsTo: ownerEmail}, + '1@example.com': {email: '1@example.com', role: CONST.POLICY.ROLE.USER, submitsTo: removedEmail}, + '2@example.com': {email: '2@example.com', role: CONST.POLICY.ROLE.USER, submitsTo: ownerEmail}, + }, + }); + const approvalsOffPolicy = buildPolicy({approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL}); + const lockedPolicy = buildPolicy({ + connections: { + [CONST.POLICY.CONNECTIONS.NAME.MERGE_ATS]: { + config: { + integration: 'greenhouse', + approvalMode: CONST.MERGE.APPROVAL_MODE.BASIC, + approverField: CONST.MERGE.ATS_APPROVER_FIELD.RECRUITER, + finalApprover: null, + filters: null, + }, + }, + }, + }); + + // When each checks whether a workflow needs fixing + // Then none is flagged, since the Workflows page gives none of them an Edit button to fix it + expect( + hasApprovalWorkflowWithNonMemberApprover({policy: {...policy, role: CONST.POLICY.ROLE.USER}, currentUserLogin: '2@example.com', isMultipleApproversBetaEnabled: false}), + ).toBe(false); + expect(hasApprovalWorkflowWithNonMemberApprover({policy: approvalsOffPolicy, currentUserLogin: ownerEmail, isMultipleApproversBetaEnabled: false})).toBe(false); + expect(hasApprovalWorkflowWithNonMemberApprover({policy: lockedPolicy, currentUserLogin: ownerEmail, isMultipleApproversBetaEnabled: false})).toBe(false); + }); + }); + + describe('getNonMemberApproverError', () => { + it('only offers deleting the workflow when it is not the default one', () => { + // Given a flagged approver on a custom workflow and on the default workflow + // When the error copy is picked + // Then the default workflow, which can't be deleted, only asks for a new approver + const removedApprover = buildApprover(9, {isNotWorkspaceMember: true}); + expect(getNonMemberApproverError(removedApprover, false)).toBe('workflowsPage.approverNotWorkspaceMember'); + expect(getNonMemberApproverError(removedApprover, true)).toBe('workflowsPage.defaultWorkflowApproverNotWorkspaceMember'); + }); + + it('asks for a new additional approver when only the over-limit one left the workspace', () => { + // Given an approver who is a member, but whose additional approver for reports over the limit is not + const approver = buildApprover(1, {approvalLimit: 1000, overLimitForwardsTo: '9@example.com', isOverLimitForwardsToNotWorkspaceMember: true}); + + // When the error copy is picked + // Then it points at the additional approver rather than at the first approver + expect(getNonMemberApproverError(approver, false)).toBe('workflowsPage.overLimitApproverNotWorkspaceMember'); + }); + + it('returns no error when every route points at a member', () => { + // Given an approver with no flags + // When the error copy is picked + // Then there is nothing to fix + expect(getNonMemberApproverError(buildApprover(1), false)).toBeUndefined(); + }); + }); + describe('approval workflows owned by a connected integration', () => { const POLICY_ID = 'ats-policy'; diff --git a/tests/unit/WorkspaceDuplicateSelectFeaturesFormTest.tsx b/tests/unit/WorkspaceDuplicateSelectFeaturesFormTest.tsx new file mode 100644 index 000000000000..364f0901cde8 --- /dev/null +++ b/tests/unit/WorkspaceDuplicateSelectFeaturesFormTest.tsx @@ -0,0 +1,209 @@ +import {act, render} from '@testing-library/react-native'; + +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import type {ConfirmButtonOptions, ListItem} from '@components/SelectionList/types'; + +import {toIndexMap} from '@libs/RuleUtils'; + +import WorkspaceDuplicateSelectFeaturesForm from '@pages/workspace/duplicate/WorkspaceDuplicateSelectFeaturesForm'; + +import {duplicateWorkspace} from '@userActions/Policy/Policy'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; +import type Rule from '@src/types/onyx/Rule'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createRandomPolicy from '../utils/collections/policies'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const POLICY_ID = '1'; +const TARGET_POLICY_ID = '2'; + +type CapturedSelectionListProps = { + data: ListItem[]; + onSelectRow: (item: ListItem) => void; + confirmButtonOptions: ConfirmButtonOptions; +}; +let mockSelectionListProps: CapturedSelectionListProps | undefined; + +// The list itself isn't under test, so it is replaced by a stub that captures the rows, the row handler and the Continue button. +jest.mock('@components/SelectionList', () => ({ + __esModule: true, + default: (props: CapturedSelectionListProps) => { + mockSelectionListProps = props; + return null; + }, +})); + +jest.mock('@react-navigation/native', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actualNav = jest.requireActual('@react-navigation/native'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actualNav, + useRoute: () => ({params: {policyID: POLICY_ID}}), + }; +}); + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + goBack: jest.fn(), + closeRHPFlow: jest.fn(), +})); + +jest.mock('@hooks/useConfirmModal', () => ({ + __esModule: true, + default: () => ({showConfirmModal: jest.fn(() => Promise.resolve({action: 'cancel'}))}), +})); + +// The form fires openDuplicatePolicyPage on mount and duplicateWorkspace on Continue. Both are stubbed so the test can read the parts the form sends. +jest.mock('@userActions/Policy/Policy', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@userActions/Policy/Policy'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + openDuplicatePolicyPage: jest.fn(), + duplicateWorkspace: jest.fn(), + }; +}); + +const MERCHANT_RULE: Rule = { + scope: CONST.RULES.SCOPE.POLICY, + scopeID: POLICY_ID, + triggers: toIndexMap([CONST.RULES.TRIGGERS.CREATE_TRANSACTION]), + filters: {left: CONST.RULES.EXPENSE_DEFAULT.FIELD.MERCHANT, operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, right: 'Acme'}, + actions: toIndexMap([{name: CONST.RULES.ACTIONS.SET, field: CONST.RULES.EXPENSE_DEFAULT.FIELD.CATEGORY, value: 'Travel'}]), +}; + +/** A Control workspace whose only possible workspace-level rule is eReceipts, so the test decides whether the Rules row is shown. */ +function createSourcePolicy(hasWorkspaceRules: boolean): Policy { + return { + ...createRandomPolicy(Number(POLICY_ID), CONST.POLICY.TYPE.CORPORATE), + preventSelfApproval: false, + maxExpenseAmountNoReceipt: CONST.DISABLED_MAX_EXPENSE_VALUE, + maxExpenseAmount: CONST.DISABLED_MAX_EXPENSE_VALUE, + maxExpenseAge: CONST.DISABLED_MAX_EXPENSE_VALUE, + eReceipts: hasWorkspaceRules, + }; +} + +async function renderForm(hasWorkspaceRules: boolean) { + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, createSourcePolicy(hasWorkspaceRules)); + await Onyx.set(`${ONYXKEYS.COLLECTION.RULE}merchantRule`, MERCHANT_RULE); + await Onyx.set(ONYXKEYS.RAM_ONLY_HAS_RULES_DATA_BEEN_FETCHED, true); + await Onyx.set(ONYXKEYS.DUPLICATE_WORKSPACE, {name: 'Duplicate', policyID: TARGET_POLICY_ID}); + await waitForBatchedUpdates(); + + render( + + + , + ); + await waitForBatchedUpdates(); +} + +function getRow(key: string): ListItem | undefined { + return mockSelectionListProps?.data.find((item) => item.keyForList === key); +} + +async function pressRow(key: string) { + const row = getRow(key); + expect(row).toBeDefined(); + if (!row) { + return; + } + act(() => { + mockSelectionListProps?.onSelectRow(row); + }); + await waitForBatchedUpdates(); +} + +function pressContinue() { + act(() => { + mockSelectionListProps?.confirmButtonOptions.onConfirm?.(); + }); +} + +function getSentParts(): Record | undefined { + return jest.mocked(duplicateWorkspace).mock.calls.at(0)?.[1].parts; +} + +describe('WorkspaceDuplicateSelectFeaturesForm Rules and Merchant rules', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(() => { + // Without a fetch mock, background requests reach the real fetch and fail in the test environment. + global.fetch = TestHelper.createGlobalFetchMock(); + mockSelectionListProps = undefined; + }); + + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + jest.clearAllMocks(); + }); + + it('deselects Merchant rules when Rules is deselected, so neither is copied', async () => { + // Given a source with workspace-level rules and a merchant rule, where every row starts selected + await renderForm(true); + expect(getRow('rules')?.isSelected).toBe(true); + expect(getRow('codingRules')?.isSelected).toBe(true); + + // When the admin deselects Rules and continues + await pressRow('rules'); + pressContinue(); + + // Then Merchant rules is deselected too, because merchant rules live under Rules and can't be copied without it + expect(getRow('codingRules')?.isSelected).toBe(false); + expect(getSentParts()).toMatchObject({expenses: false, codingRules: false}); + }); + + it('selects Rules when Merchant rules is selected while Rules is deselected', async () => { + // Given Rules and Merchant rules were both deselected + await renderForm(true); + await pressRow('rules'); + + // When the admin selects Merchant rules and continues + await pressRow('codingRules'); + pressContinue(); + + // Then Rules is selected too, so the merchant rules are copied along with the Rules feature they belong to + expect(getRow('rules')?.isSelected).toBe(true); + expect(getRow('codingRules')?.isSelected).toBe(true); + expect(getSentParts()).toMatchObject({expenses: true, codingRules: true}); + }); + + it('keeps Rules selected when only Merchant rules is deselected', async () => { + // Given every row starts selected + await renderForm(true); + + // When the admin deselects only Merchant rules and continues + await pressRow('codingRules'); + pressContinue(); + + // Then the workspace-level rules are still copied without the merchant rules, because Rules doesn't depend on Merchant rules + expect(getRow('rules')?.isSelected).toBe(true); + expect(getSentParts()).toMatchObject({expenses: true, codingRules: false}); + }); + + it('copies merchant rules on their own when the source has no workspace-level rules', async () => { + // Given a source with a merchant rule but no workspace-level rules, so the Rules row is hidden + await renderForm(false); + expect(getRow('rules')).toBeUndefined(); + expect(getRow('codingRules')?.isSelected).toBe(true); + + // When the admin continues + pressContinue(); + + // Then the merchant rules are still copied, because there is no Rules row they could depend on + expect(getSentParts()).toMatchObject({expenses: false, codingRules: true}); + }); +}); diff --git a/tests/unit/applyColumnWidthOverridesTest.ts b/tests/unit/applyColumnWidthOverridesTest.ts new file mode 100644 index 000000000000..1aad17700536 --- /dev/null +++ b/tests/unit/applyColumnWidthOverridesTest.ts @@ -0,0 +1,66 @@ +import type {ColumnWidthOverrideColumn} from '@components/Table/columnResize/applyColumnWidthOverrides'; +import applyColumnWidthOverrides from '@components/Table/columnResize/applyColumnWidthOverrides'; + +/** A headed column sized from its content, which is the only kind whose edge drags. */ +function contentColumn(key: string): ColumnWidthOverrideColumn { + return {key, label: key, hasDeclaredWidth: false}; +} + +/** A headed column that declared a width of its own, e.g. a status or a switch. */ +function fixedColumn(key: string): ColumnWidthOverrideColumn { + return {key, label: key, hasDeclaredWidth: true}; +} + +/** A column with no heading, e.g. a trailing arrow. */ +function headlessColumn(key: string): ColumnWidthOverrideColumn { + return {key, label: '', hasDeclaredWidth: false}; +} + +describe('applyColumnWidthOverrides', () => { + it('applies a stored width to its column and paints it before any drag', () => { + // Given three columns where the user widened the first by 60px + const columns = [contentColumn('name'), contentColumn('email'), contentColumn('role')]; + const baseColumnWidths = {name: 200, email: 200, role: 200}; + + // When the stored width is applied + const {columnWidths, columnWidthValues} = applyColumnWidthOverrides({columns, baseColumnWidths, columnWidthOverrides: {name: 260}}); + + // Then only that column changes, and its CSS value falls back to the stored width so it paints at 260px with no drag running + expect(columnWidths).toEqual({name: 260, email: 200, role: 200}); + expect(columnWidthValues.at(0)).toContain('260px'); + }); + + it('rounds a stored width to whole px', () => { + // Given a stored width with a fraction + const columns = [contentColumn('name'), contentColumn('role')]; + + // When it is applied + const {columnWidths} = applyColumnWidthOverrides({columns, baseColumnWidths: {name: 200, role: 120}, columnWidthOverrides: {name: 240.6}}); + + // Then the column gets whole px, so its grid track never lands on a sub-pixel + expect(columnWidths).toEqual({name: 241, role: 120}); + }); + + it('ignores a width stored for a column that declared its own', () => { + // Given a stored width for a column that has since declared a fixed width + const columns = [fixedColumn('status')]; + + // When it is applied + const {columnWidths} = applyColumnWidthOverrides({columns, baseColumnWidths: {status: 80}, columnWidthOverrides: {status: 300}}); + + // Then the declared width wins, since that column can no longer be dragged and the stored width is stale + expect(columnWidths).toEqual({status: 80}); + }); + + it('gives an edge only to headed columns sized from their content', () => { + // Given a table mixing content-sized, fixed and headless columns + const columns = [contentColumn('name'), fixedColumn('status'), contentColumn('email'), headlessColumn('arrow')]; + const baseColumnWidths = {name: 200, status: 80, email: 200, arrow: 40}; + + // When the resizable columns are worked out + const {resizableColumnKeys} = applyColumnWidthOverrides({columns, baseColumnWidths, columnWidthOverrides: undefined}); + + // Then only the content-sized ones get an edge, since fixed and headless columns hold fixed-size content + expect(resizableColumnKeys).toEqual(['name', 'email']); + }); +}); diff --git a/tests/unit/calculateDynamicColumnWidthsTest.ts b/tests/unit/calculateDynamicColumnWidthsTest.ts index 34b044fe53a1..7376ecc22c39 100644 --- a/tests/unit/calculateDynamicColumnWidthsTest.ts +++ b/tests/unit/calculateDynamicColumnWidthsTest.ts @@ -1,5 +1,5 @@ import type {DynamicColumnConstraints} from '@components/Table/calculateDynamicColumnWidths'; -import calculateDynamicColumnWidths from '@components/Table/calculateDynamicColumnWidths'; +import calculateDynamicColumnWidths, {distributeEqualWidths} from '@components/Table/calculateDynamicColumnWidths'; /** * A free-text column: squeezable down to `minWidth`, and uncapped, which is what the hook passes when a column doesn't @@ -229,3 +229,22 @@ describe('calculateDynamicColumnWidths', () => { }); }); }); + +describe('distributeEqualWidths', () => { + // Given no columns to share the width + // When equal widths are distributed + // Then nothing is produced, so a caller can't read a width for a column that isn't there + it('produces nothing for no columns', () => { + expect(distributeEqualWidths(0, 900)).toEqual([]); + }); + + // Given a width that does not divide evenly + // When equal widths are distributed + // Then the remainder goes to the first column, so the columns still sum exactly to the available width + it('hands the rounding remainder to the first column', () => { + const widths = distributeEqualWidths(3, 901); + + expect(widths).toEqual([301, 300, 300]); + expect(sumOf(widths)).toBe(901); + }); +}); diff --git a/tests/unit/columnWidthExpressionsTest.ts b/tests/unit/columnWidthExpressionsTest.ts new file mode 100644 index 000000000000..e7fd7b9192c5 --- /dev/null +++ b/tests/unit/columnWidthExpressionsTest.ts @@ -0,0 +1,17 @@ +import {getColumnWidthVariableName, getColumnsWidthExpression} from '@components/Table/columnResize/columnWidthExpressions'; + +describe('columnWidthExpressions', () => { + // Given a column key holding characters CSS does not allow in a custom property name + // When it is turned into a custom property name + // Then each of them is replaced, because a name CSS cannot parse is a width nothing can read + it('replaces characters that are not valid in a custom property name', () => { + expect(getColumnWidthVariableName('tax.rate value')).toBe('--table-column-width-tax_rate_value'); + }); + + // Given a table with no columns to sum + // When its width is built + // Then it is still a valid expression floored at the given floor, since an empty `calc()` would drop the whole width + it('floors a table with no columns at the floor it was given', () => { + expect(getColumnsWidthExpression([], 64, '100%')).toBe('max(100%, 64px)'); + }); +}); diff --git a/tests/unit/components/ExpenseFieldDropdown.test.tsx b/tests/unit/components/ExpenseFieldDropdown.test.tsx index edc22989b19e..dfca3286fb12 100644 --- a/tests/unit/components/ExpenseFieldDropdown.test.tsx +++ b/tests/unit/components/ExpenseFieldDropdown.test.tsx @@ -1,6 +1,6 @@ import {act, fireEvent, render, screen} from '@testing-library/react-native'; -import type {ExpenseFieldDropdownRenderProps} from '@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown'; +import type {ExpenseFieldDropdownHandle, ExpenseFieldDropdownRenderProps} from '@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown'; import ExpenseFieldDropdown from '@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown'; import Text from '@components/Text'; @@ -10,7 +10,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {ReactNode} from 'react'; -import React from 'react'; +import React, {createRef} from 'react'; import {View} from 'react-native'; import Onyx from 'react-native-onyx'; @@ -85,6 +85,51 @@ describe('ExpenseFieldDropdown', () => { />, ); + describe('open()', () => { + const renderFieldWithRef = (shouldOpenInDropdown: boolean, onPress: () => void) => { + const ref = createRef(); + render( + , + ); + return ref; + }; + + it('opens the list in the container without a press', () => { + // Given a field whose list can open in place, opened by its parent rather than by the user + const onPress = jest.fn(); + mockRowAt(ROW_TOP); + const ref = renderFieldWithRef(true, onPress); + + // When the parent opens it + act(() => ref.current?.open()); + + // Then the list opens in the container and the full-page selector is never reached + expect(screen.getByText(DROPDOWN_TEXT)).toBeOnTheScreen(); + expect(onPress).not.toHaveBeenCalled(); + }); + + it('falls back to the full-page selector when the list cannot open in place', () => { + // Given a field whose list cannot open in place, e.g. because there is no workspace to list categories from + const onPress = jest.fn(); + mockRowAt(ROW_TOP); + const ref = renderFieldWithRef(false, onPress); + + // When the parent opens it + act(() => ref.current?.open()); + + // Then it does what a press would and hands over to the full-page selector, never mounting the list + expect(onPress).toHaveBeenCalledTimes(1); + expect(renderCount).toBe(0); + }); + }); + it('does not mount the list until the row is pressed', () => { // Given a field whose list opens in the container anchored to the row mockRowAt(ROW_TOP); diff --git a/tests/unit/components/MoneyRequestConfirmationList/CategoryField.test.tsx b/tests/unit/components/MoneyRequestConfirmationList/CategoryField.test.tsx index ebcc9f3e4f39..42c6d56662af 100644 --- a/tests/unit/components/MoneyRequestConfirmationList/CategoryField.test.tsx +++ b/tests/unit/components/MoneyRequestConfirmationList/CategoryField.test.tsx @@ -1,11 +1,17 @@ -import {render, screen, waitFor} from '@testing-library/react-native'; +import {fireEvent, render, screen, waitFor} from '@testing-library/react-native'; import ConfirmationFieldsProvider from '@components/MoneyRequestConfirmationFields/Provider'; import CategoryField from '@components/MoneyRequestConfirmationList/sections/CategoryField'; +import type {ExpenseFieldDropdownHandle} from '@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown'; +import ExpenseFormLayoutContext, {dropdownRowsExpenseFormLayout} from '@components/MoneyRequestConfirmationList/sections/ExpenseFormLayoutContext'; + +import Navigation from '@libs/Navigation/Navigation'; +import type {CancelHandle} from '@libs/Navigation/TransitionTracker'; import CONST from '@src/CONST'; import type {IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type {Policy} from '@src/types/onyx'; import React from 'react'; @@ -27,6 +33,53 @@ jest.mock('@components/MenuItemWithTopDescription', () => { jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); jest.mock('@hooks/useThemeStyles', () => () => ({})); +// Only the anchored dropdowns beta, so the tests that render the plain menu row keep every other beta off. +jest.mock('@hooks/usePermissions', () => () => ({ + isBetaEnabled: (beta: string) => beta === jest.requireActual<{default: typeof CONST}>('@src/CONST').default.BETAS.ANCHORED_FIELD_DROPDOWNS, +})); + +let mockIsFocused = true; +// Mirrors `useFocusEffect`: runs the effect while the screen is focused, and again whenever the effect changes. +jest.mock('@react-navigation/native', () => { + const {useEffect} = jest.requireActual('react'); + return { + ...jest.requireActual>('@react-navigation/native'), + useIsFocused: () => mockIsFocused, + useFocusEffect: (effect: () => void | (() => void)) => { + const isFocused = mockIsFocused; + useEffect(() => (isFocused ? effect() : undefined), [effect, isFocused]); + }, + }; +}); + +jest.mock('@libs/Navigation/Navigation', () => ({navigate: jest.fn()})); + +// Run the post-transition work right away, since no screen actually closes in a unit test. +jest.mock('@libs/Navigation/TransitionTracker', () => ({ + runAfterTransitions: ({callback}: {callback: () => void}): CancelHandle => { + callback(); + return {cancel: () => {}}; + }, +})); + +const mockOpenDropdown = jest.fn(); +let mockShouldOpenInDropdown: boolean | undefined; +jest.mock('@components/MoneyRequestConfirmationList/sections/ExpenseFieldDropdown', () => { + const {useImperativeHandle} = jest.requireActual('react'); + const {Pressable, Text} = jest.requireActual void; role?: string}>>>('react-native'); + return ({name, onPress, shouldOpenInDropdown, ref}: {name: string; onPress: () => void; shouldOpenInDropdown: boolean; ref?: React.Ref}) => { + mockShouldOpenInDropdown = shouldOpenInDropdown; + useImperativeHandle(ref, () => ({open: mockOpenDropdown})); + return ( + + {name} + + ); + }; +}); const TRANSACTION_ID = '1'; const REPORT_ID = 'reportID'; @@ -67,6 +120,8 @@ describe('CategoryField', () => { }); beforeEach(async () => { + // `jest.clearAllMocks()` does not touch this, so a value left by an earlier render could satisfy a later assertion. + mockShouldOpenInDropdown = undefined; await Onyx.clear(); await waitForBatchedUpdates(); }); @@ -154,6 +209,232 @@ describe('CategoryField', () => { expect(screen.queryByText('common.automatic')).toBeNull(); }); + describe('after an upgrade started from the category row', () => { + const renderDropdownCategoryField = ({ + shouldNavigateToUpgradePath, + isPolicyLoaded = !shouldNavigateToUpgradePath, + didConfirm = false, + }: { + shouldNavigateToUpgradePath: boolean; + /** False while the workspace the upgrade created has not reached Onyx yet */ + isPolicyLoaded?: boolean; + didConfirm?: boolean; + }) => ( + + + + + + ); + + beforeEach(() => { + mockIsFocused = true; + jest.clearAllMocks(); + }); + + it('opens the category list in place once the user is back with a workspace', async () => { + // Given an expense with no workspace, whose category row sends the user to upgrade + await givenManualExpense(); + const {rerender} = render(renderDropdownCategoryField({shouldNavigateToUpgradePath: true})); + + // When the user presses the row, upgrades on the upgrade screen, and comes back to the form + fireEvent.press(screen.getByText('common.category')); + mockIsFocused = false; + rerender(renderDropdownCategoryField({shouldNavigateToUpgradePath: false})); + mockIsFocused = true; + rerender(renderDropdownCategoryField({shouldNavigateToUpgradePath: false})); + + // Then the upgrade screen is told to return to the form rather than open the full-page Category step, + // and the form opens the anchored list itself, so the user lands where they were heading + expect(jest.mocked(Navigation.navigate).mock.calls.at(0)?.at(0)).toContain('shouldReturnToConfirmation=true'); + await waitFor(() => { + expect(mockOpenDropdown).toHaveBeenCalledTimes(1); + }); + }); + + it('does not open the category list when the user backs out of the upgrade', async () => { + // Given an expense with no workspace, whose category row sends the user to upgrade + await givenManualExpense(); + const {rerender} = render(renderDropdownCategoryField({shouldNavigateToUpgradePath: true})); + + // When the user presses the row but closes the upgrade screen without upgrading + fireEvent.press(screen.getByText('common.category')); + mockIsFocused = false; + rerender(renderDropdownCategoryField({shouldNavigateToUpgradePath: true})); + mockIsFocused = true; + rerender(renderDropdownCategoryField({shouldNavigateToUpgradePath: true})); + await waitForBatchedUpdates(); + + // Then nothing opens, because there is still no workspace to pick a category from + expect(mockOpenDropdown).not.toHaveBeenCalled(); + }); + + it('does not open anything once the expense is being confirmed', async () => { + // Given an expense with no workspace, whose category row sends the user to upgrade + await givenManualExpense(); + const {rerender} = render(renderDropdownCategoryField({shouldNavigateToUpgradePath: true})); + + // When the user upgrades, comes back, and presses Submit before the list gets to open + fireEvent.press(screen.getByText('common.category')); + mockIsFocused = false; + rerender(renderDropdownCategoryField({shouldNavigateToUpgradePath: false})); + mockIsFocused = true; + rerender(renderDropdownCategoryField({shouldNavigateToUpgradePath: false, didConfirm: true})); + await waitForBatchedUpdates(); + + // Then the list is not asked to open: the row would refuse and push the full-page Category step over a + // confirming expense instead + expect(mockOpenDropdown).not.toHaveBeenCalled(); + expect(jest.mocked(Navigation.navigate)).toHaveBeenCalledTimes(1); + }); + + it('forgets the upgrade once the user leaves the form again', async () => { + // Given an expense with no workspace, whose category row sends the user to upgrade + await givenManualExpense(); + const {rerender} = render(renderDropdownCategoryField({shouldNavigateToUpgradePath: true})); + + // When the user upgrades and comes back before the new workspace has landed, then visits another step + // and returns once it has + fireEvent.press(screen.getByText('common.category')); + mockIsFocused = false; + rerender(renderDropdownCategoryField({shouldNavigateToUpgradePath: false, isPolicyLoaded: false})); + mockIsFocused = true; + rerender(renderDropdownCategoryField({shouldNavigateToUpgradePath: false, isPolicyLoaded: false})); + mockIsFocused = false; + rerender(renderDropdownCategoryField({shouldNavigateToUpgradePath: false})); + mockIsFocused = true; + rerender(renderDropdownCategoryField({shouldNavigateToUpgradePath: false})); + await waitForBatchedUpdates(); + + // Then the list stays closed: the return from the upgrade is the only focus that may open it, not a + // later one the user never pressed Category for + expect(mockOpenDropdown).not.toHaveBeenCalled(); + }); + }); + + it('opens the list in place for a draft workspace, whose categories only exist as drafts', async () => { + // Given an expense on the draft workspace "Submit to my employer" creates, which stores its categories only as drafts + await givenManualExpense(); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES_DRAFT}${enabledPolicy.id}`, {Travel: {name: 'Travel', enabled: true}}); + await waitForBatchedUpdates(); + + // When the confirmation form renders the category row for that flow, which submits rather than creates, so + // nothing but the draft categories can make the row open in place + render( + + + + + , + ); + + // Then the row opens its list in place, because the draft categories count as categories to pick from + await waitFor(() => { + expect(mockShouldOpenInDropdown).toBe(true); + }); + }); + + describe('while creating an expense', () => { + const renderCreateCategoryField = ({policy, shouldSelectPolicy}: {policy: Policy | undefined; shouldSelectPolicy: boolean}) => ( + + + + + + ); + + beforeEach(() => { + mockIsFocused = true; + jest.clearAllMocks(); + }); + + it('opens the list in place even before the workspace categories are loaded', async () => { + // Given a workspace whose categories have not reached Onyx yet, which the list loads on its own + await givenManualExpense(); + + // When the confirmation form renders the category row + render(renderCreateCategoryField({policy: enabledPolicy, shouldSelectPolicy: false})); + + // Then the row still opens its list in place rather than the full-page Category step + await waitFor(() => { + expect(mockShouldOpenInDropdown).toBe(true); + }); + }); + + it('opens the list in place once the user is back from picking a default workspace', async () => { + // Given a member of several workspaces who has not picked a default one yet + await givenManualExpense(); + const {rerender} = render(renderCreateCategoryField({policy: undefined, shouldSelectPolicy: true})); + + // When the user presses the row, picks a workspace, and comes back to the form + fireEvent.press(screen.getByText('common.category')); + mockIsFocused = false; + rerender(renderCreateCategoryField({policy: enabledPolicy, shouldSelectPolicy: false})); + mockIsFocused = true; + rerender(renderCreateCategoryField({policy: enabledPolicy, shouldSelectPolicy: false})); + + // Then the workspace picker is opened without a destination, so it returns here, and the list opens in place + expect(jest.mocked(Navigation.navigate)).toHaveBeenCalledWith(ROUTES.SET_DEFAULT_WORKSPACE.getRoute()); + await waitFor(() => { + expect(mockOpenDropdown).toHaveBeenCalledTimes(1); + }); + }); + }); + it('does not promise an automatic category on an invoice', async () => { // Given a manual invoice on a workspace that categorizes new expenses await givenManualExpense(); diff --git a/tests/unit/components/NavigationAwareCameraTest.tsx b/tests/unit/components/NavigationAwareCameraTest.tsx new file mode 100644 index 000000000000..671afe33beef --- /dev/null +++ b/tests/unit/components/NavigationAwareCameraTest.tsx @@ -0,0 +1,61 @@ +import {render} from '@testing-library/react-native'; + +import NavigationAwareCamera from '@pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/Camera'; + +import type {CameraDevice} from 'react-native-vision-camera'; + +import React from 'react'; + +const mockUseIsFocused = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + useIsFocused: () => mockUseIsFocused(), +})); + +const mockVisionCamera = jest.fn(); +jest.mock('react-native-vision-camera', () => ({ + Camera: (props: {isActive: boolean}) => { + mockVisionCamera(props); + return null; + }, +})); + +// The wrapper passes the device straight through to a camera that is stubbed out here, so it only has to +// satisfy the prop type. +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const DEVICE = {id: 'back'} as unknown as CameraDevice; + +/** The session state the camera ends up in for one combination of focus and the two overrides. */ +function renderIsActive({isFocused, forceInactive, shouldStayActiveWhenBlurred}: {isFocused: boolean; forceInactive?: boolean; shouldStayActiveWhenBlurred?: boolean}): boolean { + mockUseIsFocused.mockReturnValue(isFocused); + mockVisionCamera.mockClear(); + + render( + , + ); + + return !!mockVisionCamera.mock.calls.at(0)?.at(0)?.isActive; +} + +describe('NavigationAwareCamera', () => { + it('runs the session while the scan tab is focused and nothing has forced it shut', () => { + expect(renderIsActive({isFocused: true})).toBe(true); + expect(renderIsActive({isFocused: false})).toBe(false); + }); + + it('keeps the session running once a capture navigates away and the screen blurs', () => { + // Closing it here cancels an in-flight `takePhoto` with "Camera is closed.", which is the whole + // reason a full-resolution capture can outlive the shutter. + expect(renderIsActive({isFocused: false, shouldStayActiveWhenBlurred: true})).toBe(true); + }); + + it('lets forceInactive win over a capture that is still running, since it is the harder stop', () => { + expect(renderIsActive({isFocused: true, forceInactive: true})).toBe(false); + expect(renderIsActive({isFocused: false, forceInactive: true, shouldStayActiveWhenBlurred: true})).toBe(false); + expect(renderIsActive({isFocused: true, forceInactive: true, shouldStayActiveWhenBlurred: true})).toBe(false); + }); +}); diff --git a/tests/unit/components/Table/TableSemanticContainerTest.tsx b/tests/unit/components/Table/TableSemanticContainerTest.tsx index d4216c3f223f..dbd993330736 100644 --- a/tests/unit/components/Table/TableSemanticContainerTest.tsx +++ b/tests/unit/components/Table/TableSemanticContainerTest.tsx @@ -76,7 +76,9 @@ function renderContainer( rendersBodyWhenEmpty={rendersBodyWhenEmpty} shouldUseDynamicColumns={shouldUseDynamicColumns} scrollWidth={undefined} + measureWidthRef={undefined} onLayout={onLayout} + onScopeElement={undefined} > {children} , @@ -178,7 +180,9 @@ describe('TableSemanticContainer', () => { rendersBodyWhenEmpty={false} shouldUseDynamicColumns={false} scrollWidth={undefined} + measureWidthRef={undefined} onLayout={undefined} + onScopeElement={undefined} > @@ -217,7 +221,9 @@ describe('TableSemanticContainer', () => { rendersBodyWhenEmpty={false} shouldUseDynamicColumns scrollWidth={undefined} + measureWidthRef={undefined} onLayout={isWideLayout ? onLayout : undefined} + onScopeElement={undefined} > @@ -235,4 +241,32 @@ describe('TableSemanticContainer', () => { expect(mockTrackedFilterBarUnmount).not.toHaveBeenCalled(); expect(mockTrackedFilterBarMount).toHaveBeenCalledTimes(1); }); + + it('hands the measured node to the measure ref, so the table is measured before its first paint', () => { + // Given a resizable table with a measure ref + const measureWidthRef = jest.fn(); + + // When it mounts + render( + + + + , + ); + + // Then the ref receives the mounted node that `onLayout` measures + expect(measureWidthRef).toHaveBeenCalledWith(expect.anything()); + }); }); diff --git a/tests/unit/components/Table/buildTableListDataTest.ts b/tests/unit/components/Table/buildTableListDataTest.ts new file mode 100644 index 000000000000..522e9fbdae97 --- /dev/null +++ b/tests/unit/components/Table/buildTableListDataTest.ts @@ -0,0 +1,72 @@ +import {getTableListMetadata} from '@components/Table/buildTableListData'; + +import CONST from '@src/CONST'; + +const {COLUMN_HEADER_PLACEMENT} = CONST.TABLES; + +const pageHeader = 'Page header'; + +describe('getTableListMetadata', () => { + // Given a table with a page header whose columns fit and can't be resized + // When the column header is placed + // Then it rides FlashList's sticky-row overlay, which is the cheapest way to keep it stuck + it('places the column header in the sticky row when there is no horizontal scroller', () => { + const metadata = getTableListMetadata({ + listHeaderElement: pageHeader, + hasColumnHeaderElement: true, + hasRows: true, + isColumnHeaderHiddenInNarrowLayout: false, + hasHorizontalScrollContainer: false, + }); + + expect(metadata.columnHeaderPlacement).toBe(COLUMN_HEADER_PLACEMENT.STICKY_ROW); + expect(metadata.syntheticRowsBeforeData).toBe(1); + }); + + // Given a table with a page header that sits in a horizontal scroller, which every resizable table does even while its columns fit + // When the column header is placed + // Then it goes in the list header so it scrolls sideways with the columns, and doesn't move once a drag starts overflowing them + it('places the column header in the list header when there is a horizontal scroller', () => { + const metadata = getTableListMetadata({ + listHeaderElement: pageHeader, + hasColumnHeaderElement: true, + hasRows: true, + isColumnHeaderHiddenInNarrowLayout: false, + hasHorizontalScrollContainer: true, + }); + + expect(metadata.columnHeaderPlacement).toBe(COLUMN_HEADER_PLACEMENT.LIST_HEADER); + expect(metadata.syntheticRowsBeforeData).toBe(0); + }); + + // Given a table without a page header + // When the column header is placed, with or without a horizontal scroller + // Then it stays outside the list either way, since the ancestor scroller already carries it with the columns + it('keeps the column header outside the list when there is no page header', () => { + for (const hasHorizontalScrollContainer of [false, true]) { + const metadata = getTableListMetadata({ + hasColumnHeaderElement: true, + hasRows: true, + isColumnHeaderHiddenInNarrowLayout: false, + hasHorizontalScrollContainer, + }); + + expect(metadata.columnHeaderPlacement).toBe(COLUMN_HEADER_PLACEMENT.OUTSIDE_LIST); + } + }); + + // Given a table with a horizontal scroller but no rows + // When the column header is placed + // Then there is none, since a header over an empty table only labels nothing + it('renders no column header when there are no rows', () => { + const metadata = getTableListMetadata({ + listHeaderElement: pageHeader, + hasColumnHeaderElement: true, + hasRows: false, + isColumnHeaderHiddenInNarrowLayout: false, + hasHorizontalScrollContainer: true, + }); + + expect(metadata.columnHeaderPlacement).toBe(COLUMN_HEADER_PLACEMENT.NONE); + }); +}); diff --git a/tests/unit/components/Table/useColumnResizeTest.ts b/tests/unit/components/Table/useColumnResizeTest.ts new file mode 100644 index 000000000000..e253a428d273 --- /dev/null +++ b/tests/unit/components/Table/useColumnResizeTest.ts @@ -0,0 +1,285 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import {RESIZE_INDICATOR_OPACITY_VARIABLE, getColumnWidthVariableName} from '@components/Table/columnResize/columnWidthExpressions'; +import type UseColumnResize from '@components/Table/columnResize/useColumnResize'; +import type {UseColumnResizeParams} from '@components/Table/columnResize/useColumnResize/types'; + +import {setTableColumnWidth} from '@libs/actions/TableColumnWidths'; + +import type React from 'react'; + +// Jest resolves the native no-op, so the web implementation is loaded by its file name. +const {default: useColumnResize} = jest.requireActual<{default: typeof UseColumnResize}>('@components/Table/columnResize/useColumnResize/index.ts'); + +// Jest resolves the native cursor styles, which are empty, so the web ones are loaded by their file name too. +jest.mock('@styles/utils/cursor', () => jest.requireActual>('@styles/utils/cursor/index.ts')); + +jest.mock('@libs/actions/TableColumnWidths', () => ({ + setTableColumnWidth: jest.fn(), +})); + +const COLUMN_RESIZING_ID = 'testTable'; + +const NAME_COLUMN_KEY = 'name'; + +const resolvedColumnWidths = {name: 200, email: 200, role: 200}; + +type PointerEventInit = {clientX: number; button?: number}; + +/** A pointer event carrying only what the hook reads, aimed at the given handle. */ +function createPointerEvent(handleElement: HTMLDivElement, {clientX, button = 0}: PointerEventInit): React.PointerEvent { + const event = {button, clientX, clientY: 0, pointerId: 1, currentTarget: handleElement, preventDefault: jest.fn(), stopPropagation: jest.fn()}; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the hook only reads the fields above + return event as unknown as React.PointerEvent; +} + +/** Renders the hook with a scope element and one handle inside it, the way the table mounts them. */ +function renderColumnResize(params?: Partial) { + const scopeElement = document.createElement('div'); + const handleElement = document.createElement('div'); + + scopeElement.appendChild(handleElement); + // jsdom has no pointer capture. + const setPointerCapture = jest.fn(); + + handleElement.setPointerCapture = setPointerCapture; + handleElement.releasePointerCapture = jest.fn(); + handleElement.hasPointerCapture = jest.fn(() => true); + + const initialProps: UseColumnResizeParams = { + columnResizingID: COLUMN_RESIZING_ID, + resizableColumnKeys: [NAME_COLUMN_KEY], + resolvedColumnWidths, + columnGap: 12, + ...params, + }; + const hook = renderHook((props: UseColumnResizeParams) => useColumnResize(props), {initialProps}); + + hook.result.current?.setScopeElement(scopeElement); + + const getHandleProps = () => { + const controller = hook.result.current; + + if (!controller) { + throw new Error('Expected the hook to return a controller'); + } + + const handleProps = controller.getHandleProps(NAME_COLUMN_KEY); + + if (!handleProps) { + throw new Error('Expected the column to be resizable'); + } + + return handleProps; + }; + + const readWidth = (columnKey: string) => scopeElement.style.getPropertyValue(getColumnWidthVariableName(columnKey)); + + return {...hook, initialProps, scopeElement, handleElement, setPointerCapture, getHandleProps, readWidth}; +} + +describe('useColumnResize', () => { + beforeEach(() => { + jest.clearAllMocks(); + document.body.style.cursor = ''; + }); + + it('returns no controller when resizing is off', () => { + // Given a table with no resizing ID, which is how native, narrow layouts and tables that didn't opt in render it + const params: UseColumnResizeParams = {columnResizingID: undefined, resizableColumnKeys: [NAME_COLUMN_KEY], resolvedColumnWidths, columnGap: 12}; + + // When the hook runs + const {result} = renderHook(() => useColumnResize(params)); + + // Then it hands back no controller, so the header renders no handles + expect(result.current).toBeUndefined(); + }); + + it('returns a controller before the columns are measured', () => { + // Given a resizable table on its first render, before layout, when no column is resizable yet + const params: UseColumnResizeParams = {columnResizingID: COLUMN_RESIZING_ID, resizableColumnKeys: [], resolvedColumnWidths: {}, columnGap: 12}; + + // When the hook runs + const {result} = renderHook(() => useColumnResize(params)); + + // Then it still hands back a controller, so the scope element renders from the start and the header and list + // don't remount once the table is measured, while no column gets a handle yet + expect(result.current).toBeDefined(); + expect(result.current?.getHandleProps(NAME_COLUMN_KEY)).toBeUndefined(); + }); + + it('paints a drag onto the scope and stores the dragged column on release', () => { + // Given a 200px column with two 200px columns after it + const {handleElement, getHandleProps, readWidth} = renderColumnResize(); + + // When its edge is dragged 60px right + act(() => { + getHandleProps().onPointerDown?.(createPointerEvent(handleElement, {clientX: 100})); + getHandleProps().onPointerMove?.(createPointerEvent(handleElement, {clientX: 160})); + }); + + // Then the width is painted straight onto the scope without a React render, and later columns keep their widths + expect(readWidth('name')).toBe('260px'); + expect(readWidth('email')).toBe(''); + expect(readWidth('role')).toBe(''); + expect(document.body.style.cursor).toBe('col-resize'); + + // When the pointer is released + act(() => { + getHandleProps().onPointerUp?.(createPointerEvent(handleElement, {clientX: 160})); + }); + + // Then the dragged column is stored, once + expect(setTableColumnWidth).toHaveBeenCalledTimes(1); + expect(setTableColumnWidth).toHaveBeenCalledWith(COLUMN_RESIZING_ID, 'name', 260); + expect(document.body.style.cursor).toBe(''); + }); + + it('stores nothing and clears the scope when a drag ends where it started', () => { + // Given a column being dragged + const {handleElement, getHandleProps, readWidth} = renderColumnResize(); + + act(() => { + getHandleProps().onPointerDown?.(createPointerEvent(handleElement, {clientX: 100})); + getHandleProps().onPointerMove?.(createPointerEvent(handleElement, {clientX: 160})); + }); + + // When the pointer comes back to where it started and is released + act(() => { + getHandleProps().onPointerMove?.(createPointerEvent(handleElement, {clientX: 100})); + getHandleProps().onPointerUp?.(createPointerEvent(handleElement, {clientX: 100})); + }); + + // Then nothing is stored, and the scope is cleared since no render follows to clear it + expect(setTableColumnWidth).not.toHaveBeenCalled(); + expect(readWidth('name')).toBe(''); + }); + + it('stores the width when the pointer capture is lost mid-drag', () => { + // Given a column being dragged 40px right + const {handleElement, getHandleProps} = renderColumnResize(); + + act(() => { + getHandleProps().onPointerDown?.(createPointerEvent(handleElement, {clientX: 100})); + getHandleProps().onPointerMove?.(createPointerEvent(handleElement, {clientX: 140})); + }); + + // When the browser takes the pointer capture back, e.g. because the window lost focus + act(() => { + getHandleProps().onLostPointerCapture?.(createPointerEvent(handleElement, {clientX: 140})); + }); + + // Then the drag ends the same way a release does, so the width the user already sees is kept + expect(setTableColumnWidth).toHaveBeenCalledTimes(1); + expect(setTableColumnWidth).toHaveBeenCalledWith(COLUMN_RESIZING_ID, 'name', 240); + expect(document.body.style.cursor).toBe(''); + }); + + it('shows the edge line only while dragging, not on hover', () => { + // Given a resizable column + const {handleElement, getHandleProps} = renderColumnResize(); + const readLineOpacity = () => handleElement.style.getPropertyValue(RESIZE_INDICATOR_OPACITY_VARIABLE); + + // When its edge is pressed + act(() => { + getHandleProps().onPointerDown?.(createPointerEvent(handleElement, {clientX: 100})); + }); + + // Then the tall line appears, so the user sees which edge is moving + expect(readLineOpacity()).toBe('1'); + + // When the pointer is released + act(() => { + getHandleProps().onPointerUp?.(createPointerEvent(handleElement, {clientX: 100})); + }); + + // Then the line goes away, even with the pointer still over the edge + expect(readLineOpacity()).toBe('0'); + }); + + it('keeps a dragged width above its floor', () => { + // Given a 200px column that may not shrink below 180px + const {handleElement, getHandleProps, readWidth} = renderColumnResize({dragMinWidths: {[NAME_COLUMN_KEY]: 180}}); + + // When its edge is dragged far past the left of the window + act(() => { + getHandleProps().onPointerDown?.(createPointerEvent(handleElement, {clientX: 100})); + getHandleProps().onPointerMove?.(createPointerEvent(handleElement, {clientX: -1000})); + }); + + // Then it stops at its floor, so the last column can't pull the row in from the table's edge + expect(readWidth(NAME_COLUMN_KEY)).toBe('180px'); + + // When the same drag carries on far past the right + act(() => { + getHandleProps().onPointerMove?.(createPointerEvent(handleElement, {clientX: 5000})); + }); + + // Then it follows the pointer with no upper bound, since the table scrolls to reach later columns + expect(readWidth(NAME_COLUMN_KEY)).toBe('5100px'); + }); + + it('ignores the secondary button', () => { + // Given a resizable column + const {handleElement, setPointerCapture, getHandleProps} = renderColumnResize(); + + // When its edge is pressed with the secondary button, which opens a context menu + act(() => { + getHandleProps().onPointerDown?.(createPointerEvent(handleElement, {clientX: 100, button: 2})); + }); + + // Then no drag starts, so the context menu isn't fighting a captured pointer + expect(setPointerCapture).not.toHaveBeenCalled(); + expect(document.body.style.cursor).toBe(''); + }); + + it('resets the cursor when unmounted mid-drag', () => { + // Given a column being dragged, which puts the resize cursor on the whole page + const columnResize = renderColumnResize(); + const {handleElement, getHandleProps} = columnResize; + + act(() => { + getHandleProps().onPointerDown?.(createPointerEvent(handleElement, {clientX: 100})); + }); + + expect(document.body.style.cursor).toBe('col-resize'); + + // When the table unmounts, e.g. because the user navigated away mid-drag + columnResize.unmount(); + + // Then the resize cursor doesn't stay stuck on the page + expect(document.body.style.cursor).toBe(''); + }); + + it('leaves the cursor alone when unmounted with no drag', () => { + // Given a cursor some other part of the page put on the body, and a table that isn't being dragged + document.body.style.cursor = 'grabbing'; + const columnResize = renderColumnResize(); + + // When the table unmounts + columnResize.unmount(); + + // Then the cursor is untouched, since only a drag of this table set it + expect(document.body.style.cursor).toBe('grabbing'); + }); + + it('clears the painted widths once the stored width renders', () => { + // Given a drag that was released and whose width was stored + const {handleElement, getHandleProps, readWidth, rerender, initialProps} = renderColumnResize(); + + act(() => { + getHandleProps().onPointerDown?.(createPointerEvent(handleElement, {clientX: 100})); + getHandleProps().onPointerMove?.(createPointerEvent(handleElement, {clientX: 160})); + getHandleProps().onPointerUp?.(createPointerEvent(handleElement, {clientX: 160})); + }); + + expect(readWidth('name')).toBe('260px'); + + // When the stored width comes back from Onyx and the table resolves it + rerender({...initialProps, resolvedColumnWidths: {...resolvedColumnWidths, name: 260}}); + + // Then the painted width steps aside, so the column paints the fallback React rendered and later resolved widths aren't masked + expect(readWidth('name')).toBe(''); + }); +}); diff --git a/tests/unit/components/reportDetails/DynamicReportDetailsPageTest.tsx b/tests/unit/components/reportDetails/DynamicReportDetailsPageTest.tsx index 5af103f904e6..e0573b89ab97 100644 --- a/tests/unit/components/reportDetails/DynamicReportDetailsPageTest.tsx +++ b/tests/unit/components/reportDetails/DynamicReportDetailsPageTest.tsx @@ -92,12 +92,6 @@ describe('DynamicReportDetailsPage', () => { actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, } as ReportAction; - const report: Report = { - ...createRandomReport(Number(reportID), undefined), - parentReportID, - parentReportActionID: parentActionID, - }; - await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`, createRandomReport(Number(parentReportID), undefined)); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { @@ -109,12 +103,7 @@ describe('DynamicReportDetailsPage', () => { @@ -182,12 +171,7 @@ describe('DynamicReportDetailsPage', () => { @@ -247,12 +231,7 @@ describe('DynamicReportDetailsPage', () => { diff --git a/tests/unit/components/withReportOrNotFound.test.tsx b/tests/unit/components/withReportOrNotFound.test.tsx new file mode 100644 index 000000000000..fa96b446f98d --- /dev/null +++ b/tests/unit/components/withReportOrNotFound.test.tsx @@ -0,0 +1,257 @@ +import {act, render, screen} from '@testing-library/react-native'; + +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import ReportOrNotFoundGuard from '@components/ReportOrNotFoundGuard'; +import Text from '@components/Text'; + +import {openReport} from '@libs/actions/Report'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {ReportDetailsNavigatorParamList} from '@libs/Navigation/types'; + +import type {WithReportOrNotFoundProps} from '@pages/inbox/report/withReportOrNotFound'; +import withReportOrNotFound from '@pages/inbox/report/withReportOrNotFound'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import type {Report} from '@src/types/onyx'; + +import {useIsFocused} from '@react-navigation/native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import {createRandomReport} from '../../utils/collections/reports'; +import createMock from '../../utils/createMock'; +import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@libs/actions/Report', () => ({ + ...jest.requireActual>('@libs/actions/Report'), + openReport: jest.fn(), +})); +jest.mock('@components/FullscreenLoadingIndicator', () => { + const {View} = jest.requireActual>>>('react-native'); + return () => ; +}); +jest.mock('@pages/ErrorPage/NotFoundPage', () => { + const {View} = jest.requireActual>>>('react-native'); + return () => ; +}); +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual>('@react-navigation/native'), + useIsFocused: jest.fn(() => true), +})); + +const mockOpenReport = jest.mocked(openReport); +const mockUseIsFocused = jest.mocked(useIsFocused); + +type ScreenProps = PlatformStackScreenProps; + +const reportID = '100'; +const report: Report = {...createRandomReport(Number(reportID), CONST.REPORT.CHAT_TYPE.GROUP), reportName: 'From Onyx', errorFields: undefined}; +const callerReport: Report = {...report, reportName: 'From caller props'}; + +function Content() { + return guarded; +} + +function ForwardedReportName({report: forwardedReport}: WithReportOrNotFoundProps) { + return {forwardedReport.reportName}; +} + +const WrappedContent = withReportOrNotFound()(ForwardedReportName); + +function renderWithHOC() { + render( + + ({})} + route={createMock({params: {reportID}})} + report={callerReport} + policy={undefined} + reportMetadata={undefined} + reportLoadingState={undefined} + isLoadingReportData={false} + /> + , + ); +} + +function renderWithGuard() { + render( + + + + + , + ); +} + +async function seedFinishedLoading() { + await act(async () => { + await Onyx.merge(ONYXKEYS.IS_LOADING_REPORT_DATA, false); + await Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`, {isLoadingInitialReportActions: false}); + }); +} + +async function seedReport() { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + }); +} + +async function removeReport() { + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, null); + }); + await waitForBatchedUpdatesWithAct(); +} + +describe('withReportOrNotFound and ReportOrNotFoundGuard', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(() => { + mockOpenReport.mockClear(); + mockUseIsFocused.mockReturnValue(true); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + }); + + describe.each([ + ['withReportOrNotFound', renderWithHOC], + ['ReportOrNotFoundGuard', renderWithGuard], + ])('%s', (_, renderSubject) => { + it('should render the loading indicator while the report is missing and report data is still loading', async () => { + // Given no report in Onyx and no finished OpenReport call + + // When the screen renders + renderSubject(); + await waitForBatchedUpdatesWithAct(); + + // Then the loading indicator shows instead of the content + expect(screen.getByTestId('loading')).toBeTruthy(); + expect(screen.queryByTestId('content')).toBeNull(); + }); + + it('should render the not found page when loading finished without a report', async () => { + // Given loading has finished and the report never arrived + await seedFinishedLoading(); + + // When the screen renders + renderSubject(); + await waitForBatchedUpdatesWithAct(); + + // Then the not found page shows + expect(screen.getByTestId('notFound')).toBeTruthy(); + expect(screen.queryByTestId('content')).toBeNull(); + }); + + it('should render the content once the report is in Onyx', async () => { + // Given the report is loaded + await seedFinishedLoading(); + await seedReport(); + + // When the screen renders + renderSubject(); + await waitForBatchedUpdatesWithAct(); + + // Then the content shows + expect(screen.getByTestId('content')).toBeTruthy(); + }); + + it('should render nothing when a shown report disappears while the screen is unfocused', async () => { + // Given the content was shown and the screen then lost focus, as when navigating away from a deleted report + await seedFinishedLoading(); + await seedReport(); + renderSubject(); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByTestId('content')).toBeTruthy(); + mockUseIsFocused.mockReturnValue(false); + + // When the report is removed + await removeReport(); + + // Then neither the not found page nor the loading indicator flashes during the transition + expect(screen.queryByTestId('content')).toBeNull(); + expect(screen.queryByTestId('notFound')).toBeNull(); + expect(screen.queryByTestId('loading')).toBeNull(); + }); + + it('should render the not found page when a shown report disappears while the screen stays focused', async () => { + // Given the content was shown and the screen keeps focus with no delete navigation in flight + await seedFinishedLoading(); + await seedReport(); + renderSubject(); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByTestId('content')).toBeTruthy(); + + // When the report is removed + await removeReport(); + + // Then the suppression does not apply and the not found page shows + expect(screen.getByTestId('notFound')).toBeTruthy(); + }); + + it('should render nothing when a shown report disappears while a delete-transaction navigation is in flight', async () => { + // Given the content was shown and the screen keeps focus while a delete-transaction navigation is pending, as when deleting an invoice + await seedFinishedLoading(); + await seedReport(); + renderSubject(); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByTestId('content')).toBeTruthy(); + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL, 'r/1'); + }); + + // When the report is removed + await removeReport(); + + // Then the not found page does not flash before the navigation completes + expect(screen.queryByTestId('content')).toBeNull(); + expect(screen.queryByTestId('notFound')).toBeNull(); + expect(screen.queryByTestId('loading')).toBeNull(); + }); + + it('should call OpenReport when the report is missing and has not been fetched yet', async () => { + // Given a deep link to a report that is not in Onyx and has no finished OpenReport call + + // When the screen renders + renderSubject(); + await waitForBatchedUpdatesWithAct(); + + // Then the report is requested from the server + expect(mockOpenReport).toHaveBeenCalledWith(expect.objectContaining({reportID})); + }); + + it('should not call OpenReport when the report is already loaded', async () => { + // Given the report is already in Onyx + await seedReport(); + + // When the screen renders + renderSubject(); + await waitForBatchedUpdatesWithAct(); + + // Then no request is made + expect(mockOpenReport).not.toHaveBeenCalled(); + }); + }); + + it('should forward the report read from Onyx rather than the one passed by the caller', async () => { + // Given a report in Onyx that differs from the one the caller passes as a prop + await seedFinishedLoading(); + await seedReport(); + + // When the wrapped screen renders + renderWithHOC(); + await waitForBatchedUpdatesWithAct(); + + // Then the wrapper overrides the caller's report with its own Onyx read + expect(screen.getByTestId('content')).toHaveTextContent('From Onyx'); + expect(screen.queryByText('From caller props')).toBeNull(); + }); +}); diff --git a/tests/unit/getImageRecyclingKeyTest.ts b/tests/unit/getImageRecyclingKeyTest.ts index cadf9bf56cec..cb3e63bd6266 100644 --- a/tests/unit/getImageRecyclingKeyTest.ts +++ b/tests/unit/getImageRecyclingKeyTest.ts @@ -13,6 +13,26 @@ describe('getImageRecyclingKey', () => { expect(getImageRecyclingKey({uri: 'https://example.com/image.png'})).toBe('https://example.com/image.png'); }); + test('ignores the URI fragment so a forced reload of the same file keeps the key', () => { + // Given a local receipt that was upgraded in place, so its URI only gains a fragment to force a reload + const original = {uri: 'file:///receipts/receipt.jpg'}; + const upgraded = {uri: 'file:///receipts/receipt.jpg#upgraded1'}; + + // When both sources are turned into recycling keys + // Then the keys match, so expo-image keeps showing the old image instead of blanking the view while the new one decodes + expect(getImageRecyclingKey(upgraded)).toBe(getImageRecyclingKey(original)); + expect(getImageRecyclingKey(upgraded)).toBe('file:///receipts/receipt.jpg'); + }); + + test('keeps a data URI whole because a raw # in it is part of the content', () => { + // Given an inline SVG whose colors use unencoded # characters + const source = {uri: 'data:image/svg+xml;utf8,'}; + + // When the source is turned into a recycling key + // Then nothing is cut off, so two different SVGs never share a key + expect(getImageRecyclingKey(source)).toBe(source.uri); + }); + test('returns URI from first element of array source', () => { const source = [{uri: 'https://example.com/first.png'}, {uri: 'https://example.com/second.png'}]; expect(getImageRecyclingKey(source)).toBe('https://example.com/first.png'); diff --git a/tests/unit/getResizableColumnLayoutTest.ts b/tests/unit/getResizableColumnLayoutTest.ts new file mode 100644 index 000000000000..a6eab5f48338 --- /dev/null +++ b/tests/unit/getResizableColumnLayoutTest.ts @@ -0,0 +1,104 @@ +import getResizableColumnLayout from '@components/Table/columnResize/getResizableColumnLayout'; +import type {TableColumn} from '@components/Table/types'; + +import CONST from '@src/CONST'; + +const {MIN_WIDTH} = CONST.TABLES.COLUMN_RESIZE; + +/** No selection, gaps, margin or padding, so the row is exactly the column sum. */ +const rowChromeWidths = {selectionColumnWidth: 0, totalGapWidth: 0, rowMarginWidth: 0, rowPaddingWidth: 0}; + +function column(key: string, label = key): TableColumn { + return {key, label, sortable: false}; +} + +describe('getResizableColumnLayout', () => { + it('keeps the last column from shrinking while the row fills the table exactly', () => { + // Given three 200px columns filling a 600px table + const columns = [column('name'), column('email'), column('role')]; + + // When the layout is built + const {dragMinWidths} = getResizableColumnLayout({ + columns, + resolvedColumnWidths: {name: 200, email: 200, role: 200}, + columnWidthOverrides: undefined, + tableWidth: 600, + rowChromeWidths, + }); + + // Then the last column can't shrink, since that would pull the row in from the table's right edge + expect(dragMinWidths).toEqual({role: 200}); + }); + + it('lets the last column shrink only by the width that overflows the table', () => { + // Given columns widened 50px past a 600px table + const columns = [column('name'), column('email'), column('role')]; + + // When the layout is built + const {dragMinWidths} = getResizableColumnLayout({ + columns, + resolvedColumnWidths: {name: 200, email: 200, role: 250}, + columnWidthOverrides: undefined, + tableWidth: 600, + rowChromeWidths, + }); + + // Then the last column can give back the overflow and no more, so the row ends exactly at the table's edge + expect(dragMinWidths).toEqual({role: 200}); + }); + + it('falls back to the default drag bound once the overflow is larger than the column', () => { + // Given columns far wider than the table + const columns = [column('name'), column('email'), column('role')]; + + // When the layout is built + const {dragMinWidths} = getResizableColumnLayout({ + columns, + resolvedColumnWidths: {name: 800, email: 800, role: 200}, + columnWidthOverrides: undefined, + tableWidth: 600, + rowChromeWidths, + }); + + // Then the last column shrinks as far as any other, so its edge stays reachable + expect(dragMinWidths).toEqual({role: MIN_WIDTH}); + }); + + it('keeps the last headed column from shrinking when headless columns trail it', () => { + // Given a trailing headless arrow column, with the row filling a 600px table exactly + const columns = [column('name'), column('role'), column('arrow', '')]; + + // When the layout is built + const {dragMinWidths} = getResizableColumnLayout({ + columns, + resolvedColumnWidths: {name: 280, role: 280, arrow: 40}, + columnWidthOverrides: undefined, + tableWidth: 600, + rowChromeWidths, + }); + + // Then the last headed column can't shrink, since the freed room would stretch the fixed-size arrow column + expect(dragMinWidths).toEqual({role: 280}); + }); + + it('grows the last headed column into leftover room, not the trailing headless column', () => { + // Given columns 160px narrower than a 600px table, with a trailing headless arrow column + const columns = [column('name'), column('role'), column('arrow', '')]; + + // When the layout is built + const {gridTemplateColumns, resolvedColumnWidths, dragMinWidths} = getResizableColumnLayout({ + columns, + resolvedColumnWidths: {name: 200, role: 200, arrow: 40}, + columnWidthOverrides: undefined, + tableWidth: 600, + rowChromeWidths, + }); + + // Then only the last headed column's track grows, so the arrow keeps its size + expect(gridTemplateColumns.map((track) => track.includes('1fr'))).toEqual([false, true, false]); + + // And a drag on it starts from the 360px it's painted at, and can't shrink it, so the arrow never stretches + expect(resolvedColumnWidths.role).toBe(360); + expect(dragMinWidths).toEqual({role: 360}); + }); +}); diff --git a/tests/unit/getWorkspaceMenuItemsTest.ts b/tests/unit/getWorkspaceMenuItemsTest.ts index 0be6c2a0dfd0..a4b061e9d80e 100644 --- a/tests/unit/getWorkspaceMenuItemsTest.ts +++ b/tests/unit/getWorkspaceMenuItemsTest.ts @@ -267,6 +267,25 @@ describe('getWorkspaceMenuItems', () => { expect(items.find((item) => item.translationKey === 'workspace.common.workflows')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); }); + it('shows an error indicator on workflows only when a workflow the user can fix has an approver who left the workspace', () => { + // Given a workspace with workflows enabled + const policy = createMock({...buildPolicy(CONST.POLICY.ROLE.ADMIN), areWorkflowsEnabled: true}); + const getWorkflowsIndicator = (hasApprovalWorkflowWithNonMemberApprover: boolean) => + getWorkspaceMenuItems({ + policy, + policyID: policy.id, + currentUserLogin, + icons, + hasApprovalWorkflowWithNonMemberApprover, + convertToDisplayString: () => '', + }).find((item) => item.translationKey === 'workspace.common.workflows')?.brickRoadIndicator; + + // When the menu is built with and without such a workflow + // Then only the broken workflow puts an error on the Workflows row + expect(getWorkflowsIndicator(true)).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); + expect(getWorkflowsIndicator(false)).toBeUndefined(); + }); + it('shows an error indicator when a merchant rule failed to save', () => { const policy = createMock({...buildPolicy(CONST.POLICY.ROLE.ADMIN), areRulesEnabled: true}); diff --git a/tests/unit/hooks/useEditComposerToggle.test.ts b/tests/unit/hooks/useEditComposerToggle.test.ts index 6b70a8023a1b..365ec0f2b7f5 100644 --- a/tests/unit/hooks/useEditComposerToggle.test.ts +++ b/tests/unit/hooks/useEditComposerToggle.test.ts @@ -227,6 +227,35 @@ describe('useEditComposerToggle', () => { expect(onValueChange).not.toHaveBeenCalled(); }); + it('in the RHP on a wide screen, when editing starts, leaves composer value unchanged (inline editor handles edit)', () => { + // Given a report opened in the RHP on a wide screen, where shouldUseNarrowLayout is true but the screen is not small + mockUseResponsiveLayout.mockReturnValue({...wideLayoutResult(), shouldUseNarrowLayout: true, isInNarrowPaneModal: true}); + + const onValueChange = jest.fn(); + const onFocus = jest.fn(); + const composerRef = makeComposerRef(); + + const {rerender} = renderHook(() => + useEditComposerToggle({ + selection: {start: 0, end: 0}, + composerRef, + onValueChange, + onFocus, + }), + ); + + // When editing starts + composerEditStateRef.current = defaultComposerEditState({ + editingState: CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.EDITING, + editingMessage: 'from RHP', + }); + rerender({}); + + // Then the edited message is not copied into the main composer, because desktop edits inline even in the RHP + expect(onValueChange).not.toHaveBeenCalled(); + expect(onFocus).not.toHaveBeenCalled(); + }); + it('on narrow, when edit ends, restores prior draft and selection', () => { const onValueChange = jest.fn(); const onSelectionChange = jest.fn(); diff --git a/tests/unit/hooks/useLocalReceiptThumbnail.test.ts b/tests/unit/hooks/useLocalReceiptThumbnail.test.ts new file mode 100644 index 000000000000..7ea6425187b8 --- /dev/null +++ b/tests/unit/hooks/useLocalReceiptThumbnail.test.ts @@ -0,0 +1,83 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useLocalReceiptThumbnail, {precacheReceiptImage} from '@hooks/useLocalReceiptThumbnail'; + +import {finish, recordUpgrade, start} from '@libs/ReceiptStorage/receiptUpgrades'; + +const mockGenerateThumbnail = jest.fn, [string]>(); +jest.mock('@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio', () => ({ + generateThumbnail: (sourceUri: string) => mockGenerateThumbnail(sourceUri), +})); + +const FOLDER = 'file:///var/mobile/Containers/Data/Application/AAAA/Documents/Receipts-Upload'; + +/** Each test uses its own receipt, since the thumbnail cache outlives a single hook. */ +function buildReceipt(name: string) { + return {name, uri: `${FOLDER}/${name}`}; +} + +function upgradeReceipt(name: string) { + act(() => { + start(name); + recordUpgrade(name); + finish(name); + }); +} + +describe('useLocalReceiptThumbnail', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGenerateThumbnail.mockResolvedValue(null); + }); + + it('hands back the seeded receipt untouched while nothing has replaced it', () => { + const {uri} = buildReceipt('receipt_untouched.jpg'); + precacheReceiptImage(uri); + + const {result} = renderHook(() => useLocalReceiptThumbnail(uri, true)); + + expect(result.current.thumbnailUri).toBe(uri); + expect(result.current.isGenerating).toBe(false); + }); + + it('gives the same file a source the image loaders treat as new once a better capture replaces it', () => { + const {name, uri} = buildReceipt('receipt_replaced.jpg'); + precacheReceiptImage(uri); + + const {result} = renderHook(() => useLocalReceiptThumbnail(uri, true)); + expect(result.current.thumbnailUri).toBe(uri); + + upgradeReceipt(name); + + // The path never changes, so without this the loader would serve the decode it already had. + expect(result.current.thumbnailUri).toBe(`${uri}#upgraded1`); + }); + + it('moves the marker on rather than stacking it when the same receipt is replaced twice', () => { + const {name, uri} = buildReceipt('receipt_replaced_twice.jpg'); + precacheReceiptImage(uri); + + const {result} = renderHook(() => useLocalReceiptThumbnail(uri, true)); + + upgradeReceipt(name); + expect(result.current.thumbnailUri).toBe(`${uri}#upgraded1`); + + upgradeReceipt(name); + // One marker, not two, so the source stays a path the loader can still open. + expect(result.current.thumbnailUri).toBe(`${uri}#upgraded2`); + }); + + it('leaves a receipt that only finished an upgrade alone, since its bytes never moved', () => { + const {name, uri} = buildReceipt('receipt_still_the_snapshot.jpg'); + precacheReceiptImage(uri); + + const {result} = renderHook(() => useLocalReceiptThumbnail(uri, true)); + + act(() => { + start(name); + finish(name); + }); + + expect(result.current.thumbnailUri).toBe(uri); + }); +}); diff --git a/tests/unit/hooks/usePhotoUpgrade.test.ts b/tests/unit/hooks/usePhotoUpgrade.test.ts new file mode 100644 index 000000000000..0e0335bc08b6 --- /dev/null +++ b/tests/unit/hooks/usePhotoUpgrade.test.ts @@ -0,0 +1,625 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import usePhotoUpgrade from '@pages/iou/request/step/IOURequestStepScan/hooks/usePhotoUpgrade'; + +import CONST from '@src/CONST'; + +import type {Camera, Orientation, PhotoFile} from 'react-native-vision-camera'; + +const mockReplace = jest.fn, [string, string, (() => boolean) | undefined]>(); +const mockDiscard = jest.fn, [string]>(); +const mockStartUpgrade = jest.fn(); +const mockFinishUpgrade = jest.fn(); +const mockRecordUpgrade = jest.fn(); +const mockWasClaimed = jest.fn(); + +jest.mock('@libs/ReceiptStorage/receiptUpgrades', () => ({ + start: (name: string) => mockStartUpgrade(name), + finish: (name: string) => mockFinishUpgrade(name), + recordUpgrade: (name: string) => mockRecordUpgrade(name), + isClaimedForRead: (name: string) => mockWasClaimed(name), +})); + +jest.mock('@libs/ReceiptStorage', () => ({ + __esModule: true, + default: { + overwrite: (durableName: string, path: string, shouldAbort?: () => boolean) => mockReplace(durableName, path, shouldAbort), + discard: (path: string) => mockDiscard(path), + }, +})); + +const mockStartSpan = jest.fn]>(); +const mockEndSpanWithAttributes = jest.fn]>(); +const mockSetAttributes = jest.fn]>(); +const mockGetSpan = jest.fn<{setAttributes: (attributes: Record) => void} | undefined, [string]>(); + +jest.mock('@libs/telemetry/activeSpans', () => ({ + startSpan: (spanId: string, options: Record) => mockStartSpan(spanId, options), + endSpanWithAttributes: (spanId: string, attributes: Record) => mockEndSpanWithAttributes(spanId, attributes), + getSpan: (spanId: string) => mockGetSpan(spanId), +})); + +const mockRotate = jest.fn, [string, Orientation | undefined]>(); + +jest.mock('@pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright', () => ({ + __esModule: true, + default: (path: string, orientation?: Orientation) => mockRotate(path, orientation), +})); + +jest.mock('react-native-fs', () => ({TemporaryDirectoryPath: '/tmp'})); + +const UPGRADE_SPAN_ID = `${CONST.TELEMETRY.SPAN_RECEIPT_UPGRADE}_receipt_1234.jpg`; +const DURABLE_NAME = 'receipt_1234.jpg'; +const PHOTO_PATH = '/tmp/still.jpg'; +const UPRIGHT_PATH = '/tmp/ImageManipulator/upright.jpg'; +const PHOTO_CAPTURE_TIMEOUT_MS = 3000; +const ROTATE_TIMEOUT_MS = 5000; + +function buildPhoto(path = PHOTO_PATH): PhotoFile { + // A portrait capture off a landscape sensor reports `landscape-left`, per VisionCamera's own docs. + return {path, width: 1920, height: 1440, isRawPhoto: false, orientation: 'landscape-left', isMirrored: false}; +} + +/** A camera whose captures the test resolves or rejects on demand, in the order they were started. */ +function buildCamera() { + const started: Array<{resolve: (photo: PhotoFile) => void; reject: (error: Error) => void}> = []; + const takePhoto = jest.fn( + () => + new Promise((resolve, reject) => { + started.push({resolve, reject}); + }), + ); + + return { + // The hook only ever calls `takePhoto`, so a stub of the one method is all the test needs. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + camera: {takePhoto} as unknown as Camera, + takePhoto, + landPhoto: async (photo = buildPhoto(), index = 0) => { + await act(async () => { + started.at(index)?.resolve(photo); + }); + }, + failCapture: async (index = 0) => { + await act(async () => { + started.at(index)?.reject(new Error('Camera is closed.')); + }); + }, + }; +} + +describe('usePhotoUpgrade', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + mockReplace.mockResolvedValue(DURABLE_NAME); + mockDiscard.mockResolvedValue(undefined); + mockRotate.mockResolvedValue(UPRIGHT_PATH); + mockWasClaimed.mockReturnValue(false); + mockGetSpan.mockReturnValue({setAttributes: mockSetAttributes}); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('holds the camera session open from the shutter until the photo lands', async () => { + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + expect(result.current.hasPendingPhotoCapture).toBe(false); + + act(() => { + result.current.startPhotoCapture(camera); + }); + expect(result.current.hasPendingPhotoCapture).toBe(true); + + await landPhoto(); + expect(result.current.hasPendingPhotoCapture).toBe(false); + }); + + it('swaps the upright photo onto the receipt and cleans up the file it rotated from', async () => { + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await landPhoto(); + + // The hold the photo was taken in decides which way it is turned, so it has to be passed along. + expect(mockRotate).toHaveBeenCalledWith(PHOTO_PATH, 'landscape-left'); + expect(mockReplace).toHaveBeenCalledWith(DURABLE_NAME, UPRIGHT_PATH, expect.any(Function)); + expect(mockDiscard).toHaveBeenCalledWith(PHOTO_PATH); + // Only a receipt whose bytes really changed gets a new version, which is what makes a view reload it. + expect(mockRecordUpgrade).toHaveBeenCalledWith(DURABLE_NAME); + // The upload waits on this hold, so it has to be let go once the receipt is upgraded. + expect(mockFinishUpgrade).toHaveBeenCalledWith(DURABLE_NAME); + }); + + it('swaps the photo as captured when it needs no rotation, leaving nothing to clean up', async () => { + mockRotate.mockResolvedValue(undefined); + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await landPhoto(); + + expect(mockReplace).toHaveBeenCalledWith(DURABLE_NAME, PHOTO_PATH, expect.any(Function)); + expect(mockDiscard).not.toHaveBeenCalled(); + }); + + it('keeps the snapshot and releases the session when the photo misses the deadline', async () => { + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + + await act(async () => { + jest.advanceTimersByTime(PHOTO_CAPTURE_TIMEOUT_MS); + }); + + expect(result.current.hasPendingPhotoCapture).toBe(false); + expect(mockReplace).not.toHaveBeenCalled(); + expect(mockFinishUpgrade).toHaveBeenCalledWith(DURABLE_NAME); + // The file never moved, so nothing that shows it should be told to load it again. + expect(mockRecordUpgrade).not.toHaveBeenCalled(); + + // A photo that turns up after the deadline has no consumer, so it must not stay on disk. + await landPhoto(); + expect(mockDiscard).toHaveBeenCalledWith(PHOTO_PATH); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('leaves the receipt alone once an upload has claimed it', async () => { + mockWasClaimed.mockReturnValue(true); + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await landPhoto(); + + // Renaming the file now would change it under the upload that claimed it. + expect(mockReplace).not.toHaveBeenCalled(); + expect(mockRecordUpgrade).not.toHaveBeenCalled(); + expect(mockDiscard).toHaveBeenCalledWith(PHOTO_PATH); + expect(mockDiscard).toHaveBeenCalledWith(UPRIGHT_PATH); + }); + + it('keeps the snapshot when the rotate never settles, rather than holding the upgrade open', async () => { + mockRotate.mockReturnValue(new Promise(() => {})); + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await landPhoto(); + + expect(mockFinishUpgrade).not.toHaveBeenCalled(); + + await act(async () => { + jest.runOnlyPendingTimers(); + }); + + // Left registered, the receipt would count as upgrading for the rest of the launch. + expect(mockFinishUpgrade).toHaveBeenCalledWith(DURABLE_NAME); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('deletes a rotated copy that lands after the rotate deadline, which nothing else would claim', async () => { + let landRotate: (path: string) => void = () => {}; + mockRotate.mockReturnValue( + new Promise((resolve) => { + landRotate = resolve; + }), + ); + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await landPhoto(); + + // The rotate misses its cap, so the swap gives up and keeps the snapshot. + await act(async () => { + jest.advanceTimersByTime(ROTATE_TIMEOUT_MS); + }); + expect(mockReplace).not.toHaveBeenCalled(); + + // The manipulator still finishes and writes its JPEG, with nothing left pointing at it. + await act(async () => { + landRotate(UPRIGHT_PATH); + }); + + expect(mockDiscard).toHaveBeenCalledWith(UPRIGHT_PATH); + }); + + it('hands the swap a guard that still reports a claim made after the swap began', async () => { + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await landPhoto(); + + const firstSwap = mockReplace.mock.calls.at(0); + expect(firstSwap).toBeDefined(); + const shouldAbort = firstSwap?.[2]; + expect(shouldAbort?.()).toBe(false); + + // The swap yields several times before it renames anything, so a reader can give up in between. + mockWasClaimed.mockReturnValue(true); + expect(shouldAbort?.()).toBe(true); + }); + + it('records a swap that backed out for an upload as claimed rather than failed', async () => { + mockReplace.mockRejectedValue(new Error('a reader stopped waiting')); + mockWasClaimed.mockReturnValue(false); + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + + // Decided while the swap was running, which is the case the in-swap guard exists for. + mockWasClaimed.mockReturnValue(true); + await landPhoto(); + + expect(mockRecordUpgrade).not.toHaveBeenCalled(); + expect(mockFinishUpgrade).toHaveBeenCalledWith(DURABLE_NAME); + }); + + it('deletes the rotated copy when the swap fails, so failed upgrades do not pile up', async () => { + mockReplace.mockRejectedValue(new Error('no space left on device')); + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await landPhoto(); + + expect(mockDiscard).toHaveBeenCalledWith(UPRIGHT_PATH); + expect(mockRecordUpgrade).not.toHaveBeenCalled(); + }); + + it('keeps the snapshot when the capture itself fails', async () => { + const {camera, failCapture} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await failCapture(); + + expect(result.current.hasPendingPhotoCapture).toBe(false); + expect(mockReplace).not.toHaveBeenCalled(); + expect(mockDiscard).not.toHaveBeenCalled(); + // A capture that failed must not leave the upload waiting on it. + expect(mockFinishUpgrade).toHaveBeenCalledWith(DURABLE_NAME); + }); + + it('deletes the photo when the receipt it was meant for never arrived', async () => { + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.discardPendingPhoto(); + }); + await landPhoto(); + + expect(mockReplace).not.toHaveBeenCalled(); + expect(mockDiscard).toHaveBeenCalledWith(PHOTO_PATH); + }); + + it('deletes the photo of a capture the next shutter replaced before it was claimed', async () => { + const {camera, takePhoto, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + // Two shutters, and the first was never handed a receipt — so nothing else will clean up after it. + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.startPhotoCapture(camera); + }); + + // The second shutter replaces the first capture without starting one of its own, since the camera + // is still busy with it. + expect(takePhoto).toHaveBeenCalledTimes(1); + + // The abandoned photo still lands, and is still deleted, even though nothing replaced it. + await landPhoto(buildPhoto('/tmp/orphaned.jpg'), 0); + expect(mockDiscard).toHaveBeenCalledWith('/tmp/orphaned.jpg'); + expect(mockRotate).not.toHaveBeenCalled(); + }); + + it('deletes an unclaimed photo when the scan screen goes away', async () => { + const {camera, landPhoto} = buildCamera(); + const {result, unmount} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + unmount(); + + await landPhoto(); + expect(mockDiscard).toHaveBeenCalledWith(PHOTO_PATH); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('does not start a second photo over one the camera is still taking, since the next snapshot pays for it', async () => { + const {camera, takePhoto, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + + // The user came back to the camera and shot again before the first photo landed. Starting a + // second capture here is what made a later `takeSnapshot` take seconds on device. + act(() => { + result.current.startPhotoCapture(camera); + }); + expect(takePhoto).toHaveBeenCalledTimes(1); + + // The receipt this shutter produced keeps its snapshot rather than waiting on a contended camera. + act(() => { + result.current.upgradeReceiptWithPhoto('receipt_5678.jpg'); + }); + expect(mockStartUpgrade).not.toHaveBeenCalledWith('receipt_5678.jpg'); + + await landPhoto(buildPhoto(), 0); + }); + + it('takes a photo again once the camera has finished the previous one', async () => { + const {camera, takePhoto, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await landPhoto(buildPhoto(), 0); + + // The camera is free, so the guard lets the next shutter upgrade as normal. + act(() => { + result.current.startPhotoCapture(camera); + }); + expect(takePhoto).toHaveBeenCalledTimes(2); + }); + describe('telemetry', () => { + /** The outcome of the last ended upgrade span, which is what a dashboard groups by. */ + function readOutcome() { + const call = mockEndSpanWithAttributes.mock.calls.at(-1); + return {spanId: call?.[0], attributes: call?.[1]}; + } + + it('reports an upgrade that landed, with the resolution it won', async () => { + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + // Named per receipt, so a later capture cannot end this one's span. + expect(mockStartSpan).toHaveBeenCalledWith(UPGRADE_SPAN_ID, expect.objectContaining({name: CONST.TELEMETRY.SPAN_RECEIPT_UPGRADE})); + + await landPhoto(); + + expect(readOutcome()).toEqual({ + spanId: UPGRADE_SPAN_ID, + attributes: { + [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.UPGRADED, + [CONST.TELEMETRY.ATTRIBUTE_PHOTO_WIDTH]: 1920, + [CONST.TELEMETRY.ATTRIBUTE_PHOTO_HEIGHT]: 1440, + }, + }); + }); + + it('separates a capture that missed its deadline from one that failed', async () => { + const {camera, failCapture} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await act(async () => { + jest.advanceTimersByTime(PHOTO_CAPTURE_TIMEOUT_MS); + }); + + expect(readOutcome().attributes).toEqual({[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.CAPTURE_TIMED_OUT}); + + mockEndSpanWithAttributes.mockClear(); + const second = buildCamera(); + const {result: secondResult} = renderHook(() => usePhotoUpgrade()); + act(() => { + secondResult.current.startPhotoCapture(second.camera); + }); + act(() => { + secondResult.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await second.failCapture(); + await failCapture(); + + expect(readOutcome().attributes).toEqual({[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.CAPTURE_FAILED}); + }); + + it('reports a receipt an upload claimed, which is a backed-out swap rather than a failure', async () => { + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + mockWasClaimed.mockReturnValue(true); + await landPhoto(); + + expect(readOutcome().attributes).toEqual({[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.CLAIMED_FOR_UPLOAD}); + }); + + it('reports a rotate that never settled, the one failure seen repeatedly on device', async () => { + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + mockRotate.mockReturnValue(new Promise(() => {})); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await landPhoto(); + await act(async () => { + jest.advanceTimersByTime(ROTATE_TIMEOUT_MS); + }); + + expect(readOutcome().attributes).toEqual({[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.ROTATE_TIMED_OUT}); + }); + + it('reports a rotate that failed outright apart from the swap, so the outcomes stay comparable', async () => { + // Given a rotate that rejects before the swap starts, e.g. the photo size cannot be read + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + mockRotate.mockRejectedValue(new Error('could not read the image size')); + + // When the photo lands and the upgrade runs + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await landPhoto(); + + // Then the outcome names the rotate rather than counting it as a failed swap + expect(readOutcome().attributes).toEqual({[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.ROTATE_FAILED}); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('marks the attempt on the capture span, so a lost outcome is still countable', () => { + const {camera} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + + // The capture span ends in milliseconds, so it survives the background that would cancel the + // upgrade span. It carries the denominator the success rate is computed against. + expect(mockGetSpan).toHaveBeenCalledWith(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE); + expect(mockSetAttributes).toHaveBeenCalledWith({[CONST.TELEMETRY.ATTRIBUTE_UPGRADE_ATTEMPTED]: true}); + }); + + it('reports one outcome per upgrade, even when the swap rejects after it already reported', async () => { + const {camera, landPhoto} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + // `recordUpgrade` runs in the same `then` as the reported outcome, so throwing there is what + // actually reaches the outer catch. A rejected `discard` would not: `discardPhoto` swallows it. + mockReplace.mockResolvedValue(DURABLE_NAME); + // `Once`, because `clearAllMocks` between tests clears calls but keeps implementations. + mockRecordUpgrade.mockImplementationOnce(() => { + throw new Error('bookkeeping failed after the bytes had already changed'); + }); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + await landPhoto(); + + // The catch below runs and computes `swap_failed`, but the upgrade already reported and the + // receipt really was upgraded, so the first outcome has to stand. + expect(mockRecordUpgrade).toHaveBeenCalled(); + expect(mockEndSpanWithAttributes).toHaveBeenCalledTimes(1); + expect(readOutcome().attributes).toEqual({ + [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.UPGRADED, + [CONST.TELEMETRY.ATTRIBUTE_PHOTO_WIDTH]: 1920, + [CONST.TELEMETRY.ATTRIBUTE_PHOTO_HEIGHT]: 1440, + }); + }); + + it('records a shutter that skipped the upgrade on the capture span, since no receipt exists yet', () => { + const {camera} = buildCamera(); + const {result} = renderHook(() => usePhotoUpgrade()); + + act(() => { + result.current.startPhotoCapture(camera); + }); + act(() => { + result.current.upgradeReceiptWithPhoto(DURABLE_NAME); + }); + // The first capture is still running, so this shutter keeps its snapshot. + act(() => { + result.current.startPhotoCapture(camera); + }); + + expect(mockGetSpan).toHaveBeenCalledWith(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE); + expect(mockSetAttributes).toHaveBeenCalledWith({ + [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_ATTEMPTED]: false, + [CONST.TELEMETRY.ATTRIBUTE_UPGRADE_OUTCOME]: CONST.TELEMETRY.UPGRADE_OUTCOME.STACKED_CAPTURE_SKIPPED, + }); + // A skipped shutter must never end the span of the upgrade already running. + expect(mockEndSpanWithAttributes).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/hooks/useReceiptUpgradeCount.test.ts b/tests/unit/hooks/useReceiptUpgradeCount.test.ts new file mode 100644 index 000000000000..8067e8867cb3 --- /dev/null +++ b/tests/unit/hooks/useReceiptUpgradeCount.test.ts @@ -0,0 +1,79 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useReceiptUpgradeCount from '@hooks/useReceiptUpgradeCount'; + +import {finish, recordUpgrade, start} from '@libs/ReceiptStorage/receiptUpgrades'; + +const FOLDER = 'file:///var/mobile/Containers/Data/Application/AAAA/Documents/Receipts-Upload'; +const DURABLE_NAME = 'receipt_1234.jpg'; +const RECEIPT_URI = `${FOLDER}/${DURABLE_NAME}`; + +describe('useReceiptUpgradeCount', () => { + it('reports nothing for a receipt that has never been upgraded', () => { + const {result} = renderHook(() => useReceiptUpgradeCount(`${FOLDER}/receipt_untouched.jpg`)); + + expect(result.current).toBe(0); + }); + + it('reports nothing for a view that has no source yet', () => { + const {result} = renderHook(() => useReceiptUpgradeCount(undefined)); + + expect(result.current).toBe(0); + }); + + it('reports nothing for a bundled asset, which is a numeric module ID on native', () => { + // Given a source that is a bundled image such as ReceiptGeneric, which native resolves to a numeric asset ID + const bundledAsset = 42; + + // When the hook reads its upgrade count + const {result} = renderHook(() => useReceiptUpgradeCount(bundledAsset)); + + // Then it reports no upgrade instead of throwing on `split` + expect(result.current).toBe(0); + }); + + it('goes up when the receipt behind the path is replaced, since the path itself never changes', () => { + const {result} = renderHook(() => useReceiptUpgradeCount(RECEIPT_URI)); + + expect(result.current).toBe(0); + + act(() => { + start(DURABLE_NAME); + recordUpgrade(DURABLE_NAME); + finish(DURABLE_NAME); + }); + + expect(result.current).toBe(1); + }); + + it('stays put for an upgrade that finished without replacing the file', () => { + const name = 'receipt_kept_its_snapshot.jpg'; + const {result} = renderHook(() => useReceiptUpgradeCount(`${FOLDER}/${name}`)); + + act(() => { + start(name); + finish(name); + }); + + expect(result.current).toBe(0); + }); + + it('reads the same count back from a source it has already handed out', () => { + const name = 'receipt_handed_back.jpg'; + act(() => { + start(name); + recordUpgrade(name); + finish(name); + }); + + // A view passes the source it was given straight back in. Keying on the fragment or the query would + // make that look like a different receipt every time the file changed. + const {result: plain} = renderHook(() => useReceiptUpgradeCount(`${FOLDER}/${name}`)); + const {result: withFragment} = renderHook(() => useReceiptUpgradeCount(`${FOLDER}/${name}#upgraded1`)); + const {result: withQuery} = renderHook(() => useReceiptUpgradeCount(`${FOLDER}/${name}?v=1`)); + + expect(plain.current).toBe(1); + expect(withFragment.current).toBe(1); + expect(withQuery.current).toBe(1); + }); +}); diff --git a/tests/unit/hooks/useSearchBulkActionsTest.ts b/tests/unit/hooks/useSearchBulkActionsTest.ts index e61cd7a12049..91818fa4c099 100644 --- a/tests/unit/hooks/useSearchBulkActionsTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsTest.ts @@ -220,6 +220,13 @@ const expenseReportQueryJSON: SearchQueryJSON = { filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: 'type', right: 'expense-report'}, }; +const invoiceQueryJSON: SearchQueryJSON = { + ...baseQueryJSON, + inputQuery: 'type:invoice status:all', + type: CONST.SEARCH.DATA_TYPES.INVOICE, + filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: 'type', right: 'invoice'}, +}; + const groupedExpenseQueryJSON: SearchQueryJSON = { ...baseQueryJSON, inputQuery: 'type:expense sortBy:groupMerchant sortOrder:asc groupBy:merchant', @@ -286,6 +293,33 @@ describe('useSearchBulkActions - CSV export flow', () => { await Onyx.clear(); }); + it('uses the unreported-expenses warning for a single expense report', async () => { + // Given a single selected expense report + mockSelectedTransactions = {report1: makeSelectedTransaction()}; + + // When the bulk actions are created for an expense-report search + renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}), {wrapper: OnyxListItemProvider}); + + // Then the report deletion prompt explains that its expenses become unreported + await waitFor(() => { + expect(mockTranslate).toHaveBeenCalledWith('iou.deleteExpenseReportConfirmation'); + }); + }); + + it('uses the generic report confirmation for a single invoice', async () => { + // Given a single selected invoice + mockSelectedTransactions = {report1: makeSelectedTransaction()}; + + // When the bulk actions are created for an invoice search + renderHook(() => useSearchBulkActions({queryJSON: invoiceQueryJSON}), {wrapper: OnyxListItemProvider}); + + // Then the report deletion prompt does not claim that expenses become unreported + await waitFor(() => { + expect(mockTranslate).toHaveBeenCalledWith('iou.deleteReportConfirmation', {count: 1}); + }); + expect(mockTranslate).not.toHaveBeenCalledWith('iou.deleteExpenseReportConfirmation'); + }); + it('handleBasicExport with select-all tracks the export', async () => { mockAreAllMatchingItemsSelected = true; mockSelectedTransactions = {tx1: makeSelectedTransaction()}; diff --git a/tests/unit/libs/prepareRequestPayloadNativeTest.ts b/tests/unit/libs/prepareRequestPayloadNativeTest.ts index 9e5853d42ff6..c9fab238f83e 100644 --- a/tests/unit/libs/prepareRequestPayloadNativeTest.ts +++ b/tests/unit/libs/prepareRequestPayloadNativeTest.ts @@ -24,13 +24,22 @@ jest.mock('@libs/telemetry/ReceiptObservability', () => ({ })); const RECEIPTS_FOLDER = '/Containers/Data/Application/CURRENT/Documents/Receipts-Upload'; +const mockSettle = jest.fn, [string]>(() => Promise.resolve()); + +const mockResolveReceiptUri = (source?: string) => { + const name = source?.includes('/Receipts-Upload/') ? source.split('/').pop() : undefined; + return name ? `file://${RECEIPTS_FOLDER}/${name}` : source; +}; + jest.mock('@libs/ReceiptStorage', () => ({ __esModule: true, default: { - resolve: (source?: string) => { - const name = source?.includes('/Receipts-Upload/') ? source.split('/').pop() : undefined; - return name ? `file://${RECEIPTS_FOLDER}/${name}` : source; + locate: (source?: string) => { + const uri = mockResolveReceiptUri(source); + return mockCheckFileExists(uri).then(({exists}) => (exists ? uri : undefined)); }, + resolve: (source?: string) => mockResolveReceiptUri(source), + settle: (durableName: string) => mockSettle(durableName), }, })); @@ -149,4 +158,70 @@ describe('prepareRequestPayload (native)', () => { expect(formData.get('amount')).toBe('100'); expect(formData.has('undefinedField')).toBe(false); }); + + describe('a receipt sent as a file object without a source, as a request queued by an older app version sends it', () => { + /** `clearAllMocks` keeps a `mockReturnValue`, so a deferred one would leak into the next test. */ + beforeEach(() => { + mockSettle.mockReturnValue(Promise.resolve()); + }); + + /** The payload walks one key per microtask, so settle the whole queue rather than counting hops. */ + const flushMicrotasks = () => + new Promise((resolve) => { + setImmediate(resolve); + }); + + it('claims the file before reading it, and waits only while a claimed swap finishes renaming', async () => { + // Given a swap that has already committed to renaming the receipt file + let releaseCommittedSwap: () => void = () => {}; + mockSettle.mockReturnValue( + new Promise((resolve) => { + releaseCommittedSwap = resolve; + }), + ); + + // When an old queued ReplaceReceipt request is prepared with only the file object + let hasPrepared = false; + const prepared = prepareRequestPayload( + 'ReplaceReceipt', + { + transactionID: '1', + receipt: {uri: `file://${RECEIPTS_FOLDER}/CAM-1.jpg`, name: 'CAM-1.jpg', type: 'image/jpeg'}, + }, + false, + ).then((formData) => { + hasPrepared = true; + return formData; + }); + await flushMicrotasks(); + + // Then the file is claimed by name and the payload waits for the swap to finish + expect(mockSettle).toHaveBeenCalledWith('CAM-1.jpg'); + // `settle` only holds the payload while a swap has already committed to its two renames, since + // reading the receipt mid-rename would send the old bytes, the new ones, or nothing at all. + expect(hasPrepared).toBe(false); + + releaseCommittedSwap(); + await prepared; + expect(hasPrepared).toBe(true); + expect(mockValidateFormDataParameter).toHaveBeenCalledWith('ReplaceReceipt', 'receipt', expect.objectContaining({name: 'CAM-1.jpg'})); + }); + + it('never reaches locate, since a file object carries no receipt source to resolve', async () => { + // Given an old queued ReplaceReceipt request whose receipt has a uri but no source + // When the payload is prepared + await prepareRequestPayload( + 'ReplaceReceipt', + { + transactionID: '1', + receipt: {uri: `file://${RECEIPTS_FOLDER}/CAM-1.jpg`, name: 'CAM-1.jpg', type: 'image/jpeg'}, + }, + false, + ); + + // Then nothing checks the filesystem or reports the receipt as dropped, because there is no source to locate + expect(mockCheckFileExists).not.toHaveBeenCalled(); + expect(mockLogReceiptDropped).not.toHaveBeenCalled(); + }); + }); }); diff --git a/tests/unit/libs/receiptReadDuringSwapTest.ts b/tests/unit/libs/receiptReadDuringSwapTest.ts new file mode 100644 index 000000000000..4d3bd2db6207 --- /dev/null +++ b/tests/unit/libs/receiptReadDuringSwapTest.ts @@ -0,0 +1,275 @@ +import {checkIfLocalFileIsAccessible, navigateToStartStepIfScanFileCannotBeRead} from '@libs/actions/IOU/Receipt'; +import type ValidateReceiptFileNative from '@libs/fileDownload/validateReceiptFile/index.native'; +import fileURIToPath from '@libs/fileURIToPath'; +import ReceiptStorage from '@libs/ReceiptStorage'; + +import CONST from '@src/CONST'; + +const FOLDER = '/var/mobile/Containers/Data/Application/AAAA-1111/Documents/Receipts-Upload'; +const RECEIPT = 'CAM-1.jpg'; +const RECEIPT_PATH = `${FOLDER}/${RECEIPT}`; +const RECEIPT_URI = `file://${RECEIPT_PATH}`; + +const mockExisting = new Set(); +const mockDirectories = new Set(); +const mockIsOnDisk = (path?: string) => mockExisting.has(fileURIToPath(path ?? '')); + +let mockHoldRead: Promise | undefined; +const mockStat = (path?: string) => (mockHoldRead ? mockHoldRead.then(() => mockIsOnDisk(path)) : Promise.resolve(mockIsOnDisk(path))); + +jest.mock('react-native-fs', () => ({ + exists: (path: string) => Promise.resolve(mockExisting.has(path) || mockDirectories.has(path)), + moveFile: jest.fn(), + mkdir: () => Promise.resolve(), + unlink: (path: string) => { + mockExisting.delete(path); + return Promise.resolve(); + }, + readDir: () => Promise.resolve([]), + writeFile: () => Promise.resolve(), +})); + +jest.mock('@libs/fileDownload/checkFileExists', () => ({ + __esModule: true, + default: (path?: string) => mockStat(path), + checkFileExistsWithReason: (path?: string) => mockStat(path).then((exists) => ({exists})), +})); + +jest.mock('@libs/fileDownload/FileUtils', () => ({ + ...jest.requireActual>('@libs/fileDownload/FileUtils'), + readFileAsync: (path: string, fileName: string, onSuccess: (file: {uri: string}) => void, onFailure: () => void) => { + if (mockIsOnDisk(path)) { + onSuccess({uri: path}); + } else { + onFailure(); + } + return Promise.resolve(); + }, +})); + +jest.mock('@libs/getReceiptsUploadFolderPath', () => ({ + __esModule: true, + default: () => FOLDER, +})); + +// Jest resolves the bare specifier to the web implementation, which has no swap to race. +jest.mock('@libs/ReceiptStorage', () => jest.requireActual>('@libs/ReceiptStorage/index.native.ts')); + +jest.mock('@libs/Navigation/Navigation', () => ({navigate: jest.fn()})); + +jest.mock('@libs/IOUUtils', () => ({ + ...jest.requireActual>('@libs/IOUUtils'), + navigateToStartMoneyRequestStep: jest.fn(), +})); + +const {default: validateReceiptFile}: {default: typeof ValidateReceiptFileNative} = jest.requireActual('@libs/fileDownload/validateReceiptFile/index.native.ts'); + +const mockMoveFile: jest.Mock, [string, string]> = jest.requireMock<{moveFile: jest.Mock, [string, string]>}>('react-native-fs').moveFile; + +async function holdSwapWithReceiptMissing() { + let release: () => void = () => {}; + const receiptMissing = new Promise((resolve) => { + mockMoveFile.mockImplementation((from: string, to: string) => { + mockExisting.delete(from); + if (from === `${RECEIPT_PATH}.receipt-swap-staged` && to === RECEIPT_PATH) { + resolve(); + return new Promise((settle) => { + release = () => { + mockExisting.add(to); + settle(); + }; + }); + } + mockExisting.add(to); + return Promise.resolve(); + }); + }); + + const swap = ReceiptStorage.overwrite(RECEIPT, '/var/mobile/tmp/still.jpg'); + await receiptMissing; + + return { + finishSwap: () => { + release(); + return swap; + }, + }; +} + +describe('reading a receipt while an upgrade swaps it', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockExisting.clear(); + mockDirectories.clear(); + mockExisting.add(RECEIPT_PATH); + mockExisting.add('/var/mobile/tmp/still.jpg'); + mockHoldRead = undefined; + }); + + const flushPendingWork = () => + new Promise((resolve) => { + setImmediate(resolve); + }); + + it('confirms the receipt is readable instead of reporting it unreadable', async () => { + // Given an upgrade swap caught between its two renames, with nothing at the receipt's own path + const {finishSwap} = await holdSwapWithReceiptMissing(); + expect(mockExisting.has(RECEIPT_PATH)).toBe(false); + + // When the confirmation page checks the receipt can be read + const onSuccess = jest.fn(); + const onFailure = jest.fn(); + const check = checkIfLocalFileIsAccessible(RECEIPT, RECEIPT_URI, 'image/jpeg', onSuccess, onFailure); + await finishSwap(); + await check; + + // Then the check sees the upgraded receipt. A failure here restarts the request and drops the receipt. + expect(onFailure).not.toHaveBeenCalled(); + expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({uri: RECEIPT_URI})); + }); + + it('keeps the receipt when the participants step checks it mid-swap', async () => { + // Given an upgrade swap caught between its two renames + const {finishSwap} = await holdSwapWithReceiptMissing(); + + // When the participants step checks the scanned file can be read + const onSuccess = jest.fn(); + const onFailure = jest.fn(); + const check = navigateToStartStepIfScanFileCannotBeRead(RECEIPT, RECEIPT_URI, onSuccess, CONST.IOU.REQUEST_TYPE.MANUAL, CONST.IOU.TYPE.SUBMIT, '1', '2', 'image/jpeg', onFailure); + await finishSwap(); + await check; + + // Then the receipt is kept. The failure path clears it from the transaction and sends the user back to scan. + expect(onFailure).not.toHaveBeenCalled(); + expect(onSuccess).toHaveBeenCalled(); + }); + + it('passes receipt validation on the confirmation page', async () => { + // Given an upgrade swap caught between its two renames + const {finishSwap} = await holdSwapWithReceiptMissing(); + + // When the confirmation page validates the receipt file before submit + const onSuccess = jest.fn(); + const onFailure = jest.fn(); + const check = validateReceiptFile(RECEIPT, RECEIPT_URI, 'image/jpeg', onSuccess, onFailure); + await finishSwap(); + await check; + + // Then validation passes rather than flagging a receipt that is only being swapped + expect(onFailure).not.toHaveBeenCalled(); + expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({uri: RECEIPT_URI})); + }); + + it('keeps the receipt when a validation that started before the swap lands in its gap', async () => { + // Given an upgrade swap still staging its new file, before it has touched the receipt + const holds: Record<'staging' | 'secondRename', () => void> = { + staging: () => {}, + secondRename: () => {}, + }; + let stagingStarted: () => void = () => {}; + let gapOpened: () => void = () => {}; + const isStaging = new Promise((resolve) => { + stagingStarted = resolve; + }); + const isInGap = new Promise((resolve) => { + gapOpened = resolve; + }); + mockMoveFile.mockImplementation((from: string, to: string) => { + const move = () => { + mockExisting.delete(from); + mockExisting.add(to); + }; + const hold = (name: keyof typeof holds, signal: () => void) => { + signal(); + return new Promise((settle) => { + holds[name] = () => { + move(); + settle(); + }; + }); + }; + if (to === `${RECEIPT_PATH}.receipt-swap-staged`) { + return hold('staging', stagingStarted); + } + if (from === `${RECEIPT_PATH}.receipt-swap-staged` && to === RECEIPT_PATH) { + return hold('secondRename', gapOpened); + } + move(); + return Promise.resolve(); + }); + const swap = ReceiptStorage.overwrite(RECEIPT, '/var/mobile/tmp/still.jpg'); + await isStaging; + + // And a validation that has started reading the file, slowly + let finishRead: () => void = () => {}; + mockHoldRead = new Promise((resolve) => { + finishRead = resolve; + }); + const onSuccess = jest.fn(); + const onFailure = jest.fn(); + const check = validateReceiptFile(RECEIPT, RECEIPT_URI, 'image/jpeg', onSuccess, onFailure); + await flushPendingWork(); + + // When the swap reaches its gap and the read lands there, finding nothing at the receipt's path + holds.staging(); + await isInGap; + expect(mockExisting.has(RECEIPT_PATH)).toBe(false); + mockHoldRead = undefined; + finishRead(); + await flushPendingWork(); + + // Then the missed read is not reported while the swap is still running + expect(onFailure).not.toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + + // And once the swap is done, the recheck finds the upgraded receipt and it passes + holds.secondRename(); + await swap; + await check; + expect(onFailure).not.toHaveBeenCalled(); + expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({uri: RECEIPT_URI})); + }); + + it('still reports a receipt that is really gone', async () => { + // Given a receipt that is missing with no swap and no backup + mockExisting.clear(); + + // When the confirmation page validates it + const onSuccess = jest.fn(); + const onFailure = jest.fn(); + await validateReceiptFile(RECEIPT, RECEIPT_URI, 'image/jpeg', onSuccess, onFailure); + + // Then the failure goes through, so a lost receipt is still sent back to scan + expect(onFailure).toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it('still reports an unreadable receipt from the participants step', async () => { + // Given a receipt that is missing with no swap and no backup + mockExisting.clear(); + + // When the participants step checks it can be read + const onSuccess = jest.fn(); + const onFailure = jest.fn(); + await navigateToStartStepIfScanFileCannotBeRead(RECEIPT, RECEIPT_URI, onSuccess, CONST.IOU.REQUEST_TYPE.MANUAL, CONST.IOU.TYPE.SUBMIT, '1', '2', 'image/jpeg', onFailure); + + // Then the failure goes through once, after the recheck finds nothing + expect(onFailure).toHaveBeenCalledTimes(1); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it('rejects a directory at the receipt path even after the recheck', async () => { + // Given a directory where the receipt file should be, which exists but is not a file + mockExisting.clear(); + mockDirectories.add(RECEIPT_PATH); + + // When the confirmation page validates the receipt + const onSuccess = jest.fn(); + const onFailure = jest.fn(); + await validateReceiptFile(RECEIPT, RECEIPT_URI, 'image/jpeg', onSuccess, onFailure); + + // Then the recheck finding something at the path does not make it a valid receipt + expect(onSuccess).not.toHaveBeenCalled(); + expect(onFailure).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/libs/receiptStorageTest.ts b/tests/unit/libs/receiptStorageTest.ts index 17bd5b431300..9da2fa8fed71 100644 --- a/tests/unit/libs/receiptStorageTest.ts +++ b/tests/unit/libs/receiptStorageTest.ts @@ -1,17 +1,32 @@ +import fileURIToPath from '@libs/fileURIToPath'; import type ReceiptStorageType from '@libs/ReceiptStorage/types'; const mockExists = jest.fn, [string]>(); const mockMv = jest.fn, [string, string]>(); const mockMkdir = jest.fn, [string]>(); +const mockUnlink = jest.fn, [string]>(); +const mockReadDir = jest.fn>, [string]>(); +const mockWriteFile = jest.fn, [string, string, string]>(); jest.mock('react-native-fs', () => ({ exists: (path: string) => mockExists(path), moveFile: (from: string, to: string) => mockMv(from, to), mkdir: (path: string) => mockMkdir(path), + unlink: (path: string) => mockUnlink(path), + readDir: (path: string) => mockReadDir(path), + writeFile: (path: string, contents: string, encoding: string) => mockWriteFile(path, contents, encoding), })); jest.mock('@libs/NumberUtils', () => ({rand64: () => '1234'})); +const mockCheckFileExists = jest.fn, [string | undefined]>(); + +jest.mock('@libs/fileDownload/checkFileExists', () => ({ + __esModule: true, + default: (path: string | undefined) => mockCheckFileExists(path), + checkFileExistsWithReason: (path: string | undefined) => mockCheckFileExists(path).then((exists) => ({exists})), +})); + const FOLDER = '/var/mobile/Containers/Data/Application/AAAA-1111/Documents/Receipts-Upload'; jest.mock('@libs/getReceiptsUploadFolderPath', () => ({ __esModule: true, @@ -20,6 +35,11 @@ jest.mock('@libs/getReceiptsUploadFolderPath', () => ({ // Import the native implementation by path. Jest resolves the bare specifier to the web implementation. const {default: ReceiptStorage}: {default: ReceiptStorageType} = jest.requireActual('@libs/ReceiptStorage/index.native.ts'); +const { + start: startUpgrade, + finish: finishUpgrade, + isClaimedForRead, +}: {start: (name: string) => void; finish: (name: string) => void; isClaimedForRead: (name: string) => boolean} = jest.requireActual('@libs/ReceiptStorage/receiptUpgrades.ts'); const {default: WebReceiptStorage}: {default: ReceiptStorageType} = jest.requireActual('@libs/ReceiptStorage/index.ts'); describe('ReceiptStorage', () => { @@ -28,6 +48,10 @@ describe('ReceiptStorage', () => { mockExists.mockResolvedValue(true); mockMv.mockResolvedValue(undefined); mockMkdir.mockResolvedValue(undefined); + mockUnlink.mockResolvedValue(undefined); + mockCheckFileExists.mockResolvedValue(true); + mockReadDir.mockResolvedValue([]); + mockWriteFile.mockResolvedValue(undefined); }); describe('adopt', () => { @@ -74,6 +98,814 @@ describe('ReceiptStorage', () => { }); }); + describe('overwrite', () => { + const RECEIPT = 'CAM-1.jpg'; + const STILL = '/var/mobile/tmp/still.jpg'; + + // Only the receipt is on disk, not the staged or backup paths a previous swap would have used. + const onlyTheReceiptExists = (path: string) => Promise.resolve(path === `${FOLDER}/${RECEIPT}`); + + it('swaps the bytes and hands back the same durable name, so every consumer of the receipt follows along', async () => { + mockExists.mockImplementation(onlyTheReceiptExists); + + const name = await ReceiptStorage.overwrite(RECEIPT, `file://${STILL}`); + + expect(name).toBe(RECEIPT); + expect(mockMv.mock.calls).toEqual([ + [STILL, `${FOLDER}/${RECEIPT}.receipt-swap-staged`], + [`${FOLDER}/${RECEIPT}`, `${FOLDER}/${RECEIPT}.receipt-swap-backup`], + [`${FOLDER}/${RECEIPT}.receipt-swap-staged`, `${FOLDER}/${RECEIPT}`], + ]); + }); + + it('drops the file it moved aside once the swap went through, so a capture leaves one receipt behind', async () => { + mockExists.mockImplementation((path: string) => Promise.resolve(path === `${FOLDER}/${RECEIPT}` || path === `${FOLDER}/${RECEIPT}.receipt-swap-backup`)); + + await ReceiptStorage.overwrite(RECEIPT, STILL); + + expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.receipt-swap-backup`); + }); + + it('puts the original receipt back when the swap fails, rather than leaving the receipt missing', async () => { + const existing = new Set([`${FOLDER}/${RECEIPT}`]); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + mockMv.mockImplementation((from: string, to: string) => { + if (from === `${FOLDER}/${RECEIPT}.receipt-swap-staged` && to === `${FOLDER}/${RECEIPT}`) { + return Promise.reject(new Error('no space left on device')); + } + existing.delete(from); + existing.add(to); + return Promise.resolve(); + }); + + await expect(ReceiptStorage.overwrite(RECEIPT, STILL)).rejects.toThrow('no space left on device'); + + expect(mockMv).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.receipt-swap-backup`, `${FOLDER}/${RECEIPT}`); + expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.receipt-swap-staged`); + }); + + it('reports where the receipt was left when the swap fails and the restore fails too', async () => { + const existing = new Set([`${FOLDER}/${RECEIPT}`]); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + mockMv.mockImplementation((from: string, to: string) => { + if (to === `${FOLDER}/${RECEIPT}`) { + return Promise.reject(new Error('no space left on device')); + } + existing.delete(from); + existing.add(to); + return Promise.resolve(); + }); + + await expect(ReceiptStorage.overwrite(RECEIPT, STILL)).rejects.toThrow(`it is left at ${FOLDER}/${RECEIPT}.receipt-swap-backup`); + + // Freeing the staged copy gives a full disk room for the restore, so it happens even when the + // restore then fails. + expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.receipt-swap-staged`); + }); + + it('still reports success when only the housekeeping delete fails, because the receipt is already swapped', async () => { + mockExists.mockImplementation(onlyTheReceiptExists); + mockUnlink.mockRejectedValue(new Error('permission denied')); + + await expect(ReceiptStorage.overwrite(RECEIPT, STILL)).resolves.toBe(RECEIPT); + }); + + it('restores a receipt stranded under the backup name by a swap the app died in the middle of', async () => { + const existing = new Set([`${FOLDER}/${RECEIPT}.receipt-swap-backup`]); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + mockMv.mockImplementation((from: string, to: string) => { + existing.delete(from); + existing.add(to); + return Promise.resolve(); + }); + + await expect(ReceiptStorage.overwrite(RECEIPT, STILL)).resolves.toBe(RECEIPT); + + // The stranded copy is the only one left, so it goes back before any cleanup can delete it. + expect(mockMv).toHaveBeenNthCalledWith(1, `${FOLDER}/${RECEIPT}.receipt-swap-backup`, `${FOLDER}/${RECEIPT}`); + expect(mockMv.mock.invocationCallOrder.at(0) ?? 0).toBeLessThan(mockUnlink.mock.invocationCallOrder.at(0) ?? Number.MAX_SAFE_INTEGER); + }); + + it('rejects without touching anything when the receipt is not in durable storage', async () => { + mockExists.mockResolvedValue(false); + + await expect(ReceiptStorage.overwrite(RECEIPT, STILL)).rejects.toThrow('not in durable storage'); + expect(mockMv).not.toHaveBeenCalled(); + }); + + it('backs out at the last moment when an upload claimed the receipt while the swap was getting ready', async () => { + const existing = new Set([`${FOLDER}/${RECEIPT}`, STILL]); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + + // False when `overwrite` is called, true by the time the renames are about to start: the checks + // in between all read the filesystem, so this is the window the in-swap guard covers. + let hasReaderGivenUp = false; + mockMv.mockImplementation((from: string, to: string) => { + existing.delete(from); + existing.add(to); + if (from === STILL) { + hasReaderGivenUp = true; + } + return Promise.resolve(); + }); + mockUnlink.mockImplementation((path: string) => { + existing.delete(path); + return Promise.resolve(); + }); + + await expect(ReceiptStorage.overwrite(RECEIPT, STILL, () => hasReaderGivenUp)).rejects.toThrow('an upload claimed the receipt'); + + // Staging only put a file beside the receipt, so backing out means dropping that copy and + // leaving the receipt's own name untouched. + expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.receipt-swap-staged`); + expect(mockMv).not.toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}`, `${FOLDER}/${RECEIPT}.receipt-swap-backup`); + // The receipt itself is exactly where it was. + expect(existing.has(`${FOLDER}/${RECEIPT}`)).toBe(true); + }); + + it('swaps as normal when no upload has claimed the receipt', async () => { + mockExists.mockImplementation(onlyTheReceiptExists); + + await expect(ReceiptStorage.overwrite(RECEIPT, STILL, () => false)).resolves.toBe(RECEIPT); + expect(mockMv).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.receipt-swap-staged`, `${FOLDER}/${RECEIPT}`); + }); + + it('refuses a second swap over a receipt already being swapped, rather than clearing its staged copy', async () => { + const existing = new Set([`${FOLDER}/${RECEIPT}`]); + let finishFirstSwap: () => void = () => {}; + const firstSwapStarted = new Promise((resolve) => { + mockMv.mockImplementation((from: string, to: string) => { + existing.delete(from); + existing.add(to); + if (to === `${FOLDER}/${RECEIPT}.receipt-swap-staged`) { + resolve(); + return new Promise((settle) => { + finishFirstSwap = settle; + }); + } + return Promise.resolve(); + }); + }); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + + const first = ReceiptStorage.overwrite(RECEIPT, STILL); + await firstSwapStarted; + + await expect(ReceiptStorage.overwrite(RECEIPT, '/var/mobile/tmp/other.jpg')).rejects.toThrow('a swap is already running'); + + finishFirstSwap(); + await expect(first).resolves.toBe(RECEIPT); + }); + + it('registers the swap before it reads the filesystem, so a reader cannot slip in front of it', async () => { + const existing = new Set([`${FOLDER}/${RECEIPT}`]); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + mockMv.mockImplementation((from: string, to: string) => { + existing.delete(from); + existing.add(to); + return Promise.resolve(); + }); + + // No awaits yet: the checks inside `overwrite` all read the filesystem, and a reader arriving + // during them would otherwise see nothing in flight and read the file about to be renamed. + const swap = ReceiptStorage.overwrite(RECEIPT, STILL); + await expect(ReceiptStorage.overwrite(RECEIPT, STILL)).rejects.toThrow('a swap is already running'); + + await swap; + }); + }); + + describe('sweeping leftovers from an interrupted swap', () => { + const RECEIPT = 'CAM-1.jpg'; + const PENDING_SWAPS = `${FOLDER}/.pending-receipt-swaps`; + + /** The sweep runs once per launch, so a test that wants to watch it needs a module that has not swept yet. */ + function loadFreshStorage(): ReceiptStorageType { + jest.resetModules(); + const {default: storage}: {default: ReceiptStorageType} = jest.requireActual('@libs/ReceiptStorage/index.native.ts'); + return storage; + } + + async function runStartupSweep(storage: ReceiptStorageType) { + const originalRequestIdleCallback = global.requestIdleCallback; + global.requestIdleCallback = ((callback: IdleRequestCallback) => { + callback({didTimeout: false, timeRemaining: () => 50}); + return 0; + }) as typeof requestIdleCallback; + try { + await storage.sweepLeftovers(); + } finally { + global.requestIdleCallback = originalRequestIdleCallback; + } + } + + function setUpFakeDisk(paths: string[]): Set { + const existing = new Set(paths); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + mockMv.mockImplementation((from: string, to: string) => { + existing.delete(from); + existing.add(to); + return Promise.resolve(); + }); + mockUnlink.mockImplementation((path: string) => { + existing.delete(path); + return Promise.resolve(); + }); + mockWriteFile.mockImplementation((path: string) => { + existing.add(path); + return Promise.resolve(); + }); + return existing; + } + + it('never waits on the swap that asked for it, since `overwrite` registers itself before awaiting the sweep', async () => { + // Given a receipt a swap the app died in left only under its backup name + const storage = loadFreshStorage(); + setUpFakeDisk([FOLDER, PENDING_SWAPS, `${PENDING_SWAPS}/${RECEIPT}`, `${FOLDER}/${RECEIPT}.receipt-swap-backup`]); + mockReadDir.mockResolvedValue([{name: RECEIPT}]); + + // When a new swap over that same receipt is the one that starts the sweep + const swap = storage.overwrite(RECEIPT, '/var/mobile/tmp/still.jpg'); + let stall: ReturnType | undefined; + const stalled = new Promise((resolve) => { + stall = setTimeout(() => resolve('deadlocked'), 1000); + }); + + // Then it settles: if the sweep ever waited for a running swap, it would wait on the caller + // waiting on it and neither would finish, so this asserts completion, not a result + await expect(Promise.race([swap.then(() => 'settled'), stalled])).resolves.toBe('settled'); + await expect(swap).resolves.toBe(RECEIPT); + expect(mockReadDir).toHaveBeenCalledWith(PENDING_SWAPS); + if (stall) { + clearTimeout(stall); + } + }); + + it('puts back a stranded backup and deletes the copies nothing owns before the swap renames anything', async () => { + // Given two pending swaps: one receipt only under its backup name, one in place with a staged copy beside it + const storage = loadFreshStorage(); + const existing = setUpFakeDisk([ + FOLDER, + PENDING_SWAPS, + `${PENDING_SWAPS}/${RECEIPT}`, + `${PENDING_SWAPS}/other.jpg`, + `${FOLDER}/${RECEIPT}.receipt-swap-backup`, + `${FOLDER}/other.jpg`, + `${FOLDER}/other.jpg.receipt-swap-staged`, + ]); + mockReadDir.mockResolvedValue([{name: RECEIPT}, {name: 'other.jpg'}]); + + // When a swap has to find the stranded receipt, so the sweep must finish before `verify` runs + await expect(storage.overwrite(RECEIPT, '/var/mobile/tmp/still.jpg')).resolves.toBe(RECEIPT); + + // Then the backup went back first, the ownerless staged copy is gone, and both entries are cleared + expect(mockMv).toHaveBeenNthCalledWith(1, `${FOLDER}/${RECEIPT}.receipt-swap-backup`, `${FOLDER}/${RECEIPT}`); + expect(mockUnlink).toHaveBeenCalledWith(`${FOLDER}/other.jpg.receipt-swap-staged`); + expect(mockUnlink).not.toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}`); + expect(existing.has(`${PENDING_SWAPS}/other.jpg`)).toBe(false); + expect(existing.has(`${PENDING_SWAPS}/${RECEIPT}`)).toBe(false); + + mockReadDir.mockClear(); + await storage.overwrite(RECEIPT, '/var/mobile/tmp/another.jpg'); + expect(mockReadDir).not.toHaveBeenCalled(); + }); + + it('keeps the directory listing off the request path, so a read never waits on housekeeping', async () => { + // Given a launch whose first receipt work is an upload reading its file + const storage = loadFreshStorage(); + let hasListed = false; + mockReadDir.mockImplementation(() => { + hasListed = true; + return Promise.resolve([]); + }); + mockCheckFileExists.mockResolvedValue(true); + + // When `locate` runs inside the request the sequential queue is processing + await expect(storage.locate(`file://${FOLDER}/${RECEIPT}`)).resolves.toBe(`file://${FOLDER}/${RECEIPT}`); + + // Then it read straight through, but still started the sweep so a launch that only reads gets tidied + expect(mockMv).not.toHaveBeenCalled(); + expect(hasListed).toBe(true); + }); + + it('skips the sweep when no swap has ever been pending', async () => { + // Given a device that has never upgraded a receipt, so the pending swaps folder was never created + const storage = loadFreshStorage(); + mockExists.mockResolvedValue(false); + + // When the startup sweep runs + await runStartupSweep(storage); + + // Then it lists nothing, since `readDir` on a missing folder rejects and logs on every launch + expect(mockReadDir).not.toHaveBeenCalled(); + }); + + it('never lists the receipts folder itself, since that costs a stat per receipt ever taken', async () => { + // Given a device with pending swaps from earlier launches + const storage = loadFreshStorage(); + + // When the startup sweep runs + await runStartupSweep(storage); + + // Then only the pending swaps folder is listed, because on Android listing the receipts folder blocks every native call for seconds + expect(mockReadDir).toHaveBeenCalledWith(PENDING_SWAPS); + expect(mockReadDir).not.toHaveBeenCalledWith(FOLDER); + }); + + it('keeps a stranded backup and its entry when putting it back fails, since it is the only copy of that receipt', async () => { + // Given a receipt that exists only under its backup name, and a rename that fails + const storage = loadFreshStorage(); + const backupPath = `${FOLDER}/${RECEIPT}.receipt-swap-backup`; + const existing = setUpFakeDisk([FOLDER, PENDING_SWAPS, `${PENDING_SWAPS}/${RECEIPT}`, backupPath]); + mockReadDir.mockResolvedValue([{name: RECEIPT}]); + mockMv.mockRejectedValue(new Error('rename failed')); + + // When the startup sweep runs + await runStartupSweep(storage); + + // Then it tried the restore but left the backup and the entry for the next launch to retry + expect(mockMv).toHaveBeenCalledWith(backupPath, `${FOLDER}/${RECEIPT}`); + expect(existing.has(backupPath)).toBe(true); + expect(existing.has(`${PENDING_SWAPS}/${RECEIPT}`)).toBe(true); + }); + + it('keeps the entry when a leftover copy cannot be deleted, so a later sweep can still find it', async () => { + // Given a receipt in place with its old backup still beside it, and a delete that fails + const storage = loadFreshStorage(); + const backupPath = `${FOLDER}/${RECEIPT}.receipt-swap-backup`; + const existing = setUpFakeDisk([FOLDER, PENDING_SWAPS, `${PENDING_SWAPS}/${RECEIPT}`, `${FOLDER}/${RECEIPT}`, backupPath]); + mockReadDir.mockResolvedValue([{name: RECEIPT}]); + mockUnlink.mockImplementation((path: string) => { + if (path === backupPath) { + return Promise.reject(new Error('permission denied')); + } + existing.delete(path); + return Promise.resolve(); + }); + + // When the startup sweep runs + await runStartupSweep(storage); + + // Then the backup is still there and so is the entry that points at it + expect(existing.has(backupPath)).toBe(true); + expect(existing.has(`${PENDING_SWAPS}/${RECEIPT}`)).toBe(true); + }); + + it('keeps the entry when a finished swap could not delete the copy it moved aside', async () => { + // Given a receipt in durable storage, and a delete of the backup that fails + const storage = loadFreshStorage(); + const backupPath = `${FOLDER}/${RECEIPT}.receipt-swap-backup`; + const existing = setUpFakeDisk([FOLDER, `${FOLDER}/${RECEIPT}`]); + mockUnlink.mockImplementation((path: string) => { + if (path === backupPath) { + return Promise.reject(new Error('permission denied')); + } + existing.delete(path); + return Promise.resolve(); + }); + + // When the swap runs, which still succeeds because the receipt is already swapped + await expect(storage.overwrite(RECEIPT, '/var/mobile/tmp/still.jpg')).resolves.toBe(RECEIPT); + + // Then the entry stays, so the next launch's sweep deletes the backup instead of it leaking forever + expect(existing.has(backupPath)).toBe(true); + expect(existing.has(`${PENDING_SWAPS}/${RECEIPT}`)).toBe(true); + }); + + it('drops the entry of a receipt that is gone along with its backup, so later launches stop checking it', async () => { + // Given an entry whose receipt and backup were both deleted outside the app + const storage = loadFreshStorage(); + const existing = setUpFakeDisk([FOLDER, PENDING_SWAPS, `${PENDING_SWAPS}/${RECEIPT}`]); + mockReadDir.mockResolvedValue([{name: RECEIPT}]); + + // When the startup sweep runs + await runStartupSweep(storage); + + // Then there is nothing left to put back, so the entry is removed + expect(mockMv).not.toHaveBeenCalled(); + expect(existing.has(`${PENDING_SWAPS}/${RECEIPT}`)).toBe(false); + }); + + it('restores a stranded receipt during the deferred startup sweep, without needing an upload', async () => { + // Given the app died between the two renames of a swap + const storage = loadFreshStorage(); + const existing = setUpFakeDisk([FOLDER, PENDING_SWAPS, `${PENDING_SWAPS}/${RECEIPT}`, `${FOLDER}/${RECEIPT}.receipt-swap-backup`, `${FOLDER}/${RECEIPT}.receipt-swap-staged`]); + mockReadDir.mockResolvedValue([{name: RECEIPT}]); + + // When the startup sweep runs + await runStartupSweep(storage); + + // Then the receipt is back under its own name, and nothing of the swap is left + expect(mockMv).toHaveBeenCalledWith(`${FOLDER}/${RECEIPT}.receipt-swap-backup`, `${FOLDER}/${RECEIPT}`); + expect(existing.has(`${FOLDER}/${RECEIPT}`)).toBe(true); + expect(existing.has(`${FOLDER}/${RECEIPT}.receipt-swap-backup`)).toBe(false); + expect(existing.has(`${FOLDER}/${RECEIPT}.receipt-swap-staged`)).toBe(false); + expect(existing.has(`${PENDING_SWAPS}/${RECEIPT}`)).toBe(false); + }); + + it('marks a swap pending before moving any file and clears the entry once the receipt is in place', async () => { + // Given a receipt in durable storage + const storage = loadFreshStorage(); + const existing = setUpFakeDisk([FOLDER, `${FOLDER}/${RECEIPT}`]); + + // When it is swapped for the full-resolution still + await expect(storage.overwrite(RECEIPT, '/var/mobile/tmp/still.jpg')).resolves.toBe(RECEIPT); + + // Then the entry was written before the first move, so dying mid-swap always leaves one, and is gone after + expect(mockWriteFile).toHaveBeenCalledWith(`${PENDING_SWAPS}/${RECEIPT}`, '', 'utf8'); + expect(mockWriteFile.mock.invocationCallOrder.at(0) ?? Number.MAX_SAFE_INTEGER).toBeLessThan(mockMv.mock.invocationCallOrder.at(0) ?? 0); + expect(existing.has(`${PENDING_SWAPS}/${RECEIPT}`)).toBe(false); + }); + + it('keeps the entry when a failed swap could not put the receipt back', async () => { + // Given a swap whose second rename fails and whose restore fails too + const storage = loadFreshStorage(); + const existing = setUpFakeDisk([FOLDER, `${FOLDER}/${RECEIPT}`]); + mockMv.mockImplementation((from: string, to: string) => { + if (to === `${FOLDER}/${RECEIPT}`) { + return Promise.reject(new Error('no space left on device')); + } + existing.delete(from); + existing.add(to); + return Promise.resolve(); + }); + + // When the swap runs + await expect(storage.overwrite(RECEIPT, '/var/mobile/tmp/still.jpg')).rejects.toThrow('it is left at'); + + // Then the entry stays, so the next launch's sweep finds the receipt under its backup name + expect(existing.has(`${PENDING_SWAPS}/${RECEIPT}`)).toBe(true); + }); + }); + + describe('locate', () => { + const RECEIPT_URI = `file://${FOLDER}/CAM-1.jpg`; + const RECEIPT_PATH = `${FOLDER}/CAM-1.jpg`; + + /** Drains the microtask queue, so an assertion sees where a promise chain actually stopped. */ + const flushPendingWork = () => + new Promise((resolve) => { + setImmediate(resolve); + }); + + it('hands back a receipt that is where it should be', async () => { + await expect(ReceiptStorage.locate(RECEIPT_URI)).resolves.toBe(RECEIPT_URI); + expect(mockMv).not.toHaveBeenCalled(); + }); + + it('puts back a receipt stranded under the backup name by an interrupted swap', async () => { + mockCheckFileExists.mockResolvedValue(false); + mockExists.mockImplementation((path: string) => Promise.resolve(path === `${RECEIPT_PATH}.receipt-swap-backup`)); + + await expect(ReceiptStorage.locate(RECEIPT_URI)).resolves.toBe(RECEIPT_URI); + expect(mockMv).toHaveBeenCalledWith(`${RECEIPT_PATH}.receipt-swap-backup`, RECEIPT_PATH); + }); + + it('finds a receipt the startup sweep put back between its two checks, instead of sending the upload without it', async () => { + // Given a receipt missing under its own name, whose backup the sweep moves back while the read is checking + mockCheckFileExists.mockResolvedValue(false); + let targetChecks = 0; + mockExists.mockImplementation((path: string) => { + if (path === RECEIPT_PATH) { + targetChecks += 1; + return Promise.resolve(targetChecks > 1); + } + return Promise.resolve(false); + }); + + // When the upload locates the receipt + const located = await ReceiptStorage.locate(RECEIPT_URI); + + // Then it sees the receipt the sweep restored rather than reporting it missing + expect(located).toBe(RECEIPT_URI); + }); + + it('reads straight through while a swap is still staging, since that half can be called off', async () => { + const existing = new Set([RECEIPT_PATH]); + let finishStaging: () => void = () => {}; + const stagingStarted = new Promise((resolve) => { + mockMv.mockImplementation((from: string, to: string) => { + if (to === `${RECEIPT_PATH}.receipt-swap-staged`) { + resolve(); + return new Promise((settle) => { + finishStaging = settle; + }); + } + existing.delete(from); + existing.add(to); + return Promise.resolve(); + }); + }); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + mockCheckFileExists.mockImplementation((path?: string) => Promise.resolve(existing.has(fileURIToPath(path ?? '')))); + + // The upgrade has to be registered for a claim to land on it, as it is in the real flow. + startUpgrade('CAM-1.jpg'); + const swap = ReceiptStorage.overwrite('CAM-1.jpg', '/var/mobile/tmp/still.jpg', () => isClaimedForRead('CAM-1.jpg')); + await stagingStarted; + + // Staging is a copy into the receipts folder, which on a cross-filesystem move is the whole + // image. Waiting it out would put that copy in front of every other queued request. + await expect(ReceiptStorage.locate(RECEIPT_URI)).resolves.toBe(RECEIPT_URI); + expect(isClaimedForRead('CAM-1.jpg')).toBe(true); + + finishStaging(); + // The claim reaches the guard below the staging move, so the swap leaves the receipt alone. + await expect(swap).rejects.toThrow('an upload claimed the receipt'); + + finishUpgrade('CAM-1.jpg'); + }); + + it('waits for a swap that has started renaming, which is the one gap a claim cannot call off', async () => { + const existing = new Set([RECEIPT_PATH]); + let finishRename: () => void = () => {}; + const renameStarted = new Promise((resolve) => { + mockMv.mockImplementation((from: string, to: string) => { + if (to === `${RECEIPT_PATH}.receipt-swap-backup`) { + resolve(); + return new Promise((settle) => { + finishRename = settle; + }); + } + existing.delete(from); + existing.add(to); + return Promise.resolve(); + }); + }); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + mockCheckFileExists.mockImplementation((path?: string) => Promise.resolve(existing.has(fileURIToPath(path ?? '')))); + + const swap = ReceiptStorage.overwrite('CAM-1.jpg', '/var/mobile/tmp/still.jpg'); + await renameStarted; + + let hasLocated = false; + const located = ReceiptStorage.locate(RECEIPT_URI).then((uri) => { + hasLocated = true; + return uri; + }); + await flushPendingWork(); + + // The receipt's own name is moving, so reading it now returns the old bytes, the new ones, or + // nothing at all. This is the only part of a swap a reader ever sits through. + expect(hasLocated).toBe(false); + + finishRename(); + await swap; + await expect(located).resolves.toBe(RECEIPT_URI); + }); + + it('does not wait on an upgrade that has no swap running, so a submit never queues behind a capture', async () => { + const existing = new Set([RECEIPT_PATH]); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + mockCheckFileExists.mockImplementation((path?: string) => Promise.resolve(existing.has(fileURIToPath(path ?? '')))); + + // The upgrade is registered but still capturing or rotating: nothing has reached the renames. + startUpgrade('CAM-1.jpg'); + + const located = await ReceiptStorage.locate(RECEIPT_URI); + + // Read straight through, and the upgrade is told to leave the file alone rather than waited on. + expect(located).toBe(RECEIPT_URI); + expect(isClaimedForRead('CAM-1.jpg')).toBe(true); + + finishUpgrade('CAM-1.jpg'); + }); + + it('waits for a swap that is running rather than putting the backup back over it', async () => { + const existing = new Set([RECEIPT_PATH]); + let finishSwap: () => void = () => {}; + const swapReachedItsGap = new Promise((resolve) => { + mockMv.mockImplementation((from: string, to: string) => { + existing.delete(from); + existing.add(to); + if (to === `${RECEIPT_PATH}.receipt-swap-backup`) { + // The receipt is now missing from its own path, which is the gap a reader can land in. + resolve(); + return new Promise((settle) => { + finishSwap = () => { + existing.delete(`${RECEIPT_PATH}.receipt-swap-staged`); + existing.add(RECEIPT_PATH); + settle(); + }; + }); + } + return Promise.resolve(); + }); + }); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + mockCheckFileExists.mockImplementation((path?: string) => Promise.resolve(existing.has(fileURIToPath(path ?? '')))); + + const swap = ReceiptStorage.overwrite('CAM-1.jpg', '/var/mobile/tmp/still.jpg'); + await swapReachedItsGap; + + const located = ReceiptStorage.locate(RECEIPT_URI); + finishSwap(); + await swap; + + // The swapped-in file is what the reader gets, and the backup was never moved back over it. + await expect(located).resolves.toBe(RECEIPT_URI); + expect(mockMv).not.toHaveBeenCalledWith(`${RECEIPT_PATH}.receipt-swap-backup`, RECEIPT_PATH); + }); + + it('treats a receipt that reappeared while the restore was failing as readable', async () => { + mockCheckFileExists.mockResolvedValue(false); + let hasMoved = false; + mockExists.mockImplementation((path: string) => Promise.resolve(path === `${RECEIPT_PATH}.receipt-swap-backup` || (path === RECEIPT_PATH && hasMoved))); + mockMv.mockImplementation(() => { + hasMoved = true; + return Promise.reject(new Error('file exists')); + }); + + await expect(ReceiptStorage.locate(RECEIPT_URI)).resolves.toBe(RECEIPT_URI); + }); + + it('reports a receipt that is gone and has no backup, so the caller can log it as dropped', async () => { + mockCheckFileExists.mockResolvedValue(false); + mockExists.mockResolvedValue(false); + + await expect(ReceiptStorage.locate(RECEIPT_URI)).resolves.toBeUndefined(); + }); + + it('reports a remote source that could not be read, rather than handing back a URL to upload', async () => { + mockCheckFileExists.mockResolvedValue(false); + + await expect(ReceiptStorage.locate('https://www.expensify.com/receipts/w_9.jpg')).resolves.toBeUndefined(); + expect(mockMv).not.toHaveBeenCalled(); + }); + }); + + describe('recheckAfterSwap', () => { + const RECEIPT_URI = `file://${FOLDER}/CAM-1.jpg`; + const RECEIPT_PATH = `${FOLDER}/CAM-1.jpg`; + + const flushPendingWork = () => + new Promise((resolve) => { + setImmediate(resolve); + }); + + function holdSwapWithReceiptMissing() { + const existing = new Set([RECEIPT_PATH]); + let release: () => void = () => {}; + const receiptMissing = new Promise((resolve) => { + mockMv.mockImplementation((from: string, to: string) => { + existing.delete(from); + if (from === `${RECEIPT_PATH}.receipt-swap-staged` && to === RECEIPT_PATH) { + resolve(); + return new Promise((settle) => { + release = () => { + existing.add(to); + settle(); + }; + }); + } + existing.add(to); + return Promise.resolve(); + }); + }); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + return {existing, receiptMissing, release: () => release()}; + } + + it('confirms a receipt that is where it should be', async () => { + // Given a receipt on disk with nothing swapping it + mockExists.mockResolvedValue(true); + + // When a reader that failed rechecks it + const isPresent = await ReceiptStorage.recheckAfterSwap(RECEIPT_URI); + + // Then the receipt counts as present and nothing is moved + expect(isPresent).toBe(true); + expect(mockMv).not.toHaveBeenCalled(); + }); + + it('waits for a swap caught between its two renames, then confirms the receipt', async () => { + // Given a swap that has moved the receipt aside and not yet moved the upgraded file in + const {existing, receiptMissing, release} = holdSwapWithReceiptMissing(); + const swap = ReceiptStorage.overwrite('CAM-1.jpg', '/var/mobile/tmp/still.jpg'); + await receiptMissing; + + // When a reader that failed in that gap rechecks the receipt + let hasAnswered = false; + const recheck = ReceiptStorage.recheckAfterSwap(RECEIPT_URI).then((isPresent) => { + hasAnswered = true; + return isPresent; + }); + await flushPendingWork(); + + // Then it answers only once the swap is done, and the receipt is there + expect(hasAnswered).toBe(false); + release(); + await swap; + await expect(recheck).resolves.toBe(true); + expect(existing.has(RECEIPT_PATH)).toBe(true); + }); + + it('waits for a swap that is still staging, so the retried read cannot land in its gap', async () => { + // Given a swap still staging its new file, with the receipt still at its own path + const existing = new Set([RECEIPT_PATH]); + let finishStaging: () => void = () => {}; + const stagingStarted = new Promise((resolve) => { + mockMv.mockImplementation((from: string, to: string) => { + const move = () => { + existing.delete(from); + existing.add(to); + }; + if (to === `${RECEIPT_PATH}.receipt-swap-staged`) { + resolve(); + return new Promise((settle) => { + finishStaging = () => { + move(); + settle(); + }; + }); + } + move(); + return Promise.resolve(); + }); + }); + mockExists.mockImplementation((path: string) => Promise.resolve(existing.has(path))); + const swap = ReceiptStorage.overwrite('CAM-1.jpg', '/var/mobile/tmp/still.jpg'); + await stagingStarted; + + // When a reader whose read failed for another reason rechecks the receipt + let hasAnswered = false; + const recheck = ReceiptStorage.recheckAfterSwap(RECEIPT_URI).then((isPresent) => { + hasAnswered = true; + return isPresent; + }); + await flushPendingWork(); + + // Then it does not answer while the swap can still rename the receipt, and answers once the swap is done + expect(hasAnswered).toBe(false); + finishStaging(); + await swap; + await expect(recheck).resolves.toBe(true); + }); + + it('leaves a registered upgrade alone, since a reader rechecking the receipt is not an upload claiming it', async () => { + // Given an upgrade that is still capturing + startUpgrade('CAM-1.jpg'); + + // When a reader rechecks the receipt + await ReceiptStorage.recheckAfterSwap(RECEIPT_URI); + + // Then the upgrade is not claimed, so it can still swap the better photo in. `locate` would call it off. + expect(isClaimedForRead('CAM-1.jpg')).toBe(false); + + finishUpgrade('CAM-1.jpg'); + }); + + it('puts back a receipt stranded under the backup name by an interrupted swap', async () => { + // Given a swap the app died in, with the receipt left under its backup name + mockExists.mockImplementation((path: string) => Promise.resolve(path === `${RECEIPT_PATH}.receipt-swap-backup`)); + + // When a reader rechecks the receipt + await expect(ReceiptStorage.recheckAfterSwap(RECEIPT_URI)).resolves.toBe(true); + + // Then the backup is moved back, so the retried read finds the receipt + expect(mockMv).toHaveBeenCalledWith(`${RECEIPT_PATH}.receipt-swap-backup`, RECEIPT_PATH); + }); + + it('reports a receipt that is gone and has no backup, so the reader can fail for real', async () => { + // Given a receipt that is missing with no swap and no backup + mockExists.mockResolvedValue(false); + + // When a reader rechecks it + const isPresent = await ReceiptStorage.recheckAfterSwap(RECEIPT_URI); + + // Then it is reported gone + expect(isPresent).toBe(false); + }); + + it('reports a remote source as gone without touching the filesystem', async () => { + // Given a remote receipt whose read failed + const remoteSource = 'https://www.expensify.com/receipts/w_9.jpg'; + + // When the reader rechecks it + const isPresent = await ReceiptStorage.recheckAfterSwap(remoteSource); + + // Then there is no local swap to wait for, so the failure stands + expect(isPresent).toBe(false); + expect(mockExists).not.toHaveBeenCalled(); + }); + }); + + describe('discard', () => { + it('deletes a temporary file', async () => { + await ReceiptStorage.discard('file:///var/mobile/tmp/still.jpg'); + + expect(mockUnlink).toHaveBeenCalledWith('/var/mobile/tmp/still.jpg'); + }); + + it('resolves without deleting when the file is already gone', async () => { + mockExists.mockResolvedValue(false); + + await ReceiptStorage.discard('/var/mobile/tmp/still.jpg'); + + expect(mockUnlink).not.toHaveBeenCalled(); + }); + }); + describe('toLocalUri', () => { it('resolves against the folder as it is right now', () => { expect(ReceiptStorage.toLocalUri('receipt_1234.jpg')).toBe(`file://${FOLDER}/receipt_1234.jpg`); diff --git a/tests/unit/libs/receiptUpgradesTest.ts b/tests/unit/libs/receiptUpgradesTest.ts new file mode 100644 index 000000000000..2321d2585c06 --- /dev/null +++ b/tests/unit/libs/receiptUpgradesTest.ts @@ -0,0 +1,110 @@ +import {claimForRead, finish, getUpgradeCount, isClaimedForRead, recordUpgrade, start, subscribe} from '@libs/ReceiptStorage/receiptUpgrades'; + +const RECEIPT = 'receipt_1234.jpg'; + +describe('receiptUpgrades', () => { + afterEach(() => { + finish(RECEIPT); + jest.useRealTimers(); + }); + + it('reports nothing in flight for a receipt that is not being upgraded', () => { + expect(isClaimedForRead(RECEIPT)).toBe(false); + }); + + it('claims a receipt without waiting, so an upload never queues behind a capture', () => { + start(RECEIPT); + expect(isClaimedForRead(RECEIPT)).toBe(false); + + // Nothing is awaited here on purpose: this is the whole of what the upload path does, and it runs + // inside the request the sequential queue is processing. + claimForRead(RECEIPT); + + expect(isClaimedForRead(RECEIPT)).toBe(true); + }); + + it('ignores a claim on a receipt nothing is upgrading, so an ordinary upload is unaffected', () => { + claimForRead(RECEIPT); + expect(isClaimedForRead(RECEIPT)).toBe(false); + }); + + it('keeps a claim for the rest of the upgrade, so a second reader cannot hand the file back', () => { + start(RECEIPT); + claimForRead(RECEIPT); + claimForRead(RECEIPT); + + expect(isClaimedForRead(RECEIPT)).toBe(true); + }); + + it('forgets the claim once the upgrade is over, so the next one starts clean', () => { + start(RECEIPT); + claimForRead(RECEIPT); + finish(RECEIPT); + + start(RECEIPT); + expect(isClaimedForRead(RECEIPT)).toBe(false); + }); + + it('tells subscribers when a receipt starts and stops changing', () => { + const listener = jest.fn(); + const unsubscribe = subscribe(listener); + + start(RECEIPT); + expect(listener).toHaveBeenCalledTimes(1); + + finish(RECEIPT); + expect(listener).toHaveBeenCalledTimes(2); + + unsubscribe(); + start(RECEIPT); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it('ignores a second start for the same receipt, so one upgrade cannot orphan another', () => { + start(RECEIPT); + claimForRead(RECEIPT); + // A second start must not reset the claim the first upgrade already handed out. + start(RECEIPT); + + expect(isClaimedForRead(RECEIPT)).toBe(true); + }); + + describe('version count', () => { + const KEPT_SNAPSHOT = 'receipt_kept_snapshot.jpg'; + const SWAPPED = 'receipt_swapped.jpg'; + + it('stays put for an upgrade that finished without replacing the file', () => { + start(KEPT_SNAPSHOT); + finish(KEPT_SNAPSHOT); + + // A view keys its image source on this, so bumping it here would re-decode a file that never + // moved — on the deadline-miss path, which is the common one. + expect(getUpgradeCount(KEPT_SNAPSHOT)).toBe(0); + }); + + it('moves only when the bytes behind the receipt actually changed', () => { + const listener = jest.fn(); + const unsubscribe = subscribe(listener); + + start(SWAPPED); + recordUpgrade(SWAPPED); + finish(SWAPPED); + + expect(getUpgradeCount(SWAPPED)).toBe(1); + // start, recordUpgrade and finish each tell the views something changed. + expect(listener).toHaveBeenCalledTimes(3); + unsubscribe(); + }); + + it('forgets the oldest receipts rather than growing for the whole launch', () => { + const first = 'receipt_first_of_many.jpg'; + recordUpgrade(first); + for (let index = 0; index < 60; index++) { + recordUpgrade(`receipt_bulk_${index}.jpg`); + } + + expect(getUpgradeCount(first)).toBe(0); + expect(getUpgradeCount('receipt_bulk_59.jpg')).toBe(1); + }); + }); +}); diff --git a/tests/unit/rotatePhotoToUprightTest.ts b/tests/unit/rotatePhotoToUprightTest.ts new file mode 100644 index 000000000000..6b36f4beadce --- /dev/null +++ b/tests/unit/rotatePhotoToUprightTest.ts @@ -0,0 +1,50 @@ +import {getUprightRotation} from '@pages/iou/request/step/IOURequestStepScan/utils/rotatePhotoToUpright'; + +describe('getUprightRotation', () => { + it('leaves a photo whose metadata already describes it upright as captured, rather than re-encoding it', () => { + expect(getUprightRotation({width: 1920, height: 1440, rotation: 90})).toBeUndefined(); + expect(getUprightRotation({width: 1920, height: 1440, rotation: 270})).toBeUndefined(); + }); + + it('turns a landscape sensor buffer that carries no rotation of its own', () => { + expect(getUprightRotation({width: 1920, height: 1440, rotation: 0})).toBe(90); + expect(getUprightRotation({width: 1920, height: 1440, rotation: 180})).toBe(90); + }); + + it('turns the opposite hold the other way, rather than leaving it 180 out', () => { + // `PhotoFile.orientation` is counter-clockwise, so `landscape-left` (270 CCW) is the 90 clockwise + // the Galaxy S20 FE needs, and `landscape-right` (90 CCW) has to go the other way. + expect(getUprightRotation({width: 1920, height: 1440, rotation: 0}, 'landscape-left')).toBe(90); + expect(getUprightRotation({width: 1920, height: 1440, rotation: 0}, 'landscape-right')).toBe(270); + }); + + it('keeps the verified quarter-turn for a hold that cannot make a landscape frame upright', () => { + // Only a quarter-turn makes a landscape frame portrait, so these fall back rather than turning 0 or + // 180 and leaving the receipt on its side. + expect(getUprightRotation({width: 1920, height: 1440, rotation: 0}, 'portrait')).toBe(90); + expect(getUprightRotation({width: 1920, height: 1440, rotation: 0}, 'portrait-upside-down')).toBe(90); + }); + + it('ignores the hold for a frame that already decodes upright, since only the decoded frame matters', () => { + expect(getUprightRotation({width: 1440, height: 1920, rotation: 0}, 'landscape-right')).toBeUndefined(); + }); + + it('leaves a photo that is already upright with no metadata alone, rather than re-encoding it', () => { + // iOS, where react-native-image-size reports the displayed size and no rotation. + expect(getUprightRotation({width: 2160, height: 2880})).toBeUndefined(); + expect(getUprightRotation({width: 1440, height: 1920, rotation: 0})).toBeUndefined(); + }); + + it('leaves an upright photo as captured even when it still carries metadata', () => { + expect(getUprightRotation({width: 1440, height: 1920, rotation: 180})).toBeUndefined(); + }); + + it('still turns a frame that decodes landscape, whatever its metadata says', () => { + // 1440x1920 asking for 90 decodes as 1920x1440, genuinely on its side. + expect(getUprightRotation({width: 1440, height: 1920, rotation: 90})).toBe(90); + }); + + it('leaves a square photo with no metadata alone', () => { + expect(getUprightRotation({width: 1440, height: 1440, rotation: 0})).toBeUndefined(); + }); +}); diff --git a/tests/unit/selectors/TableColumnWidthsTest.ts b/tests/unit/selectors/TableColumnWidthsTest.ts new file mode 100644 index 000000000000..f8e2dd1ffed5 --- /dev/null +++ b/tests/unit/selectors/TableColumnWidthsTest.ts @@ -0,0 +1,25 @@ +import {tableColumnWidthsSelector} from '@src/selectors/TableColumnWidths'; + +const TAGS_TABLE_ID = 'workspaceTags'; +const TAXES_TABLE_ID = 'workspaceTaxes'; + +describe('tableColumnWidthsSelector', () => { + const storedWidths = { + [TAGS_TABLE_ID]: {name: 240, enabled: 120}, + [TAXES_TABLE_ID]: {name: 300}, + }; + + // Given a table that has opted into resizing + // When its widths are selected out of the record holding every table's + // Then it gets only its own, so the table never lays out from another table's columns + it('picks out the widths stored for the table', () => { + expect(tableColumnWidthsSelector(TAGS_TABLE_ID)(storedWidths)).toEqual({name: 240, enabled: 120}); + }); + + // Given a resizable table the user has never dragged + // When its widths are selected + // Then there are none, so it lays out from its column defaults instead of another table's widths + it('returns nothing for a table with no stored widths', () => { + expect(tableColumnWidthsSelector('workspaceCategories')(storedWidths)).toBeUndefined(); + }); +}); diff --git a/tests/unit/usePolicyIndicatorChecksTest.ts b/tests/unit/usePolicyIndicatorChecksTest.ts index 7f939bd6a6dd..6772c0642d41 100644 --- a/tests/unit/usePolicyIndicatorChecksTest.ts +++ b/tests/unit/usePolicyIndicatorChecksTest.ts @@ -257,6 +257,41 @@ describe('usePolicyIndicatorChecks', () => { expect(result.current.policyErrorStatus).toBe(CONST.INDICATOR_STATUS.HAS_POLICY_ADMIN_CARD_FEED_ERRORS); expect(result.current.indicatorPolicyID).toBe(WORKSPACE.policyID); }); + + it('returns HAS_APPROVAL_WORKFLOW_NON_MEMBER_APPROVER when a member submits to someone no longer on the workspace', async () => { + // Given an advanced-approval workspace the admin can edit, where a member still submits to a removed approver + await act(async () => { + await Onyx.clear(); + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: {email: userID}, + ...createPolicyCollectionDataSet( + createMock({ + id: WORKSPACE.policyID, + name: WORKSPACE.policyName, + type: CONST.POLICY.TYPE.CORPORATE, + owner: userID, + role: 'admin', + approver: userID, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + policyAccountID: WORKSPACE.policyAccountID, + employeeList: { + [userID]: {email: userID, role: CONST.POLICY.ROLE.ADMIN, submitsTo: userID}, + [otherUserID]: {email: otherUserID, role: CONST.POLICY.ROLE.USER, submitsTo: 'removed@example.com'}, + }, + }), + ), + } satisfies OnyxMultiSetInput); + await waitForBatchedUpdatesWithAct(); + }); + + // When the policy indicator checks run + const {result} = renderHook(() => usePolicyIndicatorChecks()); + await waitForBatchedUpdatesWithAct(); + + // Then the Workspaces tab gets the red dot for that workspace, matching the flagged card on its Workflows page + expect(result.current.policyErrorStatus).toBe(CONST.INDICATOR_STATUS.HAS_APPROVAL_WORKFLOW_NON_MEMBER_APPROVER); + expect(result.current.indicatorPolicyID).toBe(WORKSPACE.policyID); + }); }); describe('policy info statuses', () => { diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index c3e78bccf919..77a1fba2b9d0 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -136,6 +136,7 @@ jest.mock('@libs/ReportActionsUtils', () => ({ isTransactionThread: () => mockIsTransactionThread, isSentMoneyReportAction: () => mockIsSentMoneyReportAction, isReportPreviewAction: () => mockIsReportPreviewAction, + getVisibleReportActionErrors: (reportAction: ReportAction | undefined) => reportAction?.errors ?? {}, })); // --- ReportUtils --- From a96fa06ae2bb647ec56f6f354bd0fea26fc9c7a6 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:16:54 +0530 Subject: [PATCH 24/56] Rebuild branch on latest main with approve-only changes --- src/CONST/index.ts | 49 +++++- src/languages/de.ts | 29 ++- src/languages/el.ts | 23 ++- src/languages/en.ts | 21 ++- src/languages/es.ts | 28 ++- src/languages/fr.ts | 29 ++- src/languages/it.ts | 28 ++- src/languages/ja.ts | 27 ++- src/languages/nl.ts | 28 ++- src/languages/pl.ts | 27 ++- src/languages/pt-BR.ts | 28 ++- src/languages/zh-hans.ts | 25 ++- src/libs/API/types.ts | 4 +- src/libs/OptionsListUtils/index.ts | 27 ++- src/libs/PolicyUtils/index.ts | 9 + src/libs/PolicyUtils/permissions.ts | 21 ++- src/libs/ReportActionsUtils.ts | 29 ++- src/libs/ReportUtils.ts | 32 +++- src/libs/actions/Policy/Member.ts | 6 +- src/pages/SetDefaultWorkspacePage.tsx | 9 +- src/pages/workspace/WorkspaceMembersPage.tsx | 16 +- tests/unit/OptionsListUtilsTest.tsx | 176 +++++++++++++++++-- tests/unit/PolicyUtilsTest.ts | 40 +++++ tests/unit/ReportUtilsTest.ts | 161 ++++++++++++++++- 24 files changed, 783 insertions(+), 89 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 6e277c646bd5..73e9fa505fec 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -428,6 +428,9 @@ const CONST = { RECEIPT_CAMERA: { PHOTO_WIDTH: 2880, PHOTO_HEIGHT: 2160, + // iPhones offer no 4:3 photo size between 3 MP and 12 MP, so the 6 MP target above selects 2016x1512 + IOS_STILL_WIDTH: 4032, + IOS_STILL_HEIGHT: 3024, PHOTO_ASPECT_RATIO: 4 / 3, }, @@ -475,6 +478,9 @@ const CONST = { FAILED: 'failed', }, + /** Google Ads only accepts click ID conversions for 90 days, so stored marketing attribution older than this is ignored */ + MARKETING_ATTRIBUTION_MAX_AGE_MS: 90 * 24 * 60 * 60 * 1000, + AUTH_TOKEN_TYPES: { ANONYMOUS: 'anonymousAccount', SUPPORT: 'support', @@ -1164,6 +1170,7 @@ const CONST = { PAYMENT_HISTORY: 'paymentHistory', SUPPORT_TICKET: 'supportTicket', ANCHORED_FIELD_DROPDOWNS: 'anchoredFieldDropdowns', + RESIZABLE_TABLE_COLUMNS: 'resizableTableColumns', }, BUTTON_STATES: { DEFAULT: 'default', @@ -2427,6 +2434,7 @@ const CONST = { SPAN_SHUTTER_TO_CONFIRMATION: 'ManualShutterToConfirmation', SPAN_RECEIPT_CAPTURE: 'ManualReceiptCapture', SPAN_RECEIPT_PREPARE: 'ManualReceiptPrepare', + SPAN_RECEIPT_UPGRADE: 'ManualReceiptUpgrade', SPAN_SCAN_PROCESS_AND_NAVIGATE: 'ManualScanProcessAndNavigate', SPAN_CONFIRMATION_MOUNT: 'ManualConfirmationMount', SPAN_CONFIRMATION_LIST_READY: 'ManualConfirmationListReady', @@ -2537,6 +2545,8 @@ const CONST = { ATTRIBUTE_IS_MULTI_SCAN: 'is_multi_scan', ATTRIBUTE_CAPTURE_METHOD: 'capture_method', ATTRIBUTE_FLASH_USED: 'flash_used', + ATTRIBUTE_UPGRADE_ATTEMPTED: 'upgrade_attempted', + ATTRIBUTE_UPGRADE_OUTCOME: 'upgrade_outcome', ATTRIBUTE_PHOTO_WIDTH: 'photo_width', ATTRIBUTE_PHOTO_HEIGHT: 'photo_height', ATTRIBUTE_SOURCE: 'source', @@ -2552,6 +2562,16 @@ const CONST = { PHOTO: 'photo', SNAPSHOT: 'snapshot', }, + UPGRADE_OUTCOME: { + UPGRADED: 'upgraded', + CAPTURE_FAILED: 'capture_failed', + CAPTURE_TIMED_OUT: 'capture_timed_out', + ROTATE_TIMED_OUT: 'rotate_timed_out', + ROTATE_FAILED: 'rotate_failed', + CLAIMED_FOR_UPLOAD: 'claimed_for_upload', + SWAP_FAILED: 'swap_failed', + STACKED_CAPTURE_SKIPPED: 'stacked_capture_skipped', + }, SPAN_PLATFORM: { NATIVE: 'native', WEB: 'web', @@ -2819,6 +2839,7 @@ const CONST = { }, ERROR_TYPE: { SOCKET: 'Expensify\\Auth\\Error\\Socket', + SESSION_MISMATCH: 'Expensify\\Error\\Auth\\AccountMismatch', }, ERROR_TITLE: { SOCKET: 'Issue connecting to database', @@ -4409,6 +4430,10 @@ const CONST = { MAX_RECENT_REPORTS_TO_SHOW: 5, MAX_RECENT_ATTENDEES: 40, + // Lets consumers tell a payment failure apart from other errors on a report preview. Numeric so it keeps + // sorting below microsecond keys in getLatestErrorMessageField, which picks the highest key. + PAY_FAILURE_PREVIEW_ERROR_KEY: 0, + // This will guranatee that the quantity input will not exceed 9,007,199,254,740,991 (Number.MAX_SAFE_INTEGER). QUANTITY_MAX_LENGTH: 12, // This is the transactionID used when going through the create expense flow so that it mimics a real transaction (like the edit flow) @@ -5314,6 +5339,7 @@ const CONST = { FEED_BANK_NAME: { MASTER_CARD: 'cdf', VISA: 'vcf', + VCF_MOCK: 'vcfmock', AMEX: 'gl1025', AMEX_1205: 'gl1205', STRIPE: 'stripe', @@ -5544,6 +5570,7 @@ const CONST = { AMEX: 'American Express', VISA: 'Visa', MASTERCARD: 'Mastercard', + MOCK_COMMERCIAL: 'Mock Commercial Feed', STRIPE: 'Stripe', CSV: 'CSV', }, @@ -6077,7 +6104,7 @@ const CONST = { MAX_MARKUP_LENGTH: 10000, // WebKit renders only the ellipsis when a single-line text with text-overflow: ellipsis is longer than 10,240 characters (https://bugs.webkit.org/show_bug.cgi?id=267226). - // One line never shows this many characters, so we cut single-line texts to this length on mobile WebKit. + // One line never shows this many characters, so we cut single-line texts to this length on WebKit browsers. MAX_SINGLE_LINE_TEXT_LENGTH: 1000, MAX_THREAD_REPLIES_PREVIEW: 99, @@ -9071,6 +9098,7 @@ const CONST = { HAS_DEVICE_MANAGEMENT_ERROR: 'hasDeviceManagementError', HAS_MERGE_HR_SETUP_NEEDED: 'hasMergeHRSetupNeeded', HAS_HR_CONNECTION_ERROR: 'hasHRConnectionError', + HAS_APPROVAL_WORKFLOW_NON_MEMBER_APPROVER: 'hasApprovalWorkflowNonMemberApprover', }, DEBUG: { @@ -9445,6 +9473,25 @@ const CONST = { /** How wide a free-text column may be sized for its content once the table scrolls, so one unusually long value doesn't push every column after it out of view. A table that still fits its columns caps nothing: the spare room is there to be used. */ MAX_FREE_TEXT_COLUMN_WIDTH: 180, }, + + /** Stored-width keys, one per column set rather than per screen, so tables with the same columns share widths. */ + COLUMN_RESIZING_IDS: { + WORKSPACE_MEMBERS: 'workspaceMembers', + WORKSPACE_TAXES: 'workspaceTaxes', + WORKSPACE_EXPENSIFY_CARDS: 'workspaceExpensifyCards', + WORKSPACE_COMPANY_CARDS: 'workspaceCompanyCards', + }, + + COLUMN_RESIZE: { + /** Narrowest drag width. Low enough to hide most content, but wide enough to keep the column's edge reachable. */ + MIN_WIDTH: 48, + + /** Width of the invisible drag strip centred on a column's edge. */ + HANDLE_HIT_WIDTH: 12, + + /** Width of the line shown while a column's edge is dragged. */ + INDICATOR_WIDTH: 2, + }, }, SENTRY_LABEL: { diff --git a/src/languages/de.ts b/src/languages/de.ts index b9d9dac0d502..386fd4d97eaf 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -629,6 +629,15 @@ const translations: TranslationDeepObject = { launching: 'Expensify wird gestartet', expired: 'Ihre Sitzung ist abgelaufen.', signIn: 'Bitte melde dich erneut an.', + notValid: 'Ungültiger Link.', + sessionMismatch: 'Der Link, den Sie angeklickt haben, ist für Ihre aktuelle Sitzung nicht gültig.', + switchAccount: { + title: 'Konten wechseln?', + prompt: ({newEmail, currentEmail}: {newEmail: string; currentEmail: string}) => + `Sie sind als ${currentEmail} angemeldet. Diese Aktion meldet Sie stattdessen als ${newEmail} an.`, + confirm: 'Konten wechseln', + staySignedIn: 'Nicht das Konto wechseln', + }, }, multifactorAuthentication: { reviewTransaction: { @@ -849,7 +858,7 @@ const translations: TranslationDeepObject = { } else if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { type = 'report'; } - return `Sind Sie sicher, dass Sie diesen ${type} löschen möchten?`; + return `Sind Sie sicher, dass Sie diesen ${type} löschen möchten?${action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ? ' Alle Ausgaben in diesem Bericht werden als nicht gemeldet markiert.' : ''}`; }, onlyVisible: 'Nur sichtbar für', explain: 'Erklären', @@ -1484,6 +1493,7 @@ const translations: TranslationDeepObject = { one: 'Möchten Sie diesen Bericht wirklich löschen?', other: 'Möchten Sie diese Berichte wirklich löschen?', }), + deleteExpenseReportConfirmation: 'Möchten Sie diesen Bericht wirklich löschen? Alle Ausgaben in diesem Bericht werden als nicht gemeldet markiert.', settledExpensify: 'Bezahlt', paidStatusMarkedAsPaid: 'Als bezahlt markiert', paidStatusWithdrawing: 'Abhebung läuft', @@ -1628,6 +1638,7 @@ const translations: TranslationDeepObject = { invalidSplitYourself: 'Bitte gib für deine Aufteilung einen von null verschiedenen Betrag ein', noParticipantSelected: 'Bitte wählen Sie eine:n Teilnehmende:n aus', other: 'Unerwarteter Fehler. Bitte versuchen Sie es später erneut.', + payFailedExpenseDeleted: 'Die Zahlung ist fehlgeschlagen, weil die Spesenabrechnung gelöscht wurde.', genericCreateFailureMessage: 'Unerwarteter Fehler beim Einreichen dieser Ausgabe. Bitte versuche es später noch einmal.', genericCreateInvoiceFailureMessage: 'Unerwarteter Fehler beim Senden dieser Rechnung. Bitte versuche es später noch einmal.', genericHoldExpenseFailureMessage: 'Unerwarteter Fehler beim Zurückhalten dieser Ausgabe. Bitte versuche es später erneut.', @@ -2996,6 +3007,10 @@ ${amount} für ${merchant} – ${date}`, }, }, approverInMultipleWorkflows: 'Dieses Mitglied gehört bereits zu einem anderen Genehmigungsworkflow. Alle Aktualisierungen hier werden sich auch dort auswirken.', + approverNotWorkspaceMember: 'Diese genehmigende Person ist nicht mehr Mitglied des Arbeitsbereichs. Wählen Sie eine neue genehmigende Person oder löschen Sie diesen Workflow.', + defaultWorkflowApproverNotWorkspaceMember: 'Diese genehmigende Person ist nicht mehr Mitglied des Arbeitsbereichs. Wählen Sie eine neue genehmigende Person.', + overLimitApproverNotWorkspaceMember: + 'Die zusätzliche genehmigende Person für Berichte über dem Limit ist nicht mehr Mitglied des Arbeitsbereichs. Wählen Sie eine neue zusätzliche genehmigende Person.', approverCircularReference: (name1: string, name2: string) => `${name1} genehmigt bereits Berichte für ${name2}. Bitte wähle eine andere approvierende Person, um einen zirkulären Workflow zu vermeiden.`, emptyContent: { @@ -8909,7 +8924,17 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc setupIncomplete: (setupLink: string | undefined) => `Verbunden. ${setupLink ? `Einrichtung abschließen` : 'Einrichtung abschließen'} zum Importieren von Mitarbeitenden.`, mergeHR: { - groups: {title: 'Gruppen', description: 'Wählen Sie die Mitarbeitergruppen aus, die Sie mit diesem Workspace synchronisieren möchten'}, + groups: { + title: 'Gruppen', + description: 'Wählen Sie die Mitarbeitergruppen aus, die Sie mit diesem Workspace synchronisieren möchten', + staleSelectionError: (providerName: string) => + `Einige der Gruppen, die Sie ausgewählt haben, existieren in ${providerName} nicht mehr, daher werden deren Mitarbeitende nicht mehr synchronisiert.`, + updateSelectionLink: 'Aktualisieren Sie Ihre Gruppen.', + noGroupsFound: 'Keine Gruppen gefunden', + noGroupsFoundDescription: + 'Es gibt derzeit keine Gruppen zum Auswählen. Speichern Sie ohne ausgewählte Gruppen, um alle Mitarbeitenden zu synchronisieren, oder synchronisieren Sie die Verbindung erneut, sobald neue Gruppen vorhanden sind.', + unnamedGroup: (groupID: string) => `Unbenannte Gruppe (${groupID})`, + }, }, }, recruiting: { diff --git a/src/languages/el.ts b/src/languages/el.ts index b721bca85b38..a34beef18540 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -633,6 +633,15 @@ const translations: TranslationDeepObject = { launching: 'Εκκίνηση του Expensify', expired: 'Η συνεδρία σας έχει λήξει.', signIn: 'Παρακαλούμε συνδεθείτε ξανά.', + notValid: 'Μη έγκυρος σύνδεσμος.', + sessionMismatch: 'Ο σύνδεσμος που κάνατε κλικ δεν είναι έγκυρος για την τρέχουσα συνεδρία σας.', + switchAccount: { + title: 'Αλλαγή λογαριασμών;', + prompt: ({newEmail, currentEmail}: {newEmail: string; currentEmail: string}) => + `Έχετε συνδεθεί ως ${currentEmail}. Αυτή η ενέργεια θα σας συνδέσει ως ${newEmail} αντί για αυτό.`, + confirm: 'Αλλαγή λογαριασμών', + staySignedIn: 'Να μην αλλάξω λογαριασμούς', + }, }, multifactorAuthentication: { reviewTransaction: { @@ -867,7 +876,7 @@ const translations: TranslationDeepObject = { } else if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { type = 'report'; } - return `Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτό το ${type};`; + return `Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτό το ${type};${action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ? ' Όλες οι δαπάνες σε αυτήν την αναφορά θα γίνουν μη υποβληθείσες.' : ''}`; }, onlyVisible: 'Ορατό μόνο σε', explain: 'Εξηγήστε', @@ -1532,6 +1541,7 @@ const translations: TranslationDeepObject = { one: 'Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτήν την αναφορά;', other: 'Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτές τις αναφορές;', }), + deleteExpenseReportConfirmation: 'Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτήν την αναφορά; Όλες οι δαπάνες σε αυτήν την αναφορά θα γίνουν μη υποβληθείσες.', settledExpensify: 'Πληρωμένο', paidStatusMarkedAsPaid: 'Επισημάνθηκε ως πληρωμένο', paidStatusWithdrawing: 'Ανάληψη', @@ -1695,6 +1705,7 @@ const translations: TranslationDeepObject = { invalidSplitYourself: 'Παρακαλούμε εισαγάγετε ένα μη μηδενικό ποσό για τη διαίρεσή σας', noParticipantSelected: 'Παρακαλούμε επιλέξτε έναν συμμετέχοντα', other: 'Απρόσμενο σφάλμα. Παρακαλούμε δοκιμάστε ξανά αργότερα.', + payFailedExpenseDeleted: 'Η πληρωμή απέτυχε επειδή η έκθεση εξόδων διαγράφηκε.', genericCreateFailureMessage: 'Μη αναμενόμενο σφάλμα κατά την αποστολή αυτής της δαπάνης. Παρακαλούμε δοκιμάστε ξανά αργότερα.', genericCreateInvoiceFailureMessage: 'Προέκυψε απροσδόκητο σφάλμα κατά την αποστολή αυτού του τιμολογίου. Παρακαλούμε προσπαθήστε ξανά αργότερα.', genericHoldExpenseFailureMessage: 'Προέκυψε απρόσμενο σφάλμα κατά την κράτηση αυτής της δαπάνης. Δοκιμάστε ξανά αργότερα.', @@ -3055,6 +3066,9 @@ ${amount} για ${merchant} - ${date}`, }, }, approverInMultipleWorkflows: 'Αυτό το μέλος ανήκει ήδη σε άλλη ροή έγκρισης. Τυχόν ενημερώσεις εδώ θα αντικατοπτρίζονται και εκεί.', + approverNotWorkspaceMember: 'Αυτός ο εγκρίνων δεν είναι πλέον μέλος του χώρου εργασίας. Επιλέξτε νέο εγκρίνοντα ή διαγράψτε αυτή τη ροή εργασίας.', + defaultWorkflowApproverNotWorkspaceMember: 'Αυτός ο εγκρίνων δεν είναι πλέον μέλος του χώρου εργασίας. Επιλέξτε νέο εγκρίνοντα.', + overLimitApproverNotWorkspaceMember: 'Ο πρόσθετος εγκρίνων για αναφορές πάνω από το όριο δεν είναι πλέον μέλος του χώρου εργασίας. Επιλέξτε νέο πρόσθετο εγκρίνοντα.', approverCircularReference: (name1: string, name2: string) => `Ο/Η ${name1} ήδη εγκρίνει αναφορές προς τον/την ${name2}. Παρακαλούμε επιλέξτε διαφορετικό εγκρίνων για να αποφύγετε έναν κυκλικό κύκλο έγκρισης.`, emptyContent: { @@ -7842,6 +7856,13 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ groups: { title: 'Ομάδες', description: 'Επιλέξτε τις ομάδες υπαλλήλων που θέλετε να συγχρονίσετε με αυτόν τον χώρο εργασίας', + staleSelectionError: (providerName: string) => + `Ορισμένες από τις ομάδες που επιλέξατε δεν υπάρχουν πλέον στο ${providerName}, επομένως οι εργαζόμενοι τους έχουν σταματήσει να συγχρονίζονται.`, + updateSelectionLink: 'Ενημερώστε τις ομάδες σας.', + noGroupsFound: 'Δεν βρέθηκαν ομάδες', + noGroupsFoundDescription: + 'Δεν υπάρχουν ομάδες για επιλογή αυτή τη στιγμή. Αποθηκεύστε χωρίς επιλεγμένες ομάδες για να συγχρονίσετε όλους τους υπαλλήλους ή συγχρονίστε ξανά τη σύνδεση μόλις δημιουργηθούν νέες ομάδες.', + unnamedGroup: (groupID: string) => `Ομάδα χωρίς όνομα (${groupID})`, }, }, }, diff --git a/src/languages/en.ts b/src/languages/en.ts index b63097414db7..0fc11e32ea37 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -703,6 +703,14 @@ const translations = { launching: 'Launching Expensify', expired: 'Your session has expired.', signIn: 'Please sign in again.', + notValid: 'Invalid link.', + sessionMismatch: "The link you clicked isn't valid for your current session.", + switchAccount: { + title: 'Switch accounts?', + prompt: ({newEmail, currentEmail}: {newEmail: string; currentEmail: string}) => `You're signed in as ${currentEmail}. This action will sign you in as ${newEmail} instead.`, + confirm: 'Switch accounts', + staySignedIn: "Don't switch accounts", + }, }, multifactorAuthentication: { reviewTransaction: { @@ -935,7 +943,7 @@ const translations = { } else if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { type = 'report'; } - return `Are you sure you want to delete this ${type}?`; + return `Are you sure you want to delete this ${type}?${action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ? ' All expenses in this report will become unreported.' : ''}`; }, onlyVisible: 'Only visible to', explain: 'Explain', @@ -1613,6 +1621,7 @@ const translations = { one: 'Are you sure that you want to delete this report?', other: 'Are you sure that you want to delete these reports?', }), + deleteExpenseReportConfirmation: 'Are you sure that you want to delete this report? All expenses in this report will become unreported.', settledExpensify: 'Paid', paidStatusMarkedAsPaid: 'Marked as paid', paidStatusWithdrawing: 'Withdrawing', @@ -1772,6 +1781,7 @@ const translations = { invalidSplitYourself: 'Please enter a non-zero amount for your split', noParticipantSelected: 'Please select a participant', other: 'Unexpected error. Please try again later.', + payFailedExpenseDeleted: 'The payment failed because the expense report was deleted.', genericCreateFailureMessage: 'Unexpected error submitting this expense. Please try again later.', genericCreateInvoiceFailureMessage: 'Unexpected error sending this invoice. Please try again later.', genericHoldExpenseFailureMessage: 'Unexpected error holding this expense. Please try again later.', @@ -3131,6 +3141,9 @@ const translations = { }, }, approverInMultipleWorkflows: 'This member already belongs to another approval workflow. Any updates here will reflect there too.', + approverNotWorkspaceMember: 'This approver is no longer a workspace member. Choose a new approver or delete this workflow.', + defaultWorkflowApproverNotWorkspaceMember: 'This approver is no longer a workspace member. Choose a new approver.', + overLimitApproverNotWorkspaceMember: 'The additional approver for reports over the limit is no longer a workspace member. Choose a new additional approver.', approverCircularReference: (name1: string, name2: string) => `${name1} already approves reports to ${name2}. Please choose a different approver to avoid a circular workflow.`, emptyContent: { @@ -8064,6 +8077,12 @@ const translations = { groups: { title: 'Groups', description: 'Choose the groups of employees you would like to sync with this workspace', + staleSelectionError: (providerName: string) => `Some of the groups you selected no longer exist in ${providerName}, so their employees have stopped syncing.`, + updateSelectionLink: 'Update your groups.', + noGroupsFound: 'No groups found', + noGroupsFoundDescription: + 'There are no groups to select right now. Save with no groups selected to sync all employees, or sync the connection again once new groups exist.', + unnamedGroup: (groupID: string) => `Unnamed group (${groupID})`, }, }, }, diff --git a/src/languages/es.ts b/src/languages/es.ts index a25f1643ff3d..b2722ac2eaa9 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -619,6 +619,15 @@ const translations: TranslationDeepObject = { launching: 'Cargando Expensify', expired: 'Tu sesión ha expirado.', signIn: 'Por favor, inicia sesión de nuevo.', + notValid: 'Enlace no válido.', + sessionMismatch: 'El enlace que has pulsado no es válido para tu sesión actual.', + switchAccount: { + title: '¿Cambiar de cuenta?', + prompt: ({newEmail, currentEmail}: {newEmail: string; currentEmail: string}) => + `Has iniciado sesión como ${currentEmail}. Esta acción hará que inicies sesión como ${newEmail} en su lugar.`, + confirm: 'Cambiar de cuenta', + staySignedIn: 'No cambies de cuenta', + }, }, multifactorAuthentication: { reviewTransaction: { @@ -831,7 +840,7 @@ const translations: TranslationDeepObject = { } else if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { type = 'informe'; } - return `¿Estás seguro de que quieres eliminar este ${type}?`; + return `¿Estás seguro de que quieres eliminar este ${type}?${action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ? ' Todos los gastos de este informe quedarán sin reportar.' : ''}`; }, onlyVisible: 'Visible sólo para', explain: 'Explicar', @@ -1474,6 +1483,7 @@ const translations: TranslationDeepObject = { one: '¿Estás seguro de que quieres eliminar este informe?', other: '¿Estás seguro de que quieres eliminar estos informes?', }), + deleteExpenseReportConfirmation: '¿Estás seguro de que quieres eliminar este informe? Todos los gastos de este informe quedarán sin reportar.', settledExpensify: 'Pagado', paidStatusMarkedAsPaid: 'Marcado como pagado', paidStatusWithdrawing: 'Retirada', @@ -1638,6 +1648,7 @@ const translations: TranslationDeepObject = { other: 'Error inesperado. Por favor, inténtalo más tarde.', genericHoldExpenseFailureMessage: 'Error inesperado al retener el gasto. Por favor, inténtalo de nuevo más tarde.', genericUnholdExpenseFailureMessage: 'Error inesperado al desbloquear el gasto. Por favor, inténtalo de nuevo más tarde.', + payFailedExpenseDeleted: 'El pago falló porque el informe de gastos fue eliminado.', genericCreateFailureMessage: 'Error inesperado al enviar este gasto. Por favor, inténtalo más tarde.', genericCreateInvoiceFailureMessage: 'Error inesperado al enviar la factura. Por favor, inténtalo de nuevo más tarde.', receiptDeleteFailureError: 'Error inesperado al borrar este recibo. Por favor, vuelve a intentarlo más tarde.', @@ -2898,6 +2909,9 @@ ${amount} para ${merchant} - ${date}`, }, }, approverInMultipleWorkflows: 'Este miembro ya pertenece a otro flujo de aprobación. Cualquier actualización aquí se reflejará allí también.', + approverNotWorkspaceMember: 'Este aprobador ya no es miembro del espacio de trabajo. Elige un nuevo aprobador o elimina este flujo de trabajo.', + defaultWorkflowApproverNotWorkspaceMember: 'Este aprobador ya no es miembro del espacio de trabajo. Elige un nuevo aprobador.', + overLimitApproverNotWorkspaceMember: 'El aprobador adicional para los informes que superan el límite ya no es miembro del espacio de trabajo. Elige un nuevo aprobador adicional.', approverCircularReference: (name1, name2) => `${name1} ya aprueba informes a ${name2}. Por favor, elige un aprobador diferente para evitar un flujo de trabajo circular.`, emptyContent: { @@ -7578,7 +7592,17 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, setupIncomplete: (setupLink: string | undefined) => `Conectado. ${setupLink ? `Completa la configuración` : 'Completar configuración'} para importar empleados.`, mergeHR: { - groups: {title: 'Grupos', description: 'Elige los grupos de empleados que te gustaría sincronizar con este espacio de trabajo'}, + groups: { + title: 'Grupos', + description: 'Elige los grupos de empleados que te gustaría sincronizar con este espacio de trabajo', + staleSelectionError: (providerName: string) => + `Algunos de los grupos que seleccionaste ya no existen en ${providerName}, por lo que sus empleados han dejado de sincronizarse.`, + updateSelectionLink: 'Actualiza tus grupos.', + noGroupsFound: 'No se han encontrado grupos', + noGroupsFoundDescription: + 'No hay grupos para seleccionar ahora mismo. Guarda sin grupos seleccionados para sincronizar a todos los empleados o vuelve a sincronizar la conexión cuando existan nuevos grupos.', + unnamedGroup: (groupID: string) => `Grupo sin nombre (${groupID})`, + }, }, }, recruiting: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index a23bff9e834b..c6952dc82cfb 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -628,6 +628,15 @@ const translations: TranslationDeepObject = { launching: 'Lancement d’Expensify', expired: 'Votre session a expiré.', signIn: 'Veuillez vous reconnecter.', + notValid: 'Lien invalide.', + sessionMismatch: 'Le lien sur lequel vous avez cliqué n’est pas valide pour votre session actuelle.', + switchAccount: { + title: 'Changer de compte ?', + prompt: ({newEmail, currentEmail}: {newEmail: string; currentEmail: string}) => + `Vous êtes connecté en tant que ${currentEmail}. Cette action vous connectera plutôt en tant que ${newEmail}.`, + confirm: 'Changer de compte', + staySignedIn: 'Ne changez pas de compte', + }, }, multifactorAuthentication: { reviewTransaction: { @@ -849,7 +858,7 @@ const translations: TranslationDeepObject = { } else if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { type = 'report'; } - return `Voulez-vous vraiment supprimer ce ${type} ?`; + return `Voulez-vous vraiment supprimer ce ${type} ?${action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ? ' Toutes les dépenses de ce rapport seront considérées comme non déclarées.' : ''}`; }, onlyVisible: 'Visible uniquement par', explain: 'Expliquer', @@ -1490,6 +1499,7 @@ const translations: TranslationDeepObject = { one: 'Êtes-vous sûr de vouloir supprimer ce rapport ?', other: 'Êtes-vous sûr de vouloir supprimer ces rapports ?', }), + deleteExpenseReportConfirmation: 'Êtes-vous sûr de vouloir supprimer ce rapport ? Toutes les dépenses de ce rapport seront considérées comme non déclarées.', settledExpensify: 'Payé', paidStatusMarkedAsPaid: 'Marqué comme payé', paidStatusWithdrawing: 'Retrait', @@ -1633,6 +1643,7 @@ const translations: TranslationDeepObject = { invalidSplitYourself: 'Veuillez saisir un montant non nul pour votre répartition', noParticipantSelected: 'Veuillez sélectionner un participant', other: 'Erreur inattendue. Veuillez réessayer plus tard.', + payFailedExpenseDeleted: 'Le paiement a échoué car la note de frais a été supprimée.', genericCreateFailureMessage: 'Erreur inattendue lors de la soumission de cette dépense. Veuillez réessayer plus tard.', genericCreateInvoiceFailureMessage: 'Erreur inattendue lors de l’envoi de cette facture. Veuillez réessayer plus tard.', genericHoldExpenseFailureMessage: 'Erreur inattendue lors de la mise en attente de cette dépense. Veuillez réessayer ultérieurement.', @@ -3001,6 +3012,10 @@ ${amount} pour ${merchant} - ${date}`, }, }, approverInMultipleWorkflows: 'Ce membre appartient déjà à un autre circuit d’approbation. Toute mise à jour effectuée ici sera également répercutée là-bas.', + approverNotWorkspaceMember: 'Cet approbateur n’est plus membre de l’espace de travail. Choisissez un nouvel approbateur ou supprimez ce flux de travail.', + defaultWorkflowApproverNotWorkspaceMember: 'Cet approbateur n’est plus membre de l’espace de travail. Choisissez un nouvel approbateur.', + overLimitApproverNotWorkspaceMember: + 'L’approbateur supplémentaire pour les notes de frais au-delà de la limite n’est plus membre de l’espace de travail. Choisissez un nouvel approbateur supplémentaire.', approverCircularReference: (name1: string, name2: string) => `${name1} approuve déjà les notes de frais de ${name2}. Veuillez choisir un autre approbateur pour éviter un circuit d'approbation circulaire.`, emptyContent: { @@ -8926,7 +8941,17 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e setupIncomplete: (setupLink: string | undefined) => `Connecté. ${setupLink ? `Terminer la configuration` : 'Terminer la configuration'} pour importer les employés.`, mergeHR: { - groups: {title: 'Groupes', description: 'Choisissez les groupes d’employés que vous souhaitez synchroniser avec cet espace de travail'}, + groups: { + title: 'Groupes', + description: 'Choisissez les groupes d’employés que vous souhaitez synchroniser avec cet espace de travail', + staleSelectionError: (providerName: string) => + `Certains des groupes que vous avez sélectionnés n’existent plus dans ${providerName}, donc leurs employés ont cessé de se synchroniser.`, + updateSelectionLink: 'Mettez à jour vos groupes.', + noGroupsFound: 'Aucun groupe trouvé', + noGroupsFoundDescription: + 'Il n’y a aucun groupe à sélectionner pour le moment. Enregistrez sans groupe sélectionné pour synchroniser tous les employés, ou synchronisez à nouveau la connexion une fois que de nouveaux groupes existent.', + unnamedGroup: (groupID: string) => `Groupe sans nom (${groupID})`, + }, }, }, recruiting: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 25a73a4db01b..970e9d46503f 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -628,6 +628,15 @@ const translations: TranslationDeepObject = { launching: 'Avvio di Expensify', expired: 'La tua sessione è scaduta.', signIn: 'Accedi di nuovo.', + notValid: 'Link non valido.', + sessionMismatch: 'Il link che hai cliccato non è valido per la tua sessione attuale.', + switchAccount: { + title: 'Passare ad un altro account?', + prompt: ({newEmail, currentEmail}: {newEmail: string; currentEmail: string}) => + `Hai eseguito l’accesso come ${currentEmail}. Questa azione ti farà accedere come ${newEmail} invece.`, + confirm: 'Cambia account', + staySignedIn: 'Non cambiare account', + }, }, multifactorAuthentication: { reviewTransaction: { @@ -849,7 +858,7 @@ const translations: TranslationDeepObject = { } else if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { type = 'report'; } - return `Sei sicuro di voler eliminare questo/questa ${type}?`; + return `Sei sicuro di voler eliminare questo/questa ${type}?${action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ? ' Tutte le spese in questo report risulteranno non rendicontate.' : ''}`; }, onlyVisible: 'Visibile solo a', explain: 'Spiega', @@ -1481,6 +1490,7 @@ const translations: TranslationDeepObject = { one: 'Sei sicuro di voler eliminare questo report?', other: 'Sei sicuro di voler eliminare questi report?', }), + deleteExpenseReportConfirmation: 'Sei sicuro di voler eliminare questo report? Tutte le spese in questo report risulteranno non rendicontate.', settledExpensify: 'Pagato', paidStatusMarkedAsPaid: 'Segnato come pagato', paidStatusWithdrawing: 'Prelievo', @@ -1624,6 +1634,7 @@ const translations: TranslationDeepObject = { invalidSplitYourself: 'Inserisci un importo diverso da zero per la tua suddivisione', noParticipantSelected: 'Seleziona un partecipante', other: 'Errore imprevisto. Riprova più tardi.', + payFailedExpenseDeleted: 'Il pagamento non è riuscito perché la nota spese è stata eliminata.', genericCreateFailureMessage: 'Errore imprevisto durante l’invio di questa nota spese. Riprova più tardi.', genericCreateInvoiceFailureMessage: 'Errore imprevisto nell’invio di questa fattura. Riprova più tardi.', genericHoldExpenseFailureMessage: 'Errore imprevisto durante il blocco di questa spesa. Riprova più tardi.', @@ -2982,6 +2993,9 @@ ${amount} per ${merchant} - ${date}`, }, }, approverInMultipleWorkflows: 'Questo membro appartiene già a un altro flusso di approvazione. Qualsiasi aggiornamento effettuato qui verrà applicato anche lì.', + approverNotWorkspaceMember: 'Questo approvatore non è più un membro dello spazio di lavoro. Scegli un nuovo approvatore oppure elimina questo workflow.', + defaultWorkflowApproverNotWorkspaceMember: 'Questo approvatore non è più un membro dello spazio di lavoro. Scegli un nuovo approvatore.', + overLimitApproverNotWorkspaceMember: 'L’approvatore aggiuntivo per i report oltre il limite non è più un membro dello spazio di lavoro. Scegli un nuovo approvatore aggiuntivo.', approverCircularReference: (name1: string, name2: string) => `${name1} approva già i report per ${name2}. Scegli un altro approvatore per evitare un flusso di lavoro circolare.`, emptyContent: { @@ -8853,7 +8867,17 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, setupIncomplete: (setupLink: string | undefined) => `Connesso. ${setupLink ? `Completa la configurazione` : 'Completa configurazione'} per importare i dipendenti.`, mergeHR: { - groups: {title: 'Gruppi', description: 'Scegli i gruppi di dipendenti che vuoi sincronizzare con questo spazio di lavoro'}, + groups: { + title: 'Gruppi', + description: 'Scegli i gruppi di dipendenti che vuoi sincronizzare con questo spazio di lavoro', + staleSelectionError: (providerName: string) => + `Alcuni dei gruppi che hai selezionato non esistono più in ${providerName}, quindi i rispettivi dipendenti hanno smesso di sincronizzarsi.`, + updateSelectionLink: 'Aggiorna i tuoi gruppi.', + noGroupsFound: 'Nessun gruppo trovato', + noGroupsFoundDescription: + 'Al momento non ci sono gruppi da selezionare. Salva senza selezionare gruppi per sincronizzare tutti i dipendenti oppure sincronizza di nuovo la connessione quando esisteranno nuovi gruppi.', + unnamedGroup: (groupID: string) => `Gruppo senza nome (${groupID})`, + }, }, }, recruiting: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 03fee37c88b3..69d42701f511 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -630,6 +630,15 @@ const translations: TranslationDeepObject = { launching: 'Expensify を起動中', expired: 'セッションの有効期限が切れました。', signIn: 'もう一度サインインしてください。', + notValid: '無効なリンクです。', + sessionMismatch: 'クリックされたリンクは現在のセッションでは有効ではありません。', + switchAccount: { + title: 'アカウントを切り替えますか?', + prompt: ({newEmail, currentEmail}: {newEmail: string; currentEmail: string}) => + `現在は ${currentEmail} としてサインインしています。この操作を行うと、代わりに ${newEmail} としてサインインします。`, + confirm: 'アカウントを切り替える', + staySignedIn: 'アカウントを切り替えないでください', + }, }, multifactorAuthentication: { reviewTransaction: { @@ -838,7 +847,7 @@ const translations: TranslationDeepObject = { } else if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { type = 'report'; } - return `この${type}を削除してもよろしいですか?`; + return `この${type}を削除してもよろしいですか?${action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ? 'このレポート内のすべての経費は未報告になります。' : ''}`; }, onlyVisible: '次のユーザーにのみ表示', explain: '説明', @@ -1467,6 +1476,7 @@ const translations: TranslationDeepObject = { one: 'このレポートを削除してもよろしいですか?', other: 'これらのレポートを削除してもよろしいですか?', }), + deleteExpenseReportConfirmation: 'このレポートを削除してもよろしいですか?このレポート内のすべての経費は未報告になります。', settledExpensify: '支払い済み', paidStatusMarkedAsPaid: '支払済みに設定しました', paidStatusWithdrawing: '出金中', @@ -1609,6 +1619,7 @@ const translations: TranslationDeepObject = { invalidSplitYourself: '分割する金額は 0 以外の数値を入力してください', noParticipantSelected: '参加者を選択してください', other: '予期しないエラーが発生しました。しばらくしてからもう一度お試しください。', + payFailedExpenseDeleted: 'この支払いは、経費精算書が削除されたため失敗しました。', genericCreateFailureMessage: 'この経費の送信中に予期しないエラーが発生しました。後でもう一度お試しください。', genericCreateInvoiceFailureMessage: 'この請求書の送信中に予期しないエラーが発生しました。後でもう一度お試しください。', genericHoldExpenseFailureMessage: 'この経費を保留中に予期しないエラーが発生しました。後でもう一度お試しください。', @@ -2957,6 +2968,9 @@ ${date} の ${merchant} への ${amount}`, }, }, approverInMultipleWorkflows: 'このメンバーはすでに別の承認ワークフローに属しています。ここでの更新内容はそちらにも反映されます。', + approverNotWorkspaceMember: 'この承認者はワークスペースのメンバーではなくなりました。新しい承認者を選択するか、このワークフローを削除してください。', + defaultWorkflowApproverNotWorkspaceMember: 'この承認者はワークスペースのメンバーではなくなりました。新しい承認者を選択してください。', + overLimitApproverNotWorkspaceMember: '上限を超えるレポートの追加承認者は、ワークスペースのメンバーではなくなりました。新しい追加承認者を選択してください。', approverCircularReference: (name1: string, name2: string) => `${name1} はすでに ${name2} にレポートを承認しています。ワークフローが循環しないよう、別の承認者を選択してください。`, emptyContent: { @@ -8742,7 +8756,16 @@ ${reportName}`, setupIncomplete: (setupLink: string | undefined) => `接続されました。従業員をインポートするには ${setupLink ? `セットアップを完了` : '設定を完了'} に接続してください。`, mergeHR: { - groups: {title: 'グループ', description: 'このワークスペースと同期したい従業員グループを選択してください'}, + groups: { + title: 'グループ', + description: 'このワークスペースと同期したい従業員グループを選択してください', + staleSelectionError: (providerName: string) => `選択されたグループの一部は、${providerName} では既に存在しないため、その従業員の同期が停止しました。`, + updateSelectionLink: 'グループを更新します。', + noGroupsFound: 'グループが見つかりませんでした', + noGroupsFoundDescription: + '現在選択できるグループはありません。グループを選択せずに保存して全従業員を同期するか、新しいグループが作成されてから再度接続を同期してください。', + unnamedGroup: (groupID: string) => `名称未設定のグループ(${groupID})`, + }, }, }, recruiting: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 4bbf9c07dd2f..ad3d2ea870e9 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -628,6 +628,15 @@ const translations: TranslationDeepObject = { launching: 'Expensify wordt gestart', expired: 'Je sessie is verlopen.', signIn: 'Meld je opnieuw aan.', + notValid: 'Ongeldige link.', + sessionMismatch: 'De link waarop je hebt geklikt is niet geldig voor je huidige sessie.', + switchAccount: { + title: 'Van account wisselen?', + prompt: ({newEmail, currentEmail}: {newEmail: string; currentEmail: string}) => + `Je bent aangemeld als ${currentEmail}. Deze actie meldt je in plaats daarvan aan als ${newEmail}.`, + confirm: 'Van account wisselen', + staySignedIn: 'Niet van account wisselen', + }, }, multifactorAuthentication: { reviewTransaction: { @@ -848,7 +857,7 @@ const translations: TranslationDeepObject = { } else if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { type = 'report'; } - return `Weet je zeker dat je deze ${type} wilt verwijderen?`; + return `Weet je zeker dat je deze ${type} wilt verwijderen?${action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ? ' Alle uitgaven in dit rapport krijgen de status Niet gerapporteerd.' : ''}`; }, onlyVisible: 'Alleen zichtbaar voor', explain: 'Uitleg', @@ -1482,6 +1491,7 @@ const translations: TranslationDeepObject = { one: 'Weet u zeker dat u dit rapport wilt verwijderen?', other: 'Weet u zeker dat u deze rapporten wilt verwijderen?', }), + deleteExpenseReportConfirmation: 'Weet u zeker dat u dit rapport wilt verwijderen? Alle uitgaven in dit rapport krijgen de status Niet gerapporteerd.', settledExpensify: 'Betaald', paidStatusMarkedAsPaid: 'Gemarkeerd als betaald', paidStatusWithdrawing: 'Opnemen', @@ -1624,6 +1634,7 @@ const translations: TranslationDeepObject = { invalidSplitYourself: 'Voer een ander bedrag dan nul in voor je verdeling', noParticipantSelected: 'Selecteer een deelnemer', other: 'Onverwachte fout. Probeer het later opnieuw.', + payFailedExpenseDeleted: 'De betaling is mislukt omdat de declaratie is verwijderd.', genericCreateFailureMessage: 'Onverwachte fout bij het indienen van deze uitgave. Probeer het later opnieuw.', genericCreateInvoiceFailureMessage: 'Onverwachte fout bij het verzenden van deze factuur. Probeer het later opnieuw.', genericHoldExpenseFailureMessage: 'Onverwachte fout bij het vasthouden van deze uitgave. Probeer het later opnieuw.', @@ -2986,6 +2997,9 @@ ${amount} voor ${merchant} - ${date}`, }, }, approverInMultipleWorkflows: 'Dit lid behoort al tot een andere goedkeuringsworkflow. Alle updates hier worden daar ook doorgevoerd.', + approverNotWorkspaceMember: 'Deze fiatteur is geen lid meer van de workspace. Kies een nieuwe fiatteur of verwijder deze workflow.', + defaultWorkflowApproverNotWorkspaceMember: 'Deze fiatteur is geen lid meer van de workspace. Kies een nieuwe fiatteur.', + overLimitApproverNotWorkspaceMember: 'De extra fiatteur voor rapporten boven de limiet is geen lid meer van de workspace. Kies een nieuwe extra fiatteur.', approverCircularReference: (name1: string, name2: string) => `${name1} keurt al rapporten goed voor ${name2}. Kies een andere fiatteur om een cirkelstroom te voorkomen.`, emptyContent: { @@ -8831,7 +8845,17 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, setupIncomplete: (setupLink: string | undefined) => `Verbonden. ${setupLink ? `Instellen voltooien` : 'Configuratie voltooien'} om werknemers te importeren.`, mergeHR: { - groups: {title: 'Groepen', description: 'Kies de groepen werknemers die je met deze workspace wilt synchroniseren'}, + groups: { + title: 'Groepen', + description: 'Kies de groepen werknemers die je met deze workspace wilt synchroniseren', + staleSelectionError: (providerName: string) => + `Een aantal van de groepen die je hebt geselecteerd bestaan niet meer in ${providerName}, waardoor de werknemers in die groepen niet meer worden gesynchroniseerd.`, + updateSelectionLink: 'Werk je groepen bij.', + noGroupsFound: 'Geen groepen gevonden', + noGroupsFoundDescription: + 'Er zijn op dit moment geen groepen om te selecteren. Sla op zonder groepen te selecteren om alle werknemers te synchroniseren, of synchroniseer de verbinding opnieuw zodra er nieuwe groepen zijn.', + unnamedGroup: (groupID: string) => `Groep zonder naam (${groupID})`, + }, }, }, recruiting: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 2f73cfd6b219..1651a4662bd2 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -631,6 +631,14 @@ const translations: TranslationDeepObject = { launching: 'Uruchamianie Expensify', expired: 'Twoja sesja wygasła.', signIn: 'Zaloguj się ponownie.', + notValid: 'Nieprawidłowy link.', + sessionMismatch: 'Link, który kliknąłeś, nie jest ważny dla twojej bieżącej sesji.', + switchAccount: { + title: 'Przełączyć konto?', + prompt: ({newEmail, currentEmail}: {newEmail: string; currentEmail: string}) => `Jesteś zalogowany jako ${currentEmail}. Ta akcja zaloguje cię zamiast tego jako ${newEmail}.`, + confirm: 'Przełącz konta', + staySignedIn: 'Nie przełączaj kont', + }, }, multifactorAuthentication: { reviewTransaction: { @@ -854,7 +862,7 @@ const translations: TranslationDeepObject = { } else if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { type = 'report'; } - return `Czy na pewno chcesz usunąć ten/ tę ${type}?`; + return `Czy na pewno chcesz usunąć ten/ tę ${type}?${action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ? ' Wszystkie wydatki w tym raporcie zostaną oznaczone jako nierozliczone.' : ''}`; }, onlyVisible: 'Widoczne tylko dla', explain: 'Wyjaśnij', @@ -1516,6 +1524,7 @@ const translations: TranslationDeepObject = { one: 'Czy na pewno chcesz usunąć ten raport?', other: 'Czy na pewno chcesz usunąć te raporty?', }), + deleteExpenseReportConfirmation: 'Czy na pewno chcesz usunąć ten raport? Wszystkie wydatki w tym raporcie zostaną oznaczone jako nierozliczone.', settledExpensify: 'Zapłacono', paidStatusMarkedAsPaid: 'Oznaczono jako zapłacone', paidStatusWithdrawing: 'Wypłata', @@ -1658,6 +1667,7 @@ const translations: TranslationDeepObject = { invalidSplitYourself: 'Wprowadź niezerową kwotę dla swojego podziału', noParticipantSelected: 'Wybierz uczestnika', other: 'Nieoczekiwany błąd. Spróbuj ponownie później.', + payFailedExpenseDeleted: 'Płatność nie powiodła się, ponieważ raport wydatków został usunięty.', genericCreateFailureMessage: 'Niespodziewany błąd podczas przesyłania tego wydatku. Spróbuj ponownie później.', genericCreateInvoiceFailureMessage: 'Nieoczekiwany błąd podczas wysyłania tej faktury. Spróbuj ponownie później.', genericHoldExpenseFailureMessage: 'Nieoczekiwany błąd podczas wstrzymywania tego wydatku. Spróbuj ponownie później.', @@ -3017,6 +3027,9 @@ ${amount} dla ${merchant} - ${date}`, }, }, approverInMultipleWorkflows: 'Ten członek należy już do innego procesu zatwierdzania. Wszelkie zmiany wprowadzone tutaj będą widoczne także tam.', + approverNotWorkspaceMember: 'Ten zatwierdzający nie jest już członkiem tego workspace’u. Wybierz nowego zatwierdzającego albo usuń ten workflow.', + defaultWorkflowApproverNotWorkspaceMember: 'Ten zatwierdzający nie jest już członkiem tego workspace’u. Wybierz nowego zatwierdzającego.', + overLimitApproverNotWorkspaceMember: 'Dodatkowy zatwierdzający dla raportów powyżej limitu nie jest już członkiem tego workspace’u. Wybierz nowego dodatkowego zatwierdzającego.', approverCircularReference: (name1: string, name2: string) => `${name1} już zatwierdza raporty dla ${name2}. Wybierz innego zatwierdzającego, aby uniknąć zapętlenia przepływu pracy.`, emptyContent: { @@ -8851,7 +8864,17 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, setupIncomplete: (setupLink: string | undefined) => `Połączono. ${setupLink ? `Zakończ konfigurację` : 'Zakończ konfigurację'}, aby zaimportować pracowników.`, mergeHR: { - groups: {title: 'Grupy', description: 'Wybierz grupy pracowników, które chcesz zsynchronizować z tą przestrzenią roboczą'}, + groups: { + title: 'Grupy', + description: 'Wybierz grupy pracowników, które chcesz zsynchronizować z tą przestrzenią roboczą', + staleSelectionError: (providerName: string) => + `Niektóre z wybranych przez ciebie grup już nie istnieją w ${providerName}, więc ich pracownicy przestali się synchronizować.`, + updateSelectionLink: 'Zaktualizuj swoje grupy.', + noGroupsFound: 'Nie znaleziono grup', + noGroupsFoundDescription: + 'Aktualnie nie ma żadnych grup do wyboru. Zapisz bez zaznaczania grup, aby zsynchronizować wszystkich pracowników, albo ponownie zsynchronizuj połączenie, gdy pojawią się nowe grupy.', + unnamedGroup: (groupID: string) => `Grupa bez nazwy (${groupID})`, + }, }, }, recruiting: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index c8bd2dea74e6..8d305e772888 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -627,6 +627,15 @@ const translations: TranslationDeepObject = { launching: 'Iniciando Expensify', expired: 'Sua sessão expirou.', signIn: 'Faça login novamente.', + notValid: 'Link inválido.', + sessionMismatch: 'O link em que você clicou não é válido para a sua sessão atual.', + switchAccount: { + title: 'Trocar de conta?', + prompt: ({newEmail, currentEmail}: {newEmail: string; currentEmail: string}) => + `Você está conectado como ${currentEmail}. Esta ação fará com que você entre como ${newEmail} em vez disso.`, + confirm: 'Alterar contas', + staySignedIn: 'Não trocar de conta', + }, }, multifactorAuthentication: { reviewTransaction: { @@ -848,7 +857,7 @@ const translations: TranslationDeepObject = { } else if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { type = 'report'; } - return `Tem certeza de que deseja excluir este(a) ${type}?`; + return `Tem certeza de que deseja excluir este(a) ${type}?${action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ? ' Todas as despesas deste relatório passarão a não ser informadas.' : ''}`; }, onlyVisible: 'Visível apenas para', explain: 'Explicar', @@ -1481,6 +1490,7 @@ const translations: TranslationDeepObject = { one: 'Tem certeza de que deseja excluir este relatório?', other: 'Tem certeza de que deseja excluir estes relatórios?', }), + deleteExpenseReportConfirmation: 'Tem certeza de que deseja excluir este relatório? Todas as despesas deste relatório passarão a não ser informadas.', settledExpensify: 'Pago', paidStatusMarkedAsPaid: 'Marcado como pago', paidStatusWithdrawing: 'Sacando', @@ -1622,6 +1632,7 @@ const translations: TranslationDeepObject = { invalidSplitYourself: 'Insira um valor diferente de zero para a sua divisão', noParticipantSelected: 'Selecione um participante', other: 'Erro inesperado. Tente novamente mais tarde.', + payFailedExpenseDeleted: 'O pagamento falhou porque o relatório de despesas foi excluído.', genericCreateFailureMessage: 'Erro inesperado ao enviar esta despesa. Tente novamente mais tarde.', genericCreateInvoiceFailureMessage: 'Erro inesperado ao enviar esta fatura. Tente novamente mais tarde.', genericHoldExpenseFailureMessage: 'Erro inesperado ao reter esta despesa. Tente novamente mais tarde.', @@ -2975,6 +2986,9 @@ ${amount} para ${merchant} - ${date}`, }, }, approverInMultipleWorkflows: 'Este membro já pertence a outro fluxo de aprovação. Qualquer atualização aqui será refletida lá também.', + approverNotWorkspaceMember: 'Esse aprovador não é mais membro do workspace. Escolha um novo aprovador ou exclua este fluxo de trabalho.', + defaultWorkflowApproverNotWorkspaceMember: 'Esse aprovador não é mais membro do workspace. Escolha um novo aprovador.', + overLimitApproverNotWorkspaceMember: 'O aprovador adicional para relatórios acima do limite não é mais membro do workspace. Escolha um novo aprovador adicional.', approverCircularReference: (name1: string, name2: string) => `${name1} já aprova relatórios para ${name2}. Escolha um aprovador diferente para evitar um fluxo de aprovação circular.`, emptyContent: { @@ -8835,7 +8849,17 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, setupIncomplete: (setupLink: string | undefined) => `Conectado. ${setupLink ? `Concluir configuração` : 'Concluir configuração'} para importar funcionários.`, mergeHR: { - groups: {title: 'Grupos', description: 'Escolha os grupos de funcionários que você gostaria de sincronizar com este workspace'}, + groups: { + title: 'Grupos', + description: 'Escolha os grupos de funcionários que você gostaria de sincronizar com este workspace', + staleSelectionError: (providerName: string) => + `Alguns dos grupos que você selecionou não existem mais em ${providerName}, então os funcionários desses grupos pararam de ser sincronizados.`, + updateSelectionLink: 'Atualize seus grupos.', + noGroupsFound: 'Nenhum grupo encontrado', + noGroupsFoundDescription: + 'Não há grupos para selecionar no momento. Salve sem nenhum grupo selecionado para sincronizar todos os funcionários ou sincronize a conexão novamente quando existirem novos grupos.', + unnamedGroup: (groupID: string) => `Grupo sem nome (${groupID})`, + }, }, }, recruiting: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index b673e27085cc..4ee1dd5a0eab 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -625,6 +625,14 @@ const translations: TranslationDeepObject = { launching: '正在启动 Expensify', expired: '您的会话已过期。', signIn: '请重新登录。', + notValid: '链接无效。', + sessionMismatch: '您点击的链接对您当前的会话无效。', + switchAccount: { + title: '切换账户?', + prompt: ({newEmail, currentEmail}: {newEmail: string; currentEmail: string}) => `您当前以 ${currentEmail} 身份登录。此操作将改为以 ${newEmail} 身份登录。`, + confirm: '切换账户', + staySignedIn: '不要切换账户', + }, }, multifactorAuthentication: { reviewTransaction: { @@ -828,7 +836,7 @@ const translations: TranslationDeepObject = { } else if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { type = 'report'; } - return `确定要删除此${type}吗?`; + return `确定要删除此${type}吗?${action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW ? '此报告中的所有费用都将变为未报销状态。' : ''}`; }, onlyVisible: '仅对以下对象可见', explain: '解释', @@ -1426,6 +1434,7 @@ const translations: TranslationDeepObject = { one: '您确定要删除此报告吗?', other: '您确定要删除这些报告吗?', }), + deleteExpenseReportConfirmation: '您确定要删除此报告吗?此报告中的所有费用都将变为未报销状态。', settledExpensify: '已支付', paidStatusMarkedAsPaid: '已标记为已支付', paidStatusWithdrawing: '提现中', @@ -1566,6 +1575,7 @@ const translations: TranslationDeepObject = { invalidSplitYourself: '请输入一个非零金额用于分摊', noParticipantSelected: '请选择参与者', other: '发生意外错误。请稍后重试。', + payFailedExpenseDeleted: '此付款失败,因为报销报告已被删除。', genericCreateFailureMessage: '提交此报销时发生意外错误。请稍后重试。', genericCreateInvoiceFailureMessage: '发送此发票时发生未知错误。请稍后重试。', genericHoldExpenseFailureMessage: '暂时无法搁置此报销。请稍后再试。', @@ -2883,6 +2893,9 @@ ${amount},商户:${merchant} - 日期:${date}`, }, }, approverInMultipleWorkflows: '此成员已属于另一个审批流程。此处的任何更新也会在那边同步生效。', + approverNotWorkspaceMember: '此审批人已不再是工作区成员。请选择新的审批人或删除此工作流程。', + defaultWorkflowApproverNotWorkspaceMember: '此审批人已不再是工作区成员。请选择新的审批人。', + overLimitApproverNotWorkspaceMember: '超出限额报告的额外审批人已不再是工作区成员。请选择新的额外审批人。', approverCircularReference: (name1: string, name2: string) => `${name1} 已经将报表提交给 ${name2} 审批。请选择其他审批人以避免形成循环审批流程。`, emptyContent: { title: '没有可显示的成员', @@ -8522,7 +8535,15 @@ ${reportName}`, setupIncomplete: (setupLink: string | undefined) => `已连接。${setupLink ? `完成设置` : '完成设置'} 用于导入员工。`, mergeHR: { - groups: {title: '群组', description: '选择要与此工作区同步的员工分组'}, + groups: { + title: '群组', + description: '选择要与此工作区同步的员工分组', + staleSelectionError: (providerName: string) => `您选择的部分群组在 ${providerName} 中已不存在,因此其员工已停止同步。`, + updateSelectionLink: '更新你的群组。', + noGroupsFound: '未找到群组', + noGroupsFoundDescription: '目前没有可选择的分组。可在未选择任何分组的情况下保存以同步所有员工,或在新分组创建后再次同步连接。', + unnamedGroup: (groupID: string) => `未命名群组(${groupID})`, + }, }, }, recruiting: { diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index ecd0b6232b10..e3d830442bc5 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -1647,7 +1647,6 @@ const READ_COMMANDS = { OPEN_INITIAL_SETTINGS_PAGE: 'OpenInitialSettingsPage', OPEN_ENABLE_PAYMENTS_PAGE: 'OpenEnablePaymentsPage', BEGIN_SIGNIN: 'BeginSignIn', - SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN: 'SignInWithShortLivedAuthToken', SIGN_IN_WITH_SUPPORT_AUTH_TOKEN: 'SignInWithSupportAuthToken', GET_POLICY_CATEGORIES: 'GetPolicyCategories', OPEN_WORKSPACE: 'OpenWorkspace', @@ -1769,7 +1768,6 @@ type ReadCommandParameters = { [READ_COMMANDS.GET_INSIGHTS]: Parameters.GetInsightsParams; [READ_COMMANDS.GET_TRANSACTIONS_CONVERTED_AMOUNT]: Parameters.GetTransactionsConvertedAmountParams; [READ_COMMANDS.BEGIN_SIGNIN]: Parameters.BeginSignInParams; - [READ_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN]: Parameters.SignInWithShortLivedAuthTokenParams; [READ_COMMANDS.SIGN_IN_WITH_SUPPORT_AUTH_TOKEN]: Parameters.SignInWithSupportAuthTokenParams; [READ_COMMANDS.GET_POLICY_CATEGORIES]: Parameters.GetPolicyCategoriesParams; [READ_COMMANDS.OPEN_WORKSPACE]: Parameters.OpenWorkspaceParams; @@ -1864,6 +1862,7 @@ const SIDE_EFFECT_REQUEST_COMMANDS = { CREATE_DIGITAL_WALLET: 'CreateDigitalWallet', LOCK_ACCOUNT: 'LockAccount', SET_VACATION_DELEGATE: 'SetVacationDelegate', + SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN: 'SignInWithShortLivedAuthToken', CALCULATE_BILL_NEW_DOT: 'CalculateBillNewDot', ACCEPT_SPOTNANA_TERMS: 'AcceptSpotnanaTerms', @@ -1915,6 +1914,7 @@ type SideEffectRequestCommandParameters = { [SIDE_EFFECT_REQUEST_COMMANDS.CREATE_DIGITAL_WALLET]: Parameters.CreateDigitalWalletParams; [SIDE_EFFECT_REQUEST_COMMANDS.LOCK_ACCOUNT]: Parameters.LockAccountParams; [SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE]: Parameters.SetVacationDelegateParams; + [SIDE_EFFECT_REQUEST_COMMANDS.SIGN_IN_WITH_SHORT_LIVED_AUTH_TOKEN]: Parameters.SignInWithShortLivedAuthTokenParams; [SIDE_EFFECT_REQUEST_COMMANDS.CALCULATE_BILL_NEW_DOT]: null; [SIDE_EFFECT_REQUEST_COMMANDS.ACCEPT_SPOTNANA_TERMS]: Parameters.AcceptSpotnanaTermsParams; [SIDE_EFFECT_REQUEST_COMMANDS.GET_SCIM_TOKEN]: Parameters.GetScimTokenParams; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index bf4fea45cc33..02ee61e2650e 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -2437,7 +2437,7 @@ function getValidOptions( } const policy = policiesCollection?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; - if (!doesReportMatchSearchTerms(report, searchTerms)) { + if (!doesReportMatchSearchTerms(report, searchTerms, translate)) { return false; } @@ -2570,7 +2570,7 @@ function getValidOptions( recentReportOptions = recentReportOptions.filter((report) => !report.reportID || !reportIDsToExclude.has(report.reportID)); } } else if (recentAttendees && recentAttendees?.length > 0) { - recentReportOptions = filterReports(recentAttendees as SearchOptionData[], searchTerms) as Array>; + recentReportOptions = filterReports(recentAttendees as SearchOptionData[], searchTerms, translate) as Array>; // Only cap the recent attendees when there's no active search. During a search we surface every match, since // all recent attendees are excluded from "Contacts" and capped-off matches would otherwise vanish from the list. @@ -3067,7 +3067,7 @@ function filteredPersonalDetailsOfRecentReports(rece /** * Filters options based on the search input value */ -function filterReports(reports: SearchOptionData[], searchTerms: string[]): SearchOptionData[] { +function filterReports(reports: SearchOptionData[], searchTerms: string[], translate: LocalizedTranslate): SearchOptionData[] { const normalizedSearchTerms = searchTerms.map((term) => StringUtils.normalizeForMatch(term)); // We search eventually for multiple whitespace separated search terms. // We start with the search term at the end, and then narrow down those filtered search results with the next search term. @@ -3096,6 +3096,12 @@ function filterReports(reports: SearchOptionData[], searchTerms: string[]): Sear } } + // Let the current user find their self DM by the localized "You"/"Me", like their own contact row + if (item.isSelfDM) { + values.push(StringUtils.normalizeForMatch(translate('common.you'))); + values.push(StringUtils.normalizeForMatch(translate('common.me'))); + } + return uniqFast(values); }), // We start from all unfiltered reports: @@ -3110,7 +3116,7 @@ function filterReports(reports: SearchOptionData[], searchTerms: string[]): Sear * canonical matcher handles apostrophes, hyphens, zero-width characters, diacritics, and emails searched without * their dots, and only runs when the cheap check misses. Narrowing the cheap check silently drops matches. */ -function doesReportMatchSearchTerms(report: SearchOption, searchTerms: string[]): boolean { +function doesReportMatchSearchTerms(report: SearchOption, searchTerms: string[], translate: LocalizedTranslate): boolean { const normalizeSearchText = (value: string) => deburr(StringUtils.normalizeForMatch(value).toLocaleLowerCase()); const normalizedSearchTerms = searchTerms.map(normalizeSearchText); let searchText = `${report.text ?? ''}${report.login ?? ''}`; @@ -3124,9 +3130,12 @@ function doesReportMatchSearchTerms(report: SearchOption, searchTerms: s const participantsSearchText = report.participantsList?.map((participant) => [participant.displayName, participant.login].filter(Boolean).join(' ')).join(' ') ?? ''; searchText += participantsSearchText; } + if (report.isSelfDM) { + searchText += `${translate('common.you')} ${translate('common.me')}`; + } searchText = normalizeSearchText(searchText); - return normalizedSearchTerms.every((term) => searchText.includes(term)) || filterReports([report], normalizedSearchTerms).length > 0; + return normalizedSearchTerms.every((term) => searchText.includes(term)) || filterReports([report], normalizedSearchTerms, translate).length > 0; } function filterWorkspaceChats(reports: SearchOptionData[], searchTerms: string[]): SearchOptionData[] { @@ -3211,8 +3220,8 @@ function filterUserToInvite( }); } -function filterSelfDMChat(report: SearchOptionData, searchTerms: string[]): SearchOptionData | undefined { - return filterReports([report], searchTerms).at(0); +function filterSelfDMChat(report: SearchOptionData, searchTerms: string[], translate: LocalizedTranslate): SearchOptionData | undefined { + return filterReports([report], searchTerms, translate).at(0); } function filterOptions( @@ -3234,7 +3243,7 @@ function filterOptions( const searchValue = parsedPhoneNumber.possible && parsedPhoneNumber.number?.e164 ? parsedPhoneNumber.number.e164 : trimmedSearchInput.toLowerCase(); const searchTerms = searchValue ? searchValue.split(' ') : []; - const recentReports = filterReports(options.recentReports, searchTerms); + const recentReports = filterReports(options.recentReports, searchTerms, translate); const personalDetails = filterPersonalDetails(options.personalDetails, searchTerms, currentUserAccountID, translate); const currentUserOption = filterCurrentUserOption(options.currentUserOption, searchTerms, translate); const userToInvite = filterUserToInvite( @@ -3257,7 +3266,7 @@ function filterOptions( ); const workspaceChats = filterWorkspaceChats(options.workspaceChats ?? [], searchTerms); - const selfDMChat = options.selfDMChat ? filterSelfDMChat(options.selfDMChat, searchTerms) : undefined; + const selfDMChat = options.selfDMChat ? filterSelfDMChat(options.selfDMChat, searchTerms, translate) : undefined; return { personalDetails, diff --git a/src/libs/PolicyUtils/index.ts b/src/libs/PolicyUtils/index.ts index f90f8efeebec..db899b438fe0 100644 --- a/src/libs/PolicyUtils/index.ts +++ b/src/libs/PolicyUtils/index.ts @@ -1355,6 +1355,14 @@ function getDefaultApprover(policy: OnyxEntry): string { return policy?.approver || policy?.owner || ''; } +/** + * Whether an approver email points at someone who is no longer on the workspace. The owner always counts as a member, + * and HR advanced (manager) mode is excluded, because its final approver doesn't have to be a workspace member. + */ +function isNonMemberApprover(policy: OnyxEntry, approverEmail: string | undefined): boolean { + return !!approverEmail && !isPolicyMember(policy, approverEmail) && !getHRAdvancedModeFinalApprover(policy); +} + /** * Whether the policy has at least one custom approval workflow. A workflow is considered custom when either: * - the default workflow was modified by changing its first approver or adding an "Approves to" user, or @@ -2525,6 +2533,7 @@ export { getIntegrationLastSuccessfulDate, getCurrentConnectionName, getDefaultApprover, + isNonMemberApprover, hasCustomApprovalWorkflow, getApprovalWorkflow, getReimburserAccountID, diff --git a/src/libs/PolicyUtils/permissions.ts b/src/libs/PolicyUtils/permissions.ts index 5c900439239f..4acbc414ab30 100644 --- a/src/libs/PolicyUtils/permissions.ts +++ b/src/libs/PolicyUtils/permissions.ts @@ -23,6 +23,14 @@ type PolicyFeatureAccess = ValueOf; const isPolicyAdmin = (policy: OnyxInputOrEntry, login?: string, shouldCheckGlobalPolicyRole = true): boolean => getPolicyRole(policy, login, shouldCheckGlobalPolicyRole) === CONST.POLICY.ROLE.ADMIN; +/** + * Checks if the current user is an auditor of the policy. + * + * When `login` belongs to somebody other than the current user, pass `shouldCheckGlobalPolicyRole = false` (see `isPolicyAdmin`). + */ +const isPolicyAuditor = (policy: OnyxInputOrEntry, login?: string, shouldCheckGlobalPolicyRole = true): boolean => + getPolicyRole(policy, login, shouldCheckGlobalPolicyRole) === CONST.POLICY.ROLE.AUDITOR; + /** * Checks if the given account is the owner (creator) of the policy. * @@ -36,8 +44,9 @@ const isPolicyOwner = (policy: OnyxInputOrEntry>, * * Only a member who was invited to the chat can be removed from it. Everybody else is there by virtue of the * workspace configuration, so their membership is governed by that configuration and not by this screen — see the - * expense chat rules in `contributingGuides/philosophies/SECURITY.md`. That covers admins, the policy owner and - * approvers, who are auto-added to the chats of everybody who submits to them. + * expense chat rules in `contributingGuides/philosophies/SECURITY.md`. That covers admins, the policy owner, + * approvers, who are auto-added to the chats of everybody who submits to them, and auditors, who are default members + * of every workspace chat. * * Fails closed on a missing `login`: without one we cannot resolve the member's role, and offering removal for a * member whose role is unknown could remove a workspace admin. Both the member list and the member details page must @@ -56,7 +65,7 @@ const isPolicyOwner = (policy: OnyxInputOrEntry>, * protection, so every caller must state it even when it is `undefined`. */ const isRoomMemberProtectedByPolicyRole = (policy: OnyxInputOrEntry, login: string | undefined, accountID: number | undefined): boolean => - isPolicyOwner(policy, accountID) || !login || isPolicyAdmin(policy, login, false) || isPolicyApprover(policy, login); + isPolicyOwner(policy, accountID) || !login || isPolicyAdmin(policy, login, false) || isPolicyAuditor(policy, login, false) || isPolicyApprover(policy, login); const ALL_POLICY_FEATURES = Object.values(CONST.POLICY.POLICY_FEATURE); @@ -285,12 +294,6 @@ const isPolicyUser = (policy: OnyxInputOrEntry, currentUserLogin?: strin */ const isPolicyGuest = (policy: OnyxInputOrEntry, currentUserLogin?: string): boolean => getPolicyRole(policy, currentUserLogin) === CONST.POLICY.ROLE.GUEST; -/** - * Checks if the current user is an auditor of the policy - */ -const isPolicyAuditor = (policy: OnyxInputOrEntry, currentUserLogin?: string): boolean => - (policy?.role ?? (currentUserLogin && policy?.employeeList?.[currentUserLogin]?.role)) === CONST.POLICY.ROLE.AUDITOR; - /** * Checks if the current user is a workspace or card admin of the policy and the policy has a card product enabled. */ diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 85dae5f5a0bb..5ce38b0095b8 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -1297,6 +1297,27 @@ function isResolvedConciergeDescriptionOptions(reportAction: OnyxEntry): boolean { + return isReportPreviewAction(reportAction) && isDeletedAction(reportAction) && !!reportAction?.errors?.[CONST.IOU.PAY_FAILURE_PREVIEW_ERROR_KEY]; +} + +/** + * The errors a consumer should act on. While the expense report still exists a payment failure belongs on the pay + * action inside it, so drop the copy mirrored onto the chat's preview. + */ +function getVisibleReportActionErrors(reportAction: OnyxEntry): ReportAction['errors'] { + const errors = reportAction?.errors ?? {}; + if (!isReportPreviewAction(reportAction) || isDeletedAction(reportAction) || !(CONST.IOU.PAY_FAILURE_PREVIEW_ERROR_KEY in errors)) { + return errors; + } + const {[CONST.IOU.PAY_FAILURE_PREVIEW_ERROR_KEY]: payFailure, ...rest} = errors; + return rest; +} + /** * Checks if a reportAction is fit for display, meaning that it's not deprecated, is of a valid * and supported type, it's not deleted and also not closed. @@ -1398,6 +1419,10 @@ function shouldReportActionBeVisible( return false; } + if (isDeletedReportPreviewWithError(reportAction)) { + return true; + } + // All other actions are displayed except thread parents, deleted, or non-pending actions return !!reportAction.pendingAction || !isDeletedAction(reportAction) || isDeletedParentAction(reportAction) || isReversedTransaction(reportAction); } @@ -1474,7 +1499,7 @@ function isReportActionVisibleAsLastAction( return false; } - if (Object.keys(reportAction.errors ?? {}).length > 0) { + if (Object.keys(getVisibleReportActionErrors(reportAction) ?? {}).length > 0) { return false; } @@ -5450,6 +5475,8 @@ export { isCurrentActionUnread, isDeletedAction, isDeletedParentAction, + isDeletedReportPreviewWithError, + getVisibleReportActionErrors, isMemberChangeAction, isLeavePolicyAction, isExportIntegrationAction, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index e24b00cada2f..3285bf3a98df 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -211,6 +211,8 @@ import { isCurrentActionUnread, isDeletedAction, isDeletedParentAction, + isDeletedReportPreviewWithError, + getVisibleReportActionErrors, isDynamicExternalWorkflowApproveFailedAction, isDynamicExternalWorkflowSubmitFailedAction, isExportIntegrationAction, @@ -251,6 +253,7 @@ import { getCardName, getCategory, getConvertedAmount, + getConvertedTaxAmount, getCurrency, getDescription, getDisplayTransactionWithoutInvalidCommuterExclusion, @@ -285,10 +288,12 @@ import { hasReceipt as hasReceiptTransactionUtils, hasViolation, hasWarningTypeViolation, + isCorporateCardTransaction, isDeletedTransaction, isDemoTransaction, isDistanceRequest, isExpenseValueUnsettled, + isExpensifyCardTransaction, isFailedScanAmountPlaceholder, isFetchingWaypointsFromServer, isManagedCardTransaction, @@ -840,6 +845,7 @@ type TransactionDetails = { odometerStart?: number; odometerEnd?: number; convertedAmount: number; + convertedTaxAmount?: number; gpsCoordinates?: string; }; @@ -3626,6 +3632,10 @@ function shouldCurrentUserSubmitReport(iouReport: OnyxEntry, chatReport: } function canDeleteCardTransaction(transaction: OnyxEntry, policy: OnyxEntry, cardList: OnyxEntry): boolean { + if (isExpensifyCardTransaction(transaction)) { + return false; + } + const isCardTransaction = isManagedCardTransaction(transaction); if (!isCardTransaction) { return true; @@ -3668,6 +3678,18 @@ function canDeleteMoneyRequestReport( return true; } + const hasExpensifyCardTransaction = reportTransactions.some(isExpensifyCardTransaction); + const hasRestrictedCorporateCardTransaction = reportTransactions.some(isCorporateCardTransaction); + // Expensify Card transactions cannot be deleted or unreported, including by workspace admins. + if (isReportLevelDelete && hasExpensifyCardTransaction) { + return false; + } + + // Admins can delete reports containing third-party card expenses because those expenses become unreported. + if (isReportLevelDelete && !isReportPolicyAdmin && hasRestrictedCorporateCardTransaction) { + return false; + } + const isUnreported = isSelfDM(report) || transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; const canCardTransactionBeDeleted = canDeleteCardTransaction(transaction, policy, cardList); @@ -3676,7 +3698,6 @@ function canDeleteMoneyRequestReport( } // Admins can delete a draft report even when they are not its submitter, but not its individual expenses. - // Card liability does not apply here: deleting a draft report leaves its expenses unreported rather than deleting them. const isDraft = report?.statusNum === CONST.REPORT.STATUS_NUM.OPEN && report?.stateNum === CONST.REPORT.STATE_NUM.OPEN; if (isDraft && isReportPolicyAdmin && isReportLevelDelete) { return true; @@ -5644,6 +5665,7 @@ function getTransactionDetails( originalAmount: getOriginalAmount(transaction), originalCurrency: getOriginalCurrency(transaction), convertedAmount: getConvertedAmount(transaction, isFromExpenseReport, transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID, allowNegativeAmount, disableOppositeConversion), + convertedTaxAmount: getConvertedTaxAmount(transaction, isFromExpenseReport), postedDate: getFormattedPostedDate(transaction), transactionID: transaction.transactionID, ...(isDistanceRequest(transaction) && {distance: transaction.comment?.customUnit?.quantity ?? undefined}), @@ -10775,13 +10797,15 @@ function getAllReportActionsErrorsAndReportActionThatRequiresAttention( isReportArchived = false, reports?: OnyxCollection, ): ReportErrorsAndReportActionThatRequiresAttention { - const reportActionsArray = Object.values(reportActions ?? {}).filter((action) => !isDeletedAction(action)); + // Keep a preview errored because its report was deleted. It still has to mark the chat as needing attention. + const reportActionsArray = Object.values(reportActions ?? {}).filter((action) => !isDeletedAction(action) || isDeletedReportPreviewWithError(action)); const reportActionErrors: ErrorFields = {}; let reportAction: OnyxEntry; for (const action of reportActionsArray) { - if (action && !isEmptyValueObject(action.errors)) { - Object.assign(reportActionErrors, action.errors); + const actionErrors = getVisibleReportActionErrors(action); + if (action && !isEmptyValueObject(actionErrors)) { + Object.assign(reportActionErrors, actionErrors); if (!reportAction) { reportAction = action; diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index 248d29096802..1e2cc2b3eeb4 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -720,8 +720,7 @@ async function updateWorkspaceMembersRole(policy: OnyxEntry, selectedMem if (!policy?.id) { return; } - // Submit workspaces lock everyone to the Editor role — role changes are not allowed. - // This guard prevents any future code path from bypassing the UI-level blocks. + // Submit workspaces lock everyone to the Editor role, so role changes are not allowed. if (isSubmitPolicy(policy)) { return; } @@ -732,9 +731,8 @@ async function updateWorkspaceMembersRole(policy: OnyxEntry, selectedMem employees: JSON.stringify(memberRoles.map((item) => ({email: item.email, role: item.role}))), }; - // The approve-only blocked reasons live only on the response, which API.write does not expose. if (newRole === CONST.POLICY.ROLE.APPROVE_ONLY) { - // eslint-disable-next-line rulesdir/no-api-side-effects-method + // eslint-disable-next-line rulesdir/no-api-side-effects-method -- blocked reasons are only on the response, which API.write does not expose const response = await API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.UPDATE_WORKSPACE_MEMBERS_ROLE, params, {optimisticData, successData}); if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { Onyx.update(failureData); diff --git a/src/pages/SetDefaultWorkspacePage.tsx b/src/pages/SetDefaultWorkspacePage.tsx index 05feaeebc17f..306e58a9e913 100644 --- a/src/pages/SetDefaultWorkspacePage.tsx +++ b/src/pages/SetDefaultWorkspacePage.tsx @@ -16,7 +16,6 @@ import useShouldFooterBeInsideList from '@hooks/useShouldFooterBeInsideList'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceList from '@hooks/useWorkspaceList'; -import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {canRoleCreateExpenses, isGroupPolicy} from '@libs/PolicyUtils'; @@ -56,17 +55,13 @@ function SetDefaultWorkspacePage({route}: SetDefaultWorkspacePageProps) { if (!selectedPolicyID) { return; } - if (!navigateTo) { - Log.hmmm(`[SetDefaultWorkspacePage] navigateTo is undefined. Cannot navigate after setting default workspace to ${selectedPolicyID}`); - return; - } - const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedPolicyID}`]; // eslint-disable-next-line rulesdir/no-default-id-values setNameValuePair(ONYXKEYS.NVP_ACTIVE_POLICY_ID, selectedPolicyID, activePolicyID ?? ''); - if (policy?.areCategoriesEnabled) { + // Without a destination, the caller handles what comes next once it is back in focus. + if (navigateTo && policy?.areCategoriesEnabled) { Navigation.navigate(navigateTo); return; } diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 5007d6d8e833..8ae87a59bb21 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -175,7 +175,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const prevAccountIDs = usePrevious(accountIDs); const invitedEmails = useMemo(() => Object.keys(invitedEmailsToAccountIDsDraft ?? {}), [invitedEmailsToAccountIDsDraft]); - const ownerDetails = personalDetails?.[policy?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? ({} as PersonalDetails); + const ownerDetails = personalDetails?.[policy?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID]; const {approvalWorkflows} = useMemo( () => convertPolicyEmployeesToApprovalWorkflows({ @@ -250,21 +250,19 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const hasApprovers = selectedEmployees.some((email) => isPolicyApprover(policy, email)); if (hasApprovers) { - const ownerEmail = ownerDetails.login; + // Fall back to the employeeList email and policy owner, so submitters are still reassigned when personal + // details aren't loaded. Skipping it would leave them submitting to someone no longer on the workspace. + const ownerEmail = ownerDetails?.login ?? policy?.owner; let currentWorkflows = approvalWorkflows; for (const login of selectedEmployees) { - if (!isPolicyApprover(policy, login)) { + if (!isPolicyApprover(policy, login) || !ownerEmail) { continue; } - const accountID = policyMemberEmailsToAccountIDs[login]; - const removedApprover = personalDetails?.[accountID]; - if (!removedApprover?.login || !ownerEmail) { - continue; - } const updatedWorkflows = updateWorkflowDataOnApproverRemoval({ approvalWorkflows: currentWorkflows, - removedApprover, + removedApproverEmail: login, + ownerEmail, ownerDetails, }); currentWorkflows = updatedWorkflows.filter((workflow) => !workflow.removeApprovalWorkflow); diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index f622b21f261e..eddd6f57f732 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -2051,6 +2051,49 @@ describe('OptionsListUtils', () => { expect(hasMore).toBe(false); }); + + it('should return the self DM when searching "me"', () => { + // Given options with a self DM, whose title and login don't contain "me" + const optionsWithSelfDM = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS_WITH_SELF_DM, + undefined, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ); + + // When we call getValidOptions with includeSelfDM and the localized word the current user uses to refer to themselves, + // without includeCurrentUser, like New chat does + const {options: results} = getValidOptions( + {reports: optionsWithSelfDM.reports, personalDetails: optionsWithSelfDM.personalDetails}, + allPolicies, + {}, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + undefined, + { + getReportByID: getReportByIDFromOnyx, + dateFnsLocale: undefined, + convertToDisplayString, + includeSelfDM: true, + searchString: 'me', + }, + translateLocal, + undefined, + ); + + // Then the self DM should be returned, because it is the only row that represents the current user + expect(results.recentReports.some((option) => option.isSelfDM)).toBe(true); + }); }); describe('getValidOptions() with lazy contact options', () => { @@ -5759,6 +5802,64 @@ describe('OptionsListUtils', () => { expect(filteredOptions.recentReports.at(0)?.isSelfDM).toBe(true); }); + it('should return the self DM when searching "me"', () => { + // Given a set of options with a self DM, whose title and login don't contain "me" + const optionsWithSelfDM = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS_WITH_SELF_DM, + undefined, + EMPTY_PRIVATE_IS_ARCHIVED_MAP, + undefined, + { + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + dateFnsLocale: undefined, + convertToDisplayString, + conciergeReportID: undefined, + isSearching: true, + }, + undefined, + ); + const {options} = getSearchOptions({ + getReportByID: getReportByIDFromOnyx, + rules: undefined, + dateFnsLocale: undefined, + convertToDisplayString, + translate: translateLocal, + options: optionsWithSelfDM, + draftComments: {}, + loginList, + isDefaultRoomsBetaEnabled: true, + policyCollection: allPolicies, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + currentUserEmail: CURRENT_USER_EMAIL, + personalDetails: PERSONAL_DETAILS, + sortedActions: undefined, + conciergeReportID: undefined, + }); + + // When we call filterAndOrderOptions with the localized word the current user uses to refer to themselves + const filteredOptions = filterAndOrderOptions({ + options, + searchInputValue: 'me', + countryCode: COUNTRY_CODE, + loginList, + currentUserEmail: CURRENT_USER_EMAIL, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + personalDetails: PERSONAL_DETAILS, + config: { + dateFnsLocale: undefined, + currentUserAccountID: CURRENT_USER_ACCOUNT_ID, + convertToDisplayString, + }, + translate: translateLocal, + rules: undefined, + activePolicyID, + }); + + // Then the self DM should be returned so the current user can find their own chat + expect(filteredOptions.recentReports.some((option) => option.isSelfDM)).toBe(true); + }); + it('should return the same matches for normalized multi-word queries with extra spaces', () => { const {options} = getSearchOptions({ getReportByID: getReportByIDFromOnyx, @@ -6985,7 +7086,7 @@ describe('OptionsListUtils', () => { it('should return the report when there are no search terms', () => { // Given a report object // When we call filterSelfDMChat with the report and no search terms - const result = filterSelfDMChat(REPORT, []); + const result = filterSelfDMChat(REPORT, [], translateLocal); // Then the returned value should be the same as the input expect(result?.reportID).toEqual(REPORT.reportID); @@ -6994,7 +7095,7 @@ describe('OptionsListUtils', () => { it('should return undefined, when the search term does not match the report', () => { // Given a report object // When we call filterSelfDMChat with the report and a search term that does not match the report - const result = filterSelfDMChat(REPORT, ['XYZ']); + const result = filterSelfDMChat(REPORT, ['XYZ'], translateLocal); // Then the returned value should be undefined expect(result).toBeUndefined(); @@ -7003,7 +7104,7 @@ describe('OptionsListUtils', () => { it('should filter report by text', () => { // Given a report object // When we call filterSelfDMChat with the report and search term that matches the report - const result = filterSelfDMChat(REPORT, ['Google']); + const result = filterSelfDMChat(REPORT, ['Google'], translateLocal); // Then the returned value should be the same as the input expect(result?.reportID).toEqual(REPORT.reportID); @@ -7012,7 +7113,7 @@ describe('OptionsListUtils', () => { it('should filter report by exact text', () => { // Given a report object // When we call filterSelfDMChat with the report and multiple search terms that match the report's exact name - const result = filterSelfDMChat(REPORT, ['Google', 'Workspace']); + const result = filterSelfDMChat(REPORT, ['Google', 'Workspace'], translateLocal); // Then the returned value should be the same as the input expect(result?.reportID).toEqual(REPORT.reportID); @@ -7021,7 +7122,7 @@ describe('OptionsListUtils', () => { it('should filter report by login', () => { // Given a report object // When we call filterSelfDMChat with the report and a search term that matches the report's login - const result = filterSelfDMChat({...REPORT, login: LOGIN}, ['john']); + const result = filterSelfDMChat({...REPORT, login: LOGIN}, ['john'], translateLocal); // Then the returned value should be the same as the input expect(result?.reportID).toEqual(REPORT.reportID); @@ -7030,7 +7131,7 @@ describe('OptionsListUtils', () => { it('should filter report by exact login', () => { // Given a report object // When we call filterSelfDMChat with the report and multiple search terms that match the report's exact login - const result = filterSelfDMChat({...REPORT, login: LOGIN}, [LOGIN]); + const result = filterSelfDMChat({...REPORT, login: LOGIN}, [LOGIN], translateLocal); // Then the returned value should be the same as the input expect(result?.reportID).toEqual(REPORT.reportID); @@ -7039,7 +7140,7 @@ describe('OptionsListUtils', () => { it('should filter report by alternate text', () => { // Given a report object // When we call filterSelfDMChat with the report and a search term that matches the report's alternate text - const result = filterSelfDMChat({...REPORT, alternateText: ALTERNATE_TEXT, isThread: true}, ['William']); + const result = filterSelfDMChat({...REPORT, alternateText: ALTERNATE_TEXT, isThread: true}, ['William'], translateLocal); // Then the returned value should be the same as the input expect(result?.reportID).toEqual(REPORT.reportID); @@ -7048,7 +7149,7 @@ describe('OptionsListUtils', () => { it('should filter report by exact alternate text', () => { // Given a report object that is a thread // When we call filterSelfDMChat with the report and multiple search terms that match the report's exact alternate text - const result = filterSelfDMChat({...REPORT, alternateText: ALTERNATE_TEXT, isThread: true}, ['John', 'William', 'Doe']); + const result = filterSelfDMChat({...REPORT, alternateText: ALTERNATE_TEXT, isThread: true}, ['John', 'William', 'Doe'], translateLocal); // Then the returned value should be the same as the input expect(result?.reportID).toEqual(REPORT.reportID); @@ -7057,7 +7158,7 @@ describe('OptionsListUtils', () => { it('should filter report by alternate text if it is not a thread', () => { // Given a report object that is not a thread // When we call filterSelfDMChat with the report and a search term that matches the report's alternate text - const result = filterSelfDMChat({...REPORT, alternateText: ALTERNATE_TEXT, isThread: false}, ['William']); + const result = filterSelfDMChat({...REPORT, alternateText: ALTERNATE_TEXT, isThread: false}, ['William'], translateLocal); // Then the returned value should be undefined expect(result?.reportID).toBeUndefined(); @@ -7066,7 +7167,7 @@ describe('OptionsListUtils', () => { it('should filter report by subtitle', () => { // Given a report object // When we call filterSelfDMChat with the report and a search term that matches the report's subtitle - const result = filterSelfDMChat({...REPORT, subtitle: SUBTITLE}, ['Software']); + const result = filterSelfDMChat({...REPORT, subtitle: SUBTITLE}, ['Software'], translateLocal); // Then the returned value should be the same as the input expect(result?.reportID).toEqual(REPORT.reportID); @@ -7075,7 +7176,7 @@ describe('OptionsListUtils', () => { it('should filter report by exact subtitle', () => { // Given a report object // When we call filterSelfDMChat with the report and multiple search terms that match the report's exact subtitle - const result = filterSelfDMChat({...REPORT, subtitle: SUBTITLE}, ['Software', 'Engineer']); + const result = filterSelfDMChat({...REPORT, subtitle: SUBTITLE}, ['Software', 'Engineer'], translateLocal); // Then the returned value should be the same as the input expect(result?.reportID).toEqual(REPORT.reportID); @@ -7092,6 +7193,7 @@ describe('OptionsListUtils', () => { isChatRoom: false, }, ['Software'], + translateLocal, ); // Then the returned value should be undefined @@ -7109,11 +7211,34 @@ describe('OptionsListUtils', () => { isChatRoom: true, }, ['Software'], + translateLocal, ); // Then the returned value should be the same as the input expect(result?.reportID).toEqual(REPORT.reportID); }); + + it.each(['me', 'you'])('should filter the self DM by the localized "%s"', (searchTerm) => { + // Given a self DM whose title and login don't contain the search term + const selfDM = {...REPORT, text: 'Test User', login: 'test@test.com', isPolicyExpenseChat: false, isSelfDM: true}; + + // When we call filterSelfDMChat with the localized word the current user uses to refer to themselves + const result = filterSelfDMChat(selfDM, [searchTerm], translateLocal); + + // Then the self DM should be returned so the current user can find their own chat + expect(result?.reportID).toEqual(REPORT.reportID); + }); + + it('should not filter a non-self DM report by "me"', () => { + // Given a report that is not a self DM and whose title and login don't contain "me" + const report = {...REPORT, text: 'Test User', login: 'test@test.com', isPolicyExpenseChat: false}; + + // When we call filterSelfDMChat with "me" + const result = filterSelfDMChat(report, ['me'], translateLocal); + + // Then the report should not be returned, because only the self DM belongs to the current user + expect(result).toBeUndefined(); + }); }); describe('filterReports()', () => { @@ -7125,7 +7250,7 @@ describe('OptionsListUtils', () => { // cspell:disable-next-line const searchTerms = ['Alex Timon Dartagnan Zoe']; // When we call filterReports with the report and search terms - const filteredReports = filterReports(reports, searchTerms); + const filteredReports = filterReports(reports, searchTerms, translateLocal); // Then the returned value should match the search term expect(filteredReports).toEqual(reports); @@ -7152,7 +7277,7 @@ describe('OptionsListUtils', () => { const report: OptionData = {text: reportText, reportID: 'normalized', keyForList: 'normalized'}; // When the report is filtered with the normalized search value - const filteredReports = filterReports([report], [searchText]); + const filteredReports = filterReports([report], [searchText], translateLocal); // Then the report should remain in the results expect(filteredReports).toEqual([report]); @@ -7166,7 +7291,7 @@ describe('OptionsListUtils', () => { keyForList: 'email', }; - const filteredReports = filterReports([report], ['testuser@example.com']); + const filteredReports = filterReports([report], ['testuser@example.com'], translateLocal); expect(filteredReports).toEqual([report]); }); @@ -7179,7 +7304,7 @@ describe('OptionsListUtils', () => { keyForList: 'phone', }; - const filteredReports = filterReports([report], [getSearchValueForPhoneOrEmail('+1 (234) 567-8901', COUNTRY_CODE)]); + const filteredReports = filterReports([report], [getSearchValueForPhoneOrEmail('+1 (234) 567-8901', COUNTRY_CODE)], translateLocal); expect(filteredReports).toEqual([report]); }); @@ -7197,7 +7322,7 @@ describe('OptionsListUtils', () => { it('matches an email query against an email address', () => { // Given a report with a matching email address // When the query is an email search - const doesMatch = doesReportMatchSearchTerms(report, ['person@']); + const doesMatch = doesReportMatchSearchTerms(report, ['person@'], translateLocal); // Then the report matches expect(doesMatch).toBe(true); @@ -7213,7 +7338,7 @@ describe('OptionsListUtils', () => { }; // When the query uses the same accented name in uppercase - const doesMatch = doesReportMatchSearchTerms(groupReport, ['JOSÉ']); + const doesMatch = doesReportMatchSearchTerms(groupReport, ['JOSÉ'], translateLocal); // Then the group report matches expect(doesMatch).toBe(true); @@ -7235,11 +7360,26 @@ describe('OptionsListUtils', () => { }; // When the query uses that participant name - const doesMatch = doesReportMatchSearchTerms(groupReport, [displayName]); + const doesMatch = doesReportMatchSearchTerms(groupReport, [displayName], translateLocal); // Then the group report matches expect(doesMatch).toBe(true); }); + + it.each(['Me', 'You'])('matches the self DM when searching the localized "%s"', (searchTerm) => { + // Given a self DM whose title and login don't contain the search term + const selfDMReport: SearchOption = { + ...report, + isSelfDM: true, + item: {...createRandomReport(1, undefined), chatType: CONST.REPORT.CHAT_TYPE.SELF_DM}, + }; + + // When the query is the localized word the current user uses to refer to themselves + const doesMatch = doesReportMatchSearchTerms(selfDMReport, [searchTerm], translateLocal); + + // Then the self DM matches, so it shows up in pickers like New chat + expect(doesMatch).toBe(true); + }); }); describe('getMostRecentOptions()', () => { diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 1a92b7c66ad5..20224602e4d5 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -100,6 +100,7 @@ import { isMaxExpenseAmountSet, isMemberInHomeAndOfficeWorkspace, isMergeHRCompleteSetupNeededSelector, + isNonMemberApprover, isQBORefreshTokenExpiringSoonSelector, isPerDiemEligiblePolicy, isPerDiemEnabled, @@ -683,6 +684,19 @@ describe('PolicyUtils', () => { expect(isRoomMemberProtectedByPolicyRole(buildPolicy(), 'Member@Test.com', undefined)).toBe(false); }); + it('protects a member who is an auditor of the policy in their own right', () => { + // Given a policy viewed by an admin, holding an auditor in its employee list + const auditorLogin = 'auditor@test.com'; + const policy = buildPolicy(); + const policyWithAuditor = {...policy, employeeList: {...policy.employeeList, [auditorLogin]: {role: CONST.POLICY.ROLE.AUDITOR}}}; + + // When that member's protection is resolved, including through a mixed-case login + // Then they are protected, because auditors are default members of the workspace chat and the server adds + // them back after removal. The viewer's global `role` must not hide the auditor's own role + expect(isRoomMemberProtectedByPolicyRole(policyWithAuditor, auditorLogin, undefined)).toBe(true); + expect(isRoomMemberProtectedByPolicyRole(policyWithAuditor, 'Auditor@Test.com', undefined)).toBe(true); + }); + it('protects the policy owner by accountID even when the employee list does not list them', () => { // Given the policy owner participating in another employee's workspace chat, absent from `employeeList` // When their protection is resolved @@ -2888,6 +2902,32 @@ describe('PolicyUtils', () => { }); }); + describe('isNonMemberApprover', () => { + const ownerEmail = 'owner@example.com'; + const policy = createMock({ + id: '1', + owner: ownerEmail, + employeeList: { + [employeeEmail]: {email: employeeEmail, role: CONST.POLICY.ROLE.USER}, + }, + }); + + it('flags an approver who is no longer on the workspace, but not a member', () => { + // Given a workspace with one member + // When checking a removed approver and that member + // Then only the removed approver is flagged + expect(isNonMemberApprover(policy, 'removed@example.com')).toBe(true); + expect(isNonMemberApprover(policy, employeeEmail)).toBe(false); + }); + + it('does not flag the owner when their employee entry is missing', () => { + // Given a workspace whose employeeList has no entry for the owner + // When checking the owner as an approver + // Then they still count as a member, since the owner always belongs to the workspace + expect(isNonMemberApprover(policy, ownerEmail)).toBe(false); + }); + }); + describe('isPolicyMemberWithoutPendingDelete', () => { it('should return true if the policy member is not pending delete', () => { const policy = createMock({ diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 6e6819628a71..2bb31d5de554 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -61,6 +61,7 @@ import { canBeAutoReimbursed, canCreateRequest, canCreateTaskInReport, + canDeleteCardTransaction, canDeleteMoneyRequestReport, canDeleteReportAction, canDeleteTransaction, @@ -6330,16 +6331,70 @@ describe('ReportUtils', () => { expect(canDeleteMoneyRequestReport(draftReport, [transaction], [iouAction], currentUserAccountID, undefined, memberPolicy, undefined)).toBe(false); }); - it('should allow an admin to delete a draft report holding a card transaction with restricted liability, since the expenses become unreported rather than deleted', () => { - const transaction = { - ...createRandomTransaction(904), + it('should allow an admin to delete a draft report holding cash and third-party card expenses regardless of liability', () => { + const cashTransaction = {...createRandomTransaction(904), reportID: draftReport.reportID, managedCard: false}; + const cardTransaction = { + ...createRandomTransaction(905), reportID: draftReport.reportID, managedCard: true, + bank: 'third-party-bank', comment: {liabilityType: CONST.TRANSACTION.LIABILITY_TYPE.RESTRICT}, }; - const iouAction = buildIOUActionForTransaction(draftReport.reportID, transaction.transactionID, currentUserAccountID); + const cashIOUAction = buildIOUActionForTransaction(draftReport.reportID, cashTransaction.transactionID, 777); + const cardIOUAction = buildIOUActionForTransaction(draftReport.reportID, cardTransaction.transactionID, 777); - expect(canDeleteMoneyRequestReport(draftReport, [transaction], [iouAction], currentUserAccountID, undefined, adminPolicy, undefined, true)).toBe(true); + expect( + canDeleteMoneyRequestReport( + draftReport, + [cashTransaction, cardTransaction], + [cashIOUAction, cardIOUAction], + currentUserAccountID, + undefined, + adminPolicy, + undefined, + true, + ), + ).toBe(true); + }); + + it('should not allow an admin to delete a draft report containing an Expensify Card transaction', () => { + const cashTransaction = {...createRandomTransaction(906), reportID: draftReport.reportID, managedCard: false}; + const expensifyCardTransaction = { + ...createRandomTransaction(907), + reportID: draftReport.reportID, + managedCard: true, + bank: CONST.EXPENSIFY_CARD.BANK, + comment: {liabilityType: CONST.TRANSACTION.LIABILITY_TYPE.RESTRICT}, + }; + const cashIOUAction = buildIOUActionForTransaction(draftReport.reportID, cashTransaction.transactionID, 777); + const cardIOUAction = buildIOUActionForTransaction(draftReport.reportID, expensifyCardTransaction.transactionID, 777); + + expect( + canDeleteMoneyRequestReport( + draftReport, + [cashTransaction, expensifyCardTransaction], + [cashIOUAction, cardIOUAction], + currentUserAccountID, + undefined, + adminPolicy, + undefined, + true, + ), + ).toBe(false); + }); + + it('should not allow a member to delete their own draft report containing an Expensify Card transaction', () => { + const ownDraftReport = {...draftReport, ownerAccountID: currentUserAccountID}; + const expensifyCardTransaction = { + ...createRandomTransaction(908), + reportID: ownDraftReport.reportID, + managedCard: false, + bank: CONST.EXPENSIFY_CARD.BANK, + comment: {liabilityType: CONST.TRANSACTION.LIABILITY_TYPE.RESTRICT}, + }; + const iouAction = buildIOUActionForTransaction(ownDraftReport.reportID, expensifyCardTransaction.transactionID, currentUserAccountID); + + expect(canDeleteMoneyRequestReport(ownDraftReport, [expensifyCardTransaction], [iouAction], currentUserAccountID, undefined, memberPolicy, undefined, true)).toBe(false); }); }); @@ -10251,6 +10306,22 @@ describe('ReportUtils', () => { }); describe('canDeleteReportAction', () => { + it('should not allow an Expensify Card transaction to be deleted when managedCard is missing', () => { + // Given an Expensify Card transaction whose managed-card metadata is unavailable + const transaction = { + ...createRandomTransaction(1000), + managedCard: false, + bank: CONST.EXPENSIFY_CARD.BANK, + comment: {liabilityType: CONST.TRANSACTION.LIABILITY_TYPE.RESTRICT}, + }; + + // When checking whether the transaction can be deleted + const canDeleteTransactionByLiability = canDeleteCardTransaction(transaction, undefined, undefined); + + // Then the bank identifies it as an Expensify Card transaction and deletion stays blocked + expect(canDeleteTransactionByLiability).toBe(false); + }); + it('should return false for delete button visibility if transaction is not allowed to be deleted', async () => { // Given a restricted managed-card expense on an open expense report owned by the current user const expenseReport = { @@ -16442,6 +16513,86 @@ describe('ReportUtils', () => { reportAction: reportActionWithError, }); }); + it('should count an errored deleted report preview so the chat still requires attention', async () => { + // Given a report preview the server marked deleted, still carrying the error left by a payment that was + // rejected because the expense was deleted while it sat in the offline queue + const errorKey = CONST.IOU.PAY_FAILURE_PREVIEW_ERROR_KEY; + const deletedPreviewWithError: ReportAction = { + ...createRandomReportAction(5), + reportID: report.reportID, + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + message: [{html: 'owes $10.00', type: 'COMMENT', text: 'owes $10.00', deleted: '2025-09-30 10:00:00.000'}], + errors: {[errorKey]: 'This payment failed because the expense was deleted.'}, + }; + const reportActionsWithDeletedPreview = { + ...reportActions, + [deletedPreviewWithError.reportActionID]: deletedPreviewWithError, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, { + [deletedPreviewWithError.reportActionID]: deletedPreviewWithError, + }); + await waitForBatchedUpdates(); + + // When the chat's errors are aggregated + const {errors} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActionsWithDeletedPreview, allTransactions, currentUserAccountID); + + // Then the error counts, so the DM gets an RBR pointing the payer at the failure. Deleted actions are + // normally filtered out, which would have hidden the only feedback the payer gets. + expect(errors).toEqual({[errorKey]: 'This payment failed because the expense was deleted.'}); + }); + + it('should not mark the chat as requiring attention for a payment failure on a live preview', async () => { + // Given a payment rejected for some reason other than the expense being deleted, so the preview is intact + const livePreviewWithPayFailure: ReportAction = { + ...createRandomReportAction(7), + reportID: report.reportID, + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + message: [{html: 'owes $10.00', type: 'COMMENT', text: 'owes $10.00'}], + errors: {[CONST.IOU.PAY_FAILURE_PREVIEW_ERROR_KEY]: 'Unexpected error. Please try again later.'}, + }; + const reportActionsWithLivePreview = { + ...reportActions, + [livePreviewWithPayFailure.reportActionID]: livePreviewWithPayFailure, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, { + [livePreviewWithPayFailure.reportActionID]: livePreviewWithPayFailure, + }); + await waitForBatchedUpdates(); + + // When the chat's errors are aggregated + const {errors} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActionsWithLivePreview, allTransactions, currentUserAccountID); + + // Then the chat is not red-dotted, because the error still has a home on the pay action inside the + // expense report the payer can still reach + expect(errors).toEqual({}); + }); + + it('should keep ignoring a deleted action that is not an errored report preview', async () => { + // Given an ordinary deleted comment whose stale error should not nag the user + const errorKey = 1737000000001; + const deletedCommentWithError: ReportAction = { + ...createRandomReportAction(6), + reportID: report.reportID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + message: [{html: '', type: 'COMMENT', text: ''}], + errors: {[errorKey]: 'Stale error'}, + }; + const reportActionsWithDeletedComment = { + ...reportActions, + [deletedCommentWithError.reportActionID]: deletedCommentWithError, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, { + [deletedCommentWithError.reportActionID]: deletedCommentWithError, + }); + await waitForBatchedUpdates(); + + // When the chat's errors are aggregated + const {errors} = getAllReportActionsErrorsAndReportActionThatRequiresAttention(report, reportActionsWithDeletedComment, allTransactions, currentUserAccountID); + + // Then it is still ignored, i.e. the fix did not widen the filter beyond report previews + expect(errors).toEqual({}); + }); + it('should return smart scan error for the top-most parent report with smart scan error', async () => { const transactionID = '12345'; await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportAction1.reportID}`, { From 6dae60d0acee62722b6eac5fc6025bf8351643f4 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:48:38 +0530 Subject: [PATCH 25/56] Grant guests create-expenses permission and revert optimistic role changes on request failure --- src/libs/PolicyUtils/permissions.ts | 1 + src/libs/actions/Policy/Member.ts | 14 ++++++++++---- tests/unit/PolicyUtilsTest.ts | 7 +++---- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/libs/PolicyUtils/permissions.ts b/src/libs/PolicyUtils/permissions.ts index 4acbc414ab30..5685a7d872ea 100644 --- a/src/libs/PolicyUtils/permissions.ts +++ b/src/libs/PolicyUtils/permissions.ts @@ -109,6 +109,7 @@ const ROLE_PERMISSION_BUNDLES: Record, selectedMem }; if (newRole === CONST.POLICY.ROLE.APPROVE_ONLY) { - // eslint-disable-next-line rulesdir/no-api-side-effects-method -- blocked reasons are only on the response, which API.write does not expose - const response = await API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.UPDATE_WORKSPACE_MEMBERS_ROLE, params, {optimisticData, successData}); - if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { + try { + // eslint-disable-next-line rulesdir/no-api-side-effects-method -- blocked reasons are only on the response, which API.write does not expose + const response = await API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.UPDATE_WORKSPACE_MEMBERS_ROLE, params, {optimisticData, successData}); + if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { + Onyx.update(failureData); + } + return response; + } catch (error) { + // Rejected requests leave the optimistic role changes in place, so revert them before surfacing the error. Onyx.update(failureData); + throw error; } - return response; } // eslint-disable-next-line rulesdir/no-multiple-api-calls -- the approve-only branch returns early diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 20224602e4d5..4671ac5f76ed 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -460,12 +460,12 @@ describe('PolicyUtils', () => { } }); - it('allows guests to read only the workspace overview', () => { + it('allows guests to read only the workspace overview but create expenses', () => { const policy = buildPolicy(CONST.POLICY.ROLE.GUEST); for (const feature of Object.values(CONST.POLICY.POLICY_FEATURE)) { - expect(canMemberRead(policy, memberLogin, feature)).toBe(feature === CONST.POLICY.POLICY_FEATURE.OVERVIEW); - expect(canMemberWrite(policy, memberLogin, feature)).toBe(false); + expect(canMemberRead(policy, memberLogin, feature)).toBe(feature === CONST.POLICY.POLICY_FEATURE.OVERVIEW || feature === CONST.POLICY.POLICY_FEATURE.CREATE_EXPENSES); + expect(canMemberWrite(policy, memberLogin, feature)).toBe(feature === CONST.POLICY.POLICY_FEATURE.CREATE_EXPENSES); } }); @@ -506,7 +506,6 @@ describe('PolicyUtils', () => { expect(canRoleCreateExpenses(CONST.POLICY.ROLE.ADMIN)).toBe(true); expect(canRoleCreateExpenses(CONST.POLICY.ROLE.AUDITOR)).toBe(true); expect(canRoleCreateExpenses(CONST.POLICY.ROLE.APPROVE_ONLY)).toBe(false); - expect(canRoleCreateExpenses(CONST.POLICY.ROLE.GUEST)).toBe(false); }); it('allows Guest assignment only on Control workspaces', () => { From 0cfdcff7c69e587446105ecb59ae7a0d14eed802 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:53:15 +0530 Subject: [PATCH 26/56] Revert approve-only role changes through failureData on the side effects request --- src/libs/actions/Policy/Member.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index e4531b140920..6f2f3dab48dd 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -732,18 +732,8 @@ async function updateWorkspaceMembersRole(policy: OnyxEntry, selectedMem }; if (newRole === CONST.POLICY.ROLE.APPROVE_ONLY) { - try { - // eslint-disable-next-line rulesdir/no-api-side-effects-method -- blocked reasons are only on the response, which API.write does not expose - const response = await API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.UPDATE_WORKSPACE_MEMBERS_ROLE, params, {optimisticData, successData}); - if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { - Onyx.update(failureData); - } - return response; - } catch (error) { - // Rejected requests leave the optimistic role changes in place, so revert them before surfacing the error. - Onyx.update(failureData); - throw error; - } + // eslint-disable-next-line rulesdir/no-api-side-effects-method -- blocked reasons are only on the response, which API.write does not expose + return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.UPDATE_WORKSPACE_MEMBERS_ROLE, params, {optimisticData, successData, failureData}); } // eslint-disable-next-line rulesdir/no-multiple-api-calls -- the approve-only branch returns early From 51a4902ba7ea68cb623f7a4f1dd81eeff6407f41 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:21:10 +0530 Subject: [PATCH 27/56] Add approve-only option to the members list role filter --- src/components/Tables/WorkspaceMembersTable/index.tsx | 11 +++++++++++ src/languages/de.ts | 1 + src/languages/el.ts | 1 + src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + 12 files changed, 22 insertions(+) diff --git a/src/components/Tables/WorkspaceMembersTable/index.tsx b/src/components/Tables/WorkspaceMembersTable/index.tsx index 91ecfb28a192..e46769ead4f2 100644 --- a/src/components/Tables/WorkspaceMembersTable/index.tsx +++ b/src/components/Tables/WorkspaceMembersTable/index.tsx @@ -60,6 +60,7 @@ type WorkspaceMembersTableProps = { const WORKSPACE_MEMBER_FILTER_VALUES = { ADMINS: 'admins', + APPROVE_ONLY: 'approveOnly', APPROVERS: 'approvers', AUDITORS: 'auditors', CARD_ADMINS: 'cardAdmins', @@ -215,6 +216,11 @@ export default function WorkspaceMembersTable({ return true; } + const isApproveOnly = item.role === CONST.POLICY.ROLE.APPROVE_ONLY; + if (filterValues.includes(WORKSPACE_MEMBER_FILTER_VALUES.APPROVE_ONLY) && isApproveOnly) { + return true; + } + const isGuest = item.role === CONST.POLICY.ROLE.GUEST; if (filterValues.includes(WORKSPACE_MEMBER_FILTER_VALUES.GUESTS) && isGuest) { return true; @@ -271,6 +277,11 @@ export default function WorkspaceMembersTable({ value: WORKSPACE_MEMBER_FILTER_VALUES.AUDITORS, }); + filterConfig.role.options.push({ + label: translate('workspace.people.approveOnly'), + value: WORKSPACE_MEMBER_FILTER_VALUES.APPROVE_ONLY, + }); + filterConfig.role.options.push({ label: translate('workspace.people.guests'), value: WORKSPACE_MEMBER_FILTER_VALUES.GUESTS, diff --git a/src/languages/de.ts b/src/languages/de.ts index de500a0b3a40..30c88affe61c 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7264,6 +7264,7 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, admins: 'Workspace-Administratoren', approvers: 'Genehmigende', auditors: 'Prüfer', + approveOnly: 'Nur Genehmigung', guests: 'Gäste', editors: 'Bearbeiter', emptyRoleFilter: {title: 'Keine Mitglieder entsprechen diesem Filter', subtitle: 'Laden Sie ein Mitglied ein oder ändern Sie den Filter oben.'}, diff --git a/src/languages/el.ts b/src/languages/el.ts index a90233ce7044..f74b072e6729 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -7382,6 +7382,7 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ paymentsAdmins: 'Διαχειριστές πληρωμών', approvers: 'Εγκρίνοντες', auditors: 'Ελεγκτές', + approveOnly: 'Μόνο έγκριση', guests: 'Επισκέπτες', editors: 'Συντάκτες', members: 'Μέλη', diff --git a/src/languages/en.ts b/src/languages/en.ts index 3edf692b12f9..208fee43f5c2 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7604,6 +7604,7 @@ const translations = { paymentsAdmins: 'Payments Admins', approvers: 'Approvers', auditors: 'Auditors', + approveOnly: 'Approve-only', guests: 'Guests', editors: 'Editors', members: 'Members', diff --git a/src/languages/es.ts b/src/languages/es.ts index c63f7568de5d..73c539d23828 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7109,6 +7109,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, admins: 'Administradores del espacio de trabajo', approvers: 'Aprobadores', auditors: 'Auditores', + approveOnly: 'Solo aprobación', guests: 'Invitados', editors: 'Editores', emptyRoleFilter: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index e53151cd026c..8520fa52f1e7 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7281,6 +7281,7 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, admins: 'Administrateurs de l’espace de travail', approvers: 'Approbateurs', auditors: 'Auditeurs', + approveOnly: 'Approbation uniquement', guests: 'Invités', editors: 'Éditeurs', emptyRoleFilter: {title: 'Aucun membre ne correspond à ce filtre', subtitle: 'Invitez un membre ou modifiez le filtre ci-dessus.'}, diff --git a/src/languages/it.ts b/src/languages/it.ts index 49db8f3d1aa0..420cdec7b5f6 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7221,6 +7221,7 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, admins: 'Amministratori dello spazio di lavoro', approvers: 'Approvatori', auditors: 'Revisori', + approveOnly: 'Solo approvazione', guests: 'Ospiti', editors: 'Editori', emptyRoleFilter: {title: 'Nessun membro corrisponde a questo filtro', subtitle: 'Invita un membro o modifica il filtro qui sopra.'}, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 6620797b718d..11cb079a5377 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7132,6 +7132,7 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から admins: 'ワークスペース管理者', approvers: '承認者', auditors: '監査担当者', + approveOnly: '承認のみ', guests: 'ゲスト', editors: '編集者', emptyRoleFilter: {title: 'このフィルターに一致するメンバーはいません', subtitle: 'メンバーを招待するか、上のフィルターを変更してください。'}, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index e4243b4fe1f0..c63a5212666a 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7205,6 +7205,7 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, admins: 'Workspace-beheerders', approvers: 'Fiatteurs', auditors: 'Accountants', + approveOnly: 'Alleen goedkeuren', guests: 'Gasten', editors: 'Bewerkers', emptyRoleFilter: {title: 'Geen leden komen overeen met dit filter', subtitle: 'Nodig een lid uit of wijzig het filter hierboven.'}, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 18fc17893f69..15daa6ab02cd 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7229,6 +7229,7 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, admins: 'Administratorzy przestrzeni roboczej', approvers: 'Osoby zatwierdzające', auditors: 'Audytorzy', + approveOnly: 'Tylko zatwierdzanie', guests: 'Goście', editors: 'Edytorzy', emptyRoleFilter: {title: 'Żadni członkowie nie pasują do tego filtra', subtitle: 'Zaproś członka lub zmień filtr powyżej.'}, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 0abeec34b855..1796d1cfa317 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7208,6 +7208,7 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, admins: 'Admins do workspace', approvers: 'Aprovadores', auditors: 'Auditores', + approveOnly: 'Somente de aprovação', guests: 'Convidados', editors: 'Editores', emptyRoleFilter: {title: 'Nenhum membro corresponde a este filtro', subtitle: 'Convide um membro ou altere o filtro acima.'}, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index fc1fab62c5d9..8ff73c4dcfb3 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6952,6 +6952,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM admins: '工作区管理员', approvers: '审批人', auditors: '审计员', + approveOnly: '仅审批', guests: '访客', editors: '编辑者', emptyRoleFilter: {title: '没有成员符合此筛选条件', subtitle: '邀请成员或更改上方的筛选条件。'}, From bb1bd7ea6b6aef589db02b73d64a1d9d9158c0f7 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:37:12 +0530 Subject: [PATCH 28/56] Revert optimistic approve-only role on network failure for side-effects request --- src/libs/actions/Policy/Member.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index 429202be0efc..7b57e9396206 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -732,7 +732,13 @@ async function updateWorkspaceMembersRole(policy: OnyxEntry, selectedMem if (newRole === CONST.POLICY.ROLE.APPROVE_ONLY) { // eslint-disable-next-line rulesdir/no-api-side-effects-method -- blocked reasons are only on the response, which API.write does not expose - return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.UPDATE_WORKSPACE_MEMBERS_ROLE, params, {optimisticData, successData, failureData}); + return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.UPDATE_WORKSPACE_MEMBERS_ROLE, params, {optimisticData, successData, failureData}).catch((error: unknown) => { + // The queue's failure rollback only covers WRITE requests, so a network rejection on a side-effects + // request leaves the optimistic role changes in Onyx. Revert them here before the error surfaces. + return Onyx.update(failureData).then(() => { + throw error; + }); + }); } // eslint-disable-next-line rulesdir/no-multiple-api-calls -- the approve-only branch returns early From af88e043ef9be8cc158fa497ca7a518d24b58d7e Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:25:16 +0530 Subject: [PATCH 29/56] Block approve-only role change while offline --- src/pages/workspace/WorkspaceMembersPage.tsx | 25 +++++++++++-------- .../WorkspaceMemberDetailsRolePage.tsx | 21 +++++++++++++++- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 8ae87a59bb21..8015571967a1 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -591,6 +591,16 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const hasSelectedRuleBot = selectedEmployees.some((email) => isRuleBotEnforcingRules(policyMemberEmailsToAccountIDs[email], policy)); + const showRequiresInternetModal = useCallback(() => { + showConfirmModal({ + title: translate('common.youAppearToBeOffline'), + prompt: translate('common.thisFeatureRequiresInternet'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + shouldHandleNavigationBack: true, + }); + }, [showConfirmModal, translate]); + const changeUserRole = (role: ValueOf) => { if (role !== CONST.POLICY.ROLE.ADMIN && hasSelectedRuleBot) { showRuleBotGuardModal('changeRole', policyID); @@ -604,6 +614,11 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers setSelectedEmployees([]); if (role === CONST.POLICY.ROLE.APPROVE_ONLY) { + // Blocked reasons are only on the response, which is not available offline, so the change cannot be evaluated. + if (isOffline) { + showRequiresInternetModal(); + return; + } updateWorkspaceMembersRole(policy, loginsToUpdate, accountIDsToUpdate, role) .then((response) => { const blockedReasons = response?.data?.blockedReasons ?? []; @@ -768,16 +783,6 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers return options; }; - const showRequiresInternetModal = useCallback(() => { - showConfirmModal({ - title: translate('common.youAppearToBeOffline'), - prompt: translate('common.thisFeatureRequiresInternet'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - shouldHandleNavigationBack: true, - }); - }, [showConfirmModal, translate]); - const secondaryActions = useMemo(() => { if (!canWriteMembers) { return []; diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index 841cd808cdce..8ff995ae03b6 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -5,8 +5,10 @@ import WorkspaceMemberRoleList from '@components/WorkspaceMemberRoleList'; import type {ListItemType} from '@components/WorkspaceMemberRoleList'; import useApproveOnlyRoleBlockedModal from '@hooks/useApproveOnlyRoleBlockedModal'; +import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useRedirectSubmitWorkspaceFeatureUpgrade from '@hooks/useRedirectSubmitWorkspaceFeatureUpgrade'; import useRuleBotGuardModal from '@hooks/useRuleBotGuardModal'; @@ -30,7 +32,7 @@ import type {PersonalDetailsList} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import React, {useState} from 'react'; +import React, {useCallback, useState} from 'react'; type WorkspaceMemberDetailsRolePageProps = Omit & PlatformStackScreenProps & { @@ -57,6 +59,18 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp }); const {showApproveOnlyBlockedModal, showRoleUpdateErrorModal} = useApproveOnlyRoleBlockedModal(); + const {isOffline} = useNetwork(); + const {showConfirmModal} = useConfirmModal(); + + const showRequiresInternetModal = useCallback(() => { + showConfirmModal({ + title: translate('common.youAppearToBeOffline'), + prompt: translate('common.thisFeatureRequiresInternet'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + shouldHandleNavigationBack: true, + }); + }, [showConfirmModal, translate]); const saveRole = () => { // The save button stays disabled until a different role is picked, so selectedRole is always set here. @@ -76,6 +90,11 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp return; } if (value === CONST.POLICY.ROLE.APPROVE_ONLY) { + // Blocked reasons are only on the response, which is not available offline, so the change cannot be evaluated. + if (isOffline) { + showRequiresInternetModal(); + return; + } updateWorkspaceMembersRole(policy, [memberLogin], [accountID], value) .then((response) => { const blockedReasons = response?.data?.blockedReasons ?? []; From cdf555cf5dcea0bf91f289448ff5768e41822dba Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:30:57 +0530 Subject: [PATCH 30/56] Fall back to generic error when no blocked reason has client copy --- src/hooks/useApproveOnlyRoleBlockedModal.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/hooks/useApproveOnlyRoleBlockedModal.ts b/src/hooks/useApproveOnlyRoleBlockedModal.ts index b0f338b47bc4..bd2484109e10 100644 --- a/src/hooks/useApproveOnlyRoleBlockedModal.ts +++ b/src/hooks/useApproveOnlyRoleBlockedModal.ts @@ -20,6 +20,10 @@ function useApproveOnlyRoleBlockedModal() { }; // Skip reasons outside the known set instead of showing wrong copy for a value the backend added later. const reasonLines = blockedReasons.filter((reason) => reason in reasonTranslationKeys).map((reason) => `• ${translate(reasonTranslationKeys[reason])}`); + if (reasonLines.length === 0) { + showRoleUpdateErrorModal(); + return; + } showConfirmModal({ title: translate('workspace.people.approveOnlyRoleBlockedTitle'), prompt: [translate('workspace.people.approveOnlyRoleBlockedDescription'), '', ...reasonLines].join('\n'), From e3ed78310d2427561ffffdbad5b4afbc328d5d43 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:36:46 +0530 Subject: [PATCH 31/56] Address review feedback on comments and filter naming --- src/libs/OptionsListUtils/index.ts | 4 ++-- src/libs/OptionsListUtils/types.ts | 4 ++-- src/libs/PolicyUtils/permissions.ts | 9 ++++----- src/pages/SetDefaultWorkspacePage.tsx | 2 +- src/pages/iou/request/ParticipantSearchResults.tsx | 3 ++- src/pages/workspace/WorkspaceMembersPage.tsx | 2 +- .../workspace/members/WorkspaceMemberDetailsRolePage.tsx | 2 +- tests/unit/OptionsListUtilsTest.tsx | 4 ++-- 8 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 02ee61e2650e..032c6f8d64ec 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -1907,7 +1907,7 @@ function isValidReport( isDefaultRoomsBetaEnabled = false, includeMultipleParticipantReports = false, includeOwnedWorkspaceChats = false, - excludeApproveOnlyWorkspaces = false, + excludeWorkspacesWithoutCreateExpensePermission = false, includeThreads = false, includeTasks = false, includeMoneyRequests = false, @@ -1973,7 +1973,7 @@ function isValidReport( } // A workspace chat is not a valid submit destination when the member lacks create-expenses permission. - if (isPolicyExpenseChat && excludeApproveOnlyWorkspaces && !canRoleCreateExpenses(policy?.role)) { + if (isPolicyExpenseChat && excludeWorkspacesWithoutCreateExpensePermission && !canRoleCreateExpenses(policy?.role)) { return false; } diff --git a/src/libs/OptionsListUtils/types.ts b/src/libs/OptionsListUtils/types.ts index 4ece860fa0a0..ebbde53f55bd 100644 --- a/src/libs/OptionsListUtils/types.ts +++ b/src/libs/OptionsListUtils/types.ts @@ -216,7 +216,7 @@ type GetValidReportsConfig = { forcePolicyNamePreview?: boolean; includeSelfDM?: boolean; includeOwnedWorkspaceChats?: boolean; - excludeApproveOnlyWorkspaces?: boolean; + excludeWorkspacesWithoutCreateExpensePermission?: boolean; includeThreads?: boolean; includeTasks?: boolean; includeMoneyRequests?: boolean; @@ -250,7 +250,7 @@ type IsValidReportsConfig = Pick< | 'isDefaultRoomsBetaEnabled' | 'includeMultipleParticipantReports' | 'includeOwnedWorkspaceChats' - | 'excludeApproveOnlyWorkspaces' + | 'excludeWorkspacesWithoutCreateExpensePermission' | 'includeThreads' | 'includeTasks' | 'includeMoneyRequests' diff --git a/src/libs/PolicyUtils/permissions.ts b/src/libs/PolicyUtils/permissions.ts index e4c590454d4c..86393d753862 100644 --- a/src/libs/PolicyUtils/permissions.ts +++ b/src/libs/PolicyUtils/permissions.ts @@ -89,7 +89,7 @@ const EDITOR_POLICY_FEATURES = buildFeatureAccessMap(CONST.POLICY.POLICY_FEATURE const ROLE_PERMISSION_BUNDLES: Record>> = { [CONST.POLICY.ROLE.ADMIN]: WRITE_ALL_POLICY_FEATURES, [CONST.POLICY.ROLE.EDITOR]: EDITOR_POLICY_FEATURES, - // Auditors get read-only access to workspace settings, but they can still create expenses. + // Auditors get read-only access to workspace settings, write access to chat rooms, and can create expenses. [CONST.POLICY.ROLE.AUDITOR]: { ...READ_ALL_POLICY_FEATURES, [CONST.POLICY.POLICY_FEATURE.ROOMS]: CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE, @@ -101,8 +101,7 @@ const ROLE_PERMISSION_BUNDLES: Record, login: string, ro // Reaching here: USER always, plus GUEST/AUDITOR/APPROVE_ONLY only on corporate policies (control-only roles are // already filtered out on non-corporate policies above). Assigning these needs the // MEMBERS permission, and only on corporate policies. APPROVE_ONLY stays within a People Admin's - // authority because it is strictly less permissive than USER. + // authority because it is less permissive than USER. const isNonElevatedRole = role === CONST.POLICY.ROLE.USER || role === CONST.POLICY.ROLE.GUEST || role === CONST.POLICY.ROLE.AUDITOR || role === CONST.POLICY.ROLE.APPROVE_ONLY; return isCorporatePolicy && canMemberWrite(policy, login, CONST.POLICY.POLICY_FEATURE.MEMBERS) && isNonElevatedRole; } @@ -248,7 +247,7 @@ function canRolePay(role: string | undefined): boolean { const PAYER_ROLES = Object.values(CONST.POLICY.ROLE).filter(canRolePay); /** - * Whether a role allows creating expenses on a workspace, derived from the CREATE_EXPENSES permission. + * Whether a role allows creating expenses on a policy, derived from the CREATE_EXPENSES permission. * Every expense-creation entry point gates on this. */ function canRoleCreateExpenses(role: string | undefined): boolean { diff --git a/src/pages/SetDefaultWorkspacePage.tsx b/src/pages/SetDefaultWorkspacePage.tsx index 306e58a9e913..a2df7646e700 100644 --- a/src/pages/SetDefaultWorkspacePage.tsx +++ b/src/pages/SetDefaultWorkspacePage.tsx @@ -78,7 +78,7 @@ function SetDefaultWorkspacePage({route}: SetDefaultWorkspacePageProps) { shouldSortSelectedToTop: false, searchTerm: debouncedSearchTerm, localeCompare, - // A workspace cannot be the default expense destination when the member lacks create-expenses permission. + // A workspace cannot be the default expense destination when the member lacks permission to create expenses. additionalFilter: (newPolicy) => isGroupPolicy(newPolicy) && canRoleCreateExpenses(newPolicy?.role), }); diff --git a/src/pages/iou/request/ParticipantSearchResults.tsx b/src/pages/iou/request/ParticipantSearchResults.tsx index fbb794e11c49..5ea8fbcb589d 100644 --- a/src/pages/iou/request/ParticipantSearchResults.tsx +++ b/src/pages/iou/request/ParticipantSearchResults.tsx @@ -205,7 +205,8 @@ function ParticipantSearchResults({ excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, includeOwnedWorkspaceChats: !shouldExcludeWorkspaces && (iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.CREATE || iouType === CONST.IOU.TYPE.SPLIT || iouType === CONST.IOU.TYPE.TRACK), - excludeApproveOnlyWorkspaces: iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.CREATE || iouType === CONST.IOU.TYPE.SPLIT || iouType === CONST.IOU.TYPE.TRACK, + excludeWorkspacesWithoutCreateExpensePermission: + iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.CREATE || iouType === CONST.IOU.TYPE.SPLIT || iouType === CONST.IOU.TYPE.TRACK, excludeNonAdminWorkspaces: action === CONST.IOU.ACTION.SHARE, includeP2P: !isCategorizeOrShareAction && !isPerDiemRequest && !isTimeRequest && !isTransactionFromCreditCardImport && !shouldExcludeP2P, includeInvoiceRooms: iouType === CONST.IOU.TYPE.INVOICE, diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 46d2c1a67d96..e8d1adabef82 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -614,7 +614,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers setSelectedEmployees([]); if (role === CONST.POLICY.ROLE.APPROVE_ONLY) { - // Blocked reasons are only on the response, which is not available offline, so the change cannot be evaluated. + // Blocked reasons are only returned in the server response, which we cannot receive while offline. if (isOffline) { showRequiresInternetModal(); return; diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index 8ff995ae03b6..abd21c6c9c21 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -90,7 +90,7 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp return; } if (value === CONST.POLICY.ROLE.APPROVE_ONLY) { - // Blocked reasons are only on the response, which is not available offline, so the change cannot be evaluated. + // Blocked reasons are only returned in the server response, which we cannot receive while offline. if (isOffline) { showRequiresInternetModal(); return; diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index eddd6f57f732..a502c30cd3f2 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -1386,7 +1386,7 @@ describe('OptionsListUtils', () => { expect(results.recentReports).toEqual(expect.arrayContaining([expect.objectContaining({login: 'concierge@expensify.com'})])); }); - describe('excludeApproveOnlyWorkspaces', () => { + describe('excludeWorkspacesWithoutCreateExpensePermission', () => { const approveOnlyPolicyID = 'APPROVE_ONLY_POLICY_1'; const approveOnlyChatReportID = '901'; @@ -1455,7 +1455,7 @@ describe('OptionsListUtils', () => { includeRecentReports: true, includeP2P: true, includeOwnedWorkspaceChats: true, - excludeApproveOnlyWorkspaces: true, + excludeWorkspacesWithoutCreateExpensePermission: true, shouldSeparateWorkspaceChat: true, action: CONST.IOU.ACTION.CREATE, personalDetails: PERSONAL_DETAILS, From f0a50df0d3f68b5c159de88fabd75156644292d7 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:00:59 +0530 Subject: [PATCH 32/56] Unify create-expenses permission helper to fail closed on unresolvable role --- src/hooks/usePolicyForMovingExpenses.ts | 4 ++-- src/libs/OptionsListUtils/index.ts | 4 ++-- src/libs/PolicyUtils/index.ts | 9 ++++----- src/libs/PolicyUtils/permissions.ts | 6 +++--- src/libs/ReportUtils.ts | 2 +- src/pages/SetDefaultWorkspacePage.tsx | 4 ++-- .../AttachmentPickerWithMenuItems.tsx | 4 ++-- tests/unit/PolicyUtilsTest.ts | 20 ++++++++++++++++++- tests/unit/ReportSecondaryActionUtilsTest.ts | 1 + 9 files changed, 36 insertions(+), 18 deletions(-) diff --git a/src/hooks/usePolicyForMovingExpenses.ts b/src/hooks/usePolicyForMovingExpenses.ts index 2adf6a7f8cf8..e7a3d3f62bc0 100644 --- a/src/hooks/usePolicyForMovingExpenses.ts +++ b/src/hooks/usePolicyForMovingExpenses.ts @@ -2,7 +2,7 @@ import {useActivePolicyContext} from '@components/ActivePolicyProvider'; import {useSession} from '@components/OnyxListItemProvider'; import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; -import {canRoleCreateExpenses, canSubmitPerDiemExpenseFromWorkspace, isGroupPolicy, isPolicyMemberWithoutPendingDelete, isTimeTrackingEnabled} from '@libs/PolicyUtils'; +import {canCreateExpensesOnPolicy, canSubmitPerDiemExpenseFromWorkspace, isGroupPolicy, isPolicyMemberWithoutPendingDelete, isTimeTrackingEnabled} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -30,7 +30,7 @@ function isPolicyValidForMovingExpenses(policy: OnyxEntry, login: string checkForUserPendingDelete(login, policy) && isPolicyMemberByRole(policy) && // Moving an expense to a workspace creates it there, which requires create-expenses permission. - canRoleCreateExpenses(policy?.role) && + canCreateExpensesOnPolicy(policy, login) && isGroupPolicy(policy) && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && // Teachers Unite doesn't support reimbursement, so it can never be a destination for moving/reporting an expense. diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 032c6f8d64ec..ed8b116005c9 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -22,7 +22,7 @@ import type {OptionData as PersonalDetailOptionData} from '@libs/PersonalDetailO import {getLoginByAccountID, getPersonalDetailForAccountID, getPersonalDetailsForAccountIDs, temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {addSMSDomainIfPhoneNumber, parsePhoneNumber} from '@libs/PhoneNumber'; import { - canRoleCreateExpenses, + canCreateExpensesOnPolicy, canSendInvoiceFromWorkspace, canSubmitPerDiemExpenseFromWorkspace, getCountOfEnabledTagsOfList, @@ -1973,7 +1973,7 @@ function isValidReport( } // A workspace chat is not a valid submit destination when the member lacks create-expenses permission. - if (isPolicyExpenseChat && excludeWorkspacesWithoutCreateExpensePermission && !canRoleCreateExpenses(policy?.role)) { + if (isPolicyExpenseChat && excludeWorkspacesWithoutCreateExpensePermission && !canCreateExpensesOnPolicy(policy)) { return false; } diff --git a/src/libs/PolicyUtils/index.ts b/src/libs/PolicyUtils/index.ts index 2ed85fd47f2c..308e8043281f 100644 --- a/src/libs/PolicyUtils/index.ts +++ b/src/libs/PolicyUtils/index.ts @@ -41,7 +41,7 @@ import type {ValueOf} from 'type-fest'; import {Str} from 'expensify-common'; import {getAccountingConnectionNames, hasAccountingFeatureConnection} from './connections'; -import {canMemberWrite, canRoleCreateExpenses, getPolicyRole, isPolicyAdmin, isPolicyApprover, isPolicyOwner, isPolicyUser} from './permissions'; +import {canCreateExpensesOnPolicy, canMemberWrite, getPolicyRole, isPolicyAdmin, isPolicyApprover, isPolicyOwner, isPolicyUser} from './permissions'; import {canPolicyAccessFeature, isArchivedPolicy, isCollectPolicy, isControlPolicy, isGroupPolicy, isPaidGroupPolicy, isSubmitPolicy} from './policyType'; type MemberEmailsToAccountIDs = Record; @@ -122,9 +122,8 @@ function getActivePoliciesWithExpenseChat(policies: OnyxCollection | nul policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !!policy.name && !!policy.id && - !!getPolicyRole(policy, currentUserLogin) && - // No login means no member to resolve, so the create-expenses gate only applies per member. - (!currentUserLogin || canRoleCreateExpenses(getPolicyRole(policy, currentUserLogin))) && + // Members without create-expenses permission cannot use the workspace as an expense destination. + canCreateExpensesOnPolicy(policy, currentUserLogin) && (isPaidGroupPolicy(policy) || isSubmitPolicy(policy)) && !isArchivedPolicy(policy), ); @@ -1929,7 +1928,7 @@ function getGroupPoliciesWhereReportCanBeCreated(policies: OnyxCollection>): boolean { - return !policy?.role || canRoleCreateExpenses(policy.role); +/** Whether the role resolved for the policy allows creating expenses. With a login, the member role is resolved through employeeList. An unresolvable role blocks, since the permission cannot be confirmed. */ +function canCreateExpensesOnPolicy(policy: OnyxInputOrEntry, currentUserLogin?: string): boolean { + return canRoleCreateExpenses(getPolicyRole(policy, currentUserLogin)); } /** Check if the passed employee is an approver in the policy's employeeList */ diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 1c197b4592c3..f03af7e7030c 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -11414,7 +11414,7 @@ function canRequestMoney(report: OnyxEntry, policy: OnyxEntry, o } // Members without create-expenses permission get no create options in the workspace chats and reports. - if (!!policy?.id && report?.policyID === policy.id && !canRoleCreateExpenses(policy.role)) { + if (!!policy?.id && report?.policyID === policy.id && !canCreateExpensesOnPolicy(policy)) { return false; } diff --git a/src/pages/SetDefaultWorkspacePage.tsx b/src/pages/SetDefaultWorkspacePage.tsx index a2df7646e700..2921d71bea36 100644 --- a/src/pages/SetDefaultWorkspacePage.tsx +++ b/src/pages/SetDefaultWorkspacePage.tsx @@ -18,7 +18,7 @@ import useWorkspaceList from '@hooks/useWorkspaceList'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import {canRoleCreateExpenses, isGroupPolicy} from '@libs/PolicyUtils'; +import {canCreateExpensesOnPolicy, isGroupPolicy} from '@libs/PolicyUtils'; import type {MoneyRequestNavigatorParamList} from '@navigation/types'; @@ -79,7 +79,7 @@ function SetDefaultWorkspacePage({route}: SetDefaultWorkspacePageProps) { searchTerm: debouncedSearchTerm, localeCompare, // A workspace cannot be the default expense destination when the member lacks permission to create expenses. - additionalFilter: (newPolicy) => isGroupPolicy(newPolicy) && canRoleCreateExpenses(newPolicy?.role), + additionalFilter: (newPolicy) => isGroupPolicy(newPolicy) && canCreateExpensesOnPolicy(newPolicy), }); const confirmButtonOptions = { diff --git a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index 16a5c46c7bc5..dd6c316e20f0 100644 --- a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -28,7 +28,7 @@ import getButtonState from '@libs/getButtonState'; import getIconForAction from '@libs/getIconForAction'; import Navigation from '@libs/Navigation/Navigation'; import {getDistanceExpenseTypeForPolicy} from '@libs/PolicyDistanceRatesUtils'; -import {canRoleCreateExpenses, isGroupPolicyByType} from '@libs/PolicyUtils'; +import {canCreateExpensesOnPolicy, isGroupPolicyByType} from '@libs/PolicyUtils'; import { canCreateTaskInReport, getPayeeName, @@ -338,7 +338,7 @@ function AttachmentPickerWithMenuItems({ const createReportOption: PopoverMenuItem[] = useMemo(() => { // Members without create-expenses permission cannot create reports on the workspace either. - if (!isPolicyExpenseChat(report) || !isGroupPolicyByType(policy?.type) || !isReportOwner(report) || !canRoleCreateExpenses(policy?.role)) { + if (!isPolicyExpenseChat(report) || !isGroupPolicyByType(policy?.type) || !isReportOwner(report) || !canCreateExpensesOnPolicy(policy)) { return []; } diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 23f6d2618c77..a7891d45a87a 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -14,6 +14,7 @@ import { canMemberManageMemberWithRole, canMemberRead, canMemberWrite, + canCreateExpensesOnPolicy, canRoleCreateExpenses, canSendInvoiceFromWorkspace, canUnarchivePolicy, @@ -524,6 +525,22 @@ describe('PolicyUtils', () => { expect(canRoleCreateExpenses(CONST.POLICY.ROLE.APPROVE_ONLY)).toBe(false); }); + it('blocks expense creation when the policy role cannot be resolved', () => { + expect(canCreateExpensesOnPolicy({role: undefined} as Policy)).toBe(false); + expect(canCreateExpensesOnPolicy(undefined)).toBe(false); + }); + + it('resolves the member role from employeeList when the policy role is missing', () => { + const legacyPolicy = { + employeeList: { + [memberLogin]: {role: CONST.POLICY.ROLE.USER}, + }, + } as unknown as Policy; + + expect(canCreateExpensesOnPolicy(legacyPolicy)).toBe(false); + expect(canCreateExpensesOnPolicy(legacyPolicy, memberLogin)).toBe(true); + }); + it('blocks Guest assignment even on Control workspaces', () => { const controlPolicy = buildPolicy(CONST.POLICY.ROLE.ADMIN); @@ -955,10 +972,11 @@ describe('PolicyUtils', () => { }); describe('getActivePoliciesWithExpenseChat', () => { it('excludes archived policies while keeping the active sibling', () => { - const activePolicy = createMock({...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), name: 'active', pendingAction: null}); + const activePolicy = createMock({...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), name: 'active', role: CONST.POLICY.ROLE.USER, pendingAction: null}); const archivedPolicy = createMock({ ...createRandomPolicy(2, CONST.POLICY.TYPE.CORPORATE), name: 'archived', + role: CONST.POLICY.ROLE.USER, pendingAction: null, archivedDate: '2024-01-01', }); diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index 16471032dbf1..3852b09ee2e9 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -717,6 +717,7 @@ describe('getSecondaryAction', () => { enabled: true, }, type: CONST.POLICY.TYPE.CORPORATE, + role: CONST.POLICY.ROLE.USER, }); await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); const TRANSACTION_ID = 'TRANSACTION_ID'; From 4a3ee28c75a07b0ab863b137778a4b6e030dea46 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:03:49 +0530 Subject: [PATCH 33/56] Restore guest role UI dropped in the merge --- .../Tables/WorkspaceMembersTable/index.tsx | 11 +++++++++++ src/pages/workspace/WorkspaceMembersPage.tsx | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/components/Tables/WorkspaceMembersTable/index.tsx b/src/components/Tables/WorkspaceMembersTable/index.tsx index 1ca45839b06f..238001051b2c 100644 --- a/src/components/Tables/WorkspaceMembersTable/index.tsx +++ b/src/components/Tables/WorkspaceMembersTable/index.tsx @@ -65,6 +65,7 @@ const WORKSPACE_MEMBER_FILTER_VALUES = { AUDITORS: 'auditors', CARD_ADMINS: 'cardAdmins', EDITORS: 'editors', + GUESTS: 'guests', MEMBERS: 'members', PAYMENTS_ADMINS: 'paymentsAdmins', PEOPLE_ADMINS: 'peopleAdmins', @@ -215,6 +216,11 @@ export default function WorkspaceMembersTable({ return true; } + const isGuest = item.role === CONST.POLICY.ROLE.GUEST; + if (filterValues.includes(WORKSPACE_MEMBER_FILTER_VALUES.GUESTS) && isGuest) { + return true; + } + const isApproveOnly = item.role === CONST.POLICY.ROLE.APPROVE_ONLY; if (filterValues.includes(WORKSPACE_MEMBER_FILTER_VALUES.APPROVE_ONLY) && isApproveOnly) { return true; @@ -271,6 +277,11 @@ export default function WorkspaceMembersTable({ value: WORKSPACE_MEMBER_FILTER_VALUES.AUDITORS, }); + filterConfig.role.options.push({ + label: translate('workspace.people.guests'), + value: WORKSPACE_MEMBER_FILTER_VALUES.GUESTS, + }); + filterConfig.role.options.push({ label: translate('workspace.people.approveOnly'), value: WORKSPACE_MEMBER_FILTER_VALUES.APPROVE_ONLY, diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index e8d1adabef82..b229676c32c3 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -681,6 +681,13 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers shouldSkipFocusRestore: hasSelectedRuleBot, onSelected: () => changeUserRole(CONST.POLICY.ROLE.AUDITOR), }; + const guestOption = { + text: translate('workspace.people.makeGuest', {count: selectedEmployees.length}), + value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_GUEST, + icon: icons.User, + shouldSkipFocusRestore: hasSelectedRuleBot, + onSelected: () => changeUserRole(CONST.POLICY.ROLE.GUEST), + }; const approveOnlyOption = { text: translate('workspace.people.makeApproveOnly', {count: selectedEmployees.length}), value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_APPROVE_ONLY, @@ -711,6 +718,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers }; const hasAtLeastOneNonAuditorRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.AUDITOR); + const hasAtLeastOneNonGuestRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.GUEST); const hasAtLeastOneNonApproveOnlyRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.APPROVE_ONLY); const hasAtLeastOneNonCardAdminRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.CARD_ADMIN); const hasAtLeastOneNonPeopleAdminRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.PEOPLE_ADMIN); @@ -739,6 +747,16 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers options.push(auditorOption); } + if ( + hasAtLeastOneNonGuestRole && + isControlPolicy(policy) && + !hasAtLeastOnePayer && + canManageSelectedEmployees && + canMemberAssignRole(policy, currentUserLogin ?? '', CONST.POLICY.ROLE.GUEST) + ) { + options.push(guestOption); + } + if ( hasAtLeastOneNonApproveOnlyRole && isControlPolicy(policy) && From a4018cd7494735fbaf8e8b78042b24a477f5d79f Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:08:47 +0530 Subject: [PATCH 34/56] Fix missing import, compiler deps, and test cast --- src/libs/ReportUtils.ts | 1 + .../ReportActionCompose/AttachmentPickerWithMenuItems.tsx | 2 +- tests/unit/PolicyUtilsTest.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 373fdd679c03..cf563101c085 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -141,6 +141,7 @@ import {buildPersonalDetailsUpdate, getAccountIDsByLogins, getLoginByAccountID, import { arePaymentsEnabled, canMemberWrite as canMemberWritePolicyUtils, + canCreateExpensesOnPolicy, canRoleCreateExpenses, canSendInvoiceFromWorkspace, getActivePolicies, diff --git a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index dd6c316e20f0..693fb4ebea55 100644 --- a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -351,7 +351,7 @@ function AttachmentPickerWithMenuItems({ onSelected: () => selectOption(() => handleCreateReport(), true), }, ]; - }, [icons.Document, handleCreateReport, policy?.role, policy?.type, report, selectOption, shouldUseNarrowLayout, translate]); + }, [icons.Document, handleCreateReport, policy, report, selectOption, shouldUseNarrowLayout, translate]); /** * Determines if we can show the task option diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index a7891d45a87a..5717e223a233 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -526,7 +526,7 @@ describe('PolicyUtils', () => { }); it('blocks expense creation when the policy role cannot be resolved', () => { - expect(canCreateExpensesOnPolicy({role: undefined} as Policy)).toBe(false); + expect(canCreateExpensesOnPolicy({role: undefined} as unknown as Policy)).toBe(false); expect(canCreateExpensesOnPolicy(undefined)).toBe(false); }); From 9ae3130455384dd94e401693a63f48bed5090dc5 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:09:41 +0530 Subject: [PATCH 35/56] Drop unnecessary useMemo for create report option --- .../AttachmentPickerWithMenuItems.tsx | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index 693fb4ebea55..ccb39cd782d0 100644 --- a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -336,22 +336,19 @@ function AttachmentPickerWithMenuItems({ rules, ]); - const createReportOption: PopoverMenuItem[] = useMemo(() => { - // Members without create-expenses permission cannot create reports on the workspace either. - if (!isPolicyExpenseChat(report) || !isGroupPolicyByType(policy?.type) || !isReportOwner(report) || !canCreateExpensesOnPolicy(policy)) { - return []; - } - - return [ - { - icon: icons.Document, - text: translate('report.newReport.createReport'), - shouldCallAfterModalHide: shouldUseNarrowLayout, - sentryLabel: CONST.SENTRY_LABEL.REPORT.ATTACHMENT_PICKER_MENU_CREATE_REPORT, - onSelected: () => selectOption(() => handleCreateReport(), true), - }, - ]; - }, [icons.Document, handleCreateReport, policy, report, selectOption, shouldUseNarrowLayout, translate]); + // Members without create-expenses permission cannot create reports on the workspace either. + const createReportOption: PopoverMenuItem[] = + !isPolicyExpenseChat(report) || !isGroupPolicyByType(policy?.type) || !isReportOwner(report) || !canCreateExpensesOnPolicy(policy) + ? [] + : [ + { + icon: icons.Document, + text: translate('report.newReport.createReport'), + shouldCallAfterModalHide: shouldUseNarrowLayout, + sentryLabel: CONST.SENTRY_LABEL.REPORT.ATTACHMENT_PICKER_MENU_CREATE_REPORT, + onSelected: () => selectOption(() => handleCreateReport(), true), + }, + ]; /** * Determines if we can show the task option From fef76af9cae8ae07648118bd832840d81ed5cb8c Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:58:57 +0530 Subject: [PATCH 36/56] Show no inline member error for approve-only role change failures --- src/libs/actions/Policy/Member.ts | 3 ++- src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index eafbd22ddff8..0a2483db4599 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -658,7 +658,8 @@ function buildUpdateWorkspaceMembersRoleOnyxData(policy: OnyxEntry, sele member[current.email] = { ...(previousEmployeeList[current.email] ?? {}), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.editor.genericFailureMessage'), + // Failures of the approve-only side-effects request surface through page-level modals, so no inline member error is set. + errors: newRole === CONST.POLICY.ROLE.APPROVE_ONLY ? null : ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.editor.genericFailureMessage'), }; return member; }, {}), diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index abd21c6c9c21..926d16759cc5 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -102,7 +102,7 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp showApproveOnlyBlockedModal(blockedReasons); return; } - // The action already set the failure on the member row, so only keep the user here to see the error modal. + // The action already reverted the optimistic role changes on failure, so only surface the error modal here. if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { showRoleUpdateErrorModal(); return; From a77852ee3803fd68fcab8421474ab9f83e15cf8c Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:20:26 +0530 Subject: [PATCH 37/56] Report role change failures per request transport --- src/libs/actions/Policy/Member.ts | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index 0a2483db4599..9caf080ab663 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -658,8 +658,6 @@ function buildUpdateWorkspaceMembersRoleOnyxData(policy: OnyxEntry, sele member[current.email] = { ...(previousEmployeeList[current.email] ?? {}), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, - // Failures of the approve-only side-effects request surface through page-level modals, so no inline member error is set. - errors: newRole === CONST.POLICY.ROLE.APPROVE_ONLY ? null : ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.editor.genericFailureMessage'), }; return member; }, {}), @@ -734,17 +732,30 @@ async function updateWorkspaceMembersRole(policy: OnyxEntry, selectedMem employees: JSON.stringify(memberRoles.map((item) => ({email: item.email, role: item.role}))), }; + // A write request reports failures through the member rows, while the side-effects request reports them through page-level modals. + const setMemberErrorsOnFailure = (errors: PolicyEmployee['errors']): OnyxUpdate => ({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, + value: { + employeeList: Object.fromEntries(memberRoles.map(({email}) => [email, {errors}])), + }, + }); + if (newRole === CONST.POLICY.ROLE.APPROVE_ONLY) { + const modalFailureData = [...failureData, setMemberErrorsOnFailure(null)]; // eslint-disable-next-line rulesdir/no-api-side-effects-method -- blocked reasons are only on the response, which API.write does not expose - return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.UPDATE_WORKSPACE_MEMBERS_ROLE, params, {optimisticData, successData, failureData}).catch((error: unknown) => { - // The queue's failure rollback only covers WRITE requests, so a network rejection on a side-effects - // request leaves the optimistic role changes in Onyx. Revert them here before the error surfaces. - return Onyx.update(failureData).then(() => { - throw error; - }); - }); + return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.UPDATE_WORKSPACE_MEMBERS_ROLE, params, {optimisticData, successData, failureData: modalFailureData}).catch( + (error: unknown) => { + // The queue's failure rollback only covers WRITE requests, so a network rejection on a side-effects + // request leaves the optimistic role changes in Onyx. Revert them here before the error surfaces. + return Onyx.update(modalFailureData).then(() => { + throw error; + }); + }, + ); } + failureData.push(setMemberErrorsOnFailure(ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.editor.genericFailureMessage'))); // eslint-disable-next-line rulesdir/no-multiple-api-calls -- the approve-only branch returns early API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_MEMBERS_ROLE, params, {optimisticData, successData, failureData}); } From f201b014a35a3c87484fda61bd609332bf65e2d1 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:27:04 +0530 Subject: [PATCH 38/56] Fix lint errors, test mock role, and errors type --- src/hooks/useApproveOnlyRoleBlockedModal.ts | 18 +++++++++--------- src/libs/actions/Policy/Member.ts | 4 ++-- .../components/AddExpenseActionButtonTest.tsx | 3 ++- tests/unit/PolicyUtilsTest.ts | 6 +++--- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/hooks/useApproveOnlyRoleBlockedModal.ts b/src/hooks/useApproveOnlyRoleBlockedModal.ts index bd2484109e10..e2264fadad1c 100644 --- a/src/hooks/useApproveOnlyRoleBlockedModal.ts +++ b/src/hooks/useApproveOnlyRoleBlockedModal.ts @@ -12,6 +12,15 @@ function useApproveOnlyRoleBlockedModal() { const {translate} = useLocalize(); const {showConfirmModal} = useConfirmModal(); + const showRoleUpdateErrorModal = () => { + showConfirmModal({ + title: translate('workspace.people.approveOnlyRoleBlockedTitle'), + prompt: translate('common.genericErrorMessage'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + }; + const showApproveOnlyBlockedModal = (blockedReasons: string[]) => { const reasonTranslationKeys: Record = { [CONST.POLICY.APPROVE_ONLY_BLOCKED_REASONS.HAS_CARD_ON_POLICY]: 'workspace.people.approveOnlyRoleBlockedReasons.hasCardOnPolicy', @@ -32,15 +41,6 @@ function useApproveOnlyRoleBlockedModal() { }); }; - const showRoleUpdateErrorModal = () => { - showConfirmModal({ - title: translate('workspace.people.approveOnlyRoleBlockedTitle'), - prompt: translate('common.genericErrorMessage'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); - }; - return {showApproveOnlyBlockedModal, showRoleUpdateErrorModal}; } diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index 9caf080ab663..d14fbe91c207 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -34,7 +34,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ImportedSpreadsheetMemberData, InvitedEmailsToAccountIDs, Policy, PolicyEmployee, PolicyOwnershipChangeChecks, Report, ReportAction, ReportActions} from '@src/types/onyx'; import type {ImportFinalModal} from '@src/types/onyx/ImportedSpreadsheet'; -import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; import type {JoinWorkspaceResolution} from '@src/types/onyx/OriginalMessage'; import type {ApprovalRule} from '@src/types/onyx/Policy'; import type {NotificationPreference, Participant} from '@src/types/onyx/Report'; @@ -733,7 +733,7 @@ async function updateWorkspaceMembersRole(policy: OnyxEntry, selectedMem }; // A write request reports failures through the member rows, while the side-effects request reports them through page-level modals. - const setMemberErrorsOnFailure = (errors: PolicyEmployee['errors']): OnyxUpdate => ({ + const setMemberErrorsOnFailure = (errors: Errors | null): OnyxUpdate => ({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, value: { diff --git a/tests/ui/components/AddExpenseActionButtonTest.tsx b/tests/ui/components/AddExpenseActionButtonTest.tsx index e4c4eb32ec4e..5b1cba0a9a21 100644 --- a/tests/ui/components/AddExpenseActionButtonTest.tsx +++ b/tests/ui/components/AddExpenseActionButtonTest.tsx @@ -4,12 +4,13 @@ import AddExpenseActionButton from '@components/ReportActionItem/MoneyRequestRep import {getAddExpenseDropdownOptions} from '@userActions/IOU/StartExpenseFlows'; +import CONST from '@src/CONST'; import type {Report} from '@src/types/onyx'; import React from 'react'; const mockIouReport = {reportID: '1001', parentReportID: '3003'} as Report; -const mockPolicy = {id: 'policy1'}; +const mockPolicy = {id: 'policy1', role: CONST.POLICY.ROLE.USER}; // Capture the props AddExpenseActionButton forwards to ButtonWithDropdownMenu. const mockButtonProps: {current: {customText?: string} | undefined} = {current: undefined}; diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 5717e223a233..750d56085fd8 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -526,16 +526,16 @@ describe('PolicyUtils', () => { }); it('blocks expense creation when the policy role cannot be resolved', () => { - expect(canCreateExpensesOnPolicy({role: undefined} as unknown as Policy)).toBe(false); + expect(canCreateExpensesOnPolicy(createMock({role: undefined}))).toBe(false); expect(canCreateExpensesOnPolicy(undefined)).toBe(false); }); it('resolves the member role from employeeList when the policy role is missing', () => { - const legacyPolicy = { + const legacyPolicy = createMock({ employeeList: { [memberLogin]: {role: CONST.POLICY.ROLE.USER}, }, - } as unknown as Policy; + }); expect(canCreateExpensesOnPolicy(legacyPolicy)).toBe(false); expect(canCreateExpensesOnPolicy(legacyPolicy, memberLogin)).toBe(true); From b97f3c5a9f03aaed7d8123bed10dade8ccdbd13d Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:20:36 +0530 Subject: [PATCH 39/56] Deliver approve-only blocked reasons through Onyx and queue role updates via API.write --- src/CONST/index.ts | 1 - src/ONYXKEYS.ts | 4 + src/components/DeferredGlobalModals.tsx | 2 + .../AddExpenseActionButton.tsx | 1 - src/hooks/useApproveOnlyRoleBlockedModal.ts | 93 +++++++++++++------ src/hooks/usePolicyForMovingExpenses.ts | 1 - src/libs/API/types.ts | 2 - src/libs/OptionsListUtils/index.ts | 1 - src/libs/PolicyUtils/index.ts | 2 - src/libs/PolicyUtils/permissions.ts | 6 -- src/libs/actions/Policy/Member.ts | 38 +++----- src/pages/SetDefaultWorkspacePage.tsx | 1 - .../AttachmentPickerWithMenuItems.tsx | 1 - src/pages/workspace/WorkspaceMembersPage.tsx | 45 ++------- .../WorkspaceMemberDetailsRolePage.tsx | 45 +-------- src/types/onyx/BlockedRoleChange.ts | 19 ++++ src/types/onyx/Response.ts | 3 - src/types/onyx/index.ts | 2 + 18 files changed, 115 insertions(+), 152 deletions(-) create mode 100644 src/types/onyx/BlockedRoleChange.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 8377782a02dd..23cee48bc983 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -4672,7 +4672,6 @@ const CONST = { PAYMENTS_ADMIN: 'paymentsAdmin', APPROVE_ONLY: 'approveOnly', }, - // Values returned by UpdateWorkspaceMembersRole in response.data.blockedReasons when it refuses the approve-only role. APPROVE_ONLY_BLOCKED_REASONS: { HAS_CARD_ON_POLICY: 'hasCardOnPolicy', IS_RESTRICTED_BY_DOMAIN_GROUP: 'isRestrictedByDomainGroup', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 67aed9f0ba9c..a68b7148dced 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -157,6 +157,9 @@ const ONYXKEYS = { /** Object containing contact method that's going to be added */ PENDING_CONTACT_ACTION: 'pendingContactAction', + /** Reasons the backend refused an approve-only role change, shown to the requesting client in a modal */ + BLOCKED_ROLE_CHANGE: 'blockedRoleChange', + /** Store the information of validateCode */ VALIDATE_ACTION_CODE: 'validateActionCode', @@ -1889,6 +1892,7 @@ type OnyxValuesMapping = { [ONYXKEYS.RECENTLY_USED_REPORT_FIELDS]: OnyxTypes.RecentlyUsedReportFields; [ONYXKEYS.RAM_ONLY_UPDATE_REQUIRED]: boolean; [ONYXKEYS.SUPPORTAL_PERMISSION_DENIED]: OnyxTypes.SupportalPermissionDenied | null; + [ONYXKEYS.BLOCKED_ROLE_CHANGE]: OnyxTypes.BlockedRoleChange; [ONYXKEYS.RESET_REQUIRED]: boolean; [ONYXKEYS.PLAID_CURRENT_EVENT]: string; [ONYXKEYS.NVP_PRIVATE_TAX_EXEMPT]: boolean; diff --git a/src/components/DeferredGlobalModals.tsx b/src/components/DeferredGlobalModals.tsx index 31eb16153bc3..2b9c578183fd 100644 --- a/src/components/DeferredGlobalModals.tsx +++ b/src/components/DeferredGlobalModals.tsx @@ -1,3 +1,4 @@ +import useApproveOnlyRoleBlockedModal from '@hooks/useApproveOnlyRoleBlockedModal'; import useScreenShareRequestPrompt from '@hooks/useScreenShareRequestPrompt'; import React from 'react'; @@ -14,6 +15,7 @@ const LazyTrialPaymentReminderModalManager = React.lazy(() => import('./TrialPay */ function DeferredGlobalModals() { useScreenShareRequestPrompt(); + useApproveOnlyRoleBlockedModal(); return ( <> diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/AddExpenseActionButton.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/AddExpenseActionButton.tsx index 0a3272f6cef2..d38ed321b7ae 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/AddExpenseActionButton.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/AddExpenseActionButton.tsx @@ -36,7 +36,6 @@ function AddExpenseActionButton() { isDistanceRequest: true, }); - // Members without create-expenses permission get no add-expense entry point on the report preview. if (!canCreateExpensesOnPolicy(policy)) { return null; } diff --git a/src/hooks/useApproveOnlyRoleBlockedModal.ts b/src/hooks/useApproveOnlyRoleBlockedModal.ts index e2264fadad1c..3b2a2ba57746 100644 --- a/src/hooks/useApproveOnlyRoleBlockedModal.ts +++ b/src/hooks/useApproveOnlyRoleBlockedModal.ts @@ -1,27 +1,56 @@ -/** - * Shared modals for approve-only role changes. The backend can refuse the role and list why in - * `response.data.blockedReasons`, so both the single-member page and bulk actions show the same copy. - */ +import {clearBlockedRoleChange, clearUpdateMemberRoleError} from '@userActions/Policy/Member'; + import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import {useEffect, useRef} from 'react'; import useConfirmModal from './useConfirmModal'; import useLocalize from './useLocalize'; +import useOnyx from './useOnyx'; + +const BLOCKED_ROLE_CHANGE_MAX_AGE_MS = 60_000; +const APPROVE_ONLY_ROLE_BLOCKED_MODAL_ID = 'approveOnlyRoleBlocked'; function useApproveOnlyRoleBlockedModal() { const {translate} = useLocalize(); - const {showConfirmModal} = useConfirmModal(); - - const showRoleUpdateErrorModal = () => { - showConfirmModal({ - title: translate('workspace.people.approveOnlyRoleBlockedTitle'), - prompt: translate('common.genericErrorMessage'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); - }; + const {showConfirmModal, closeModalByID} = useConfirmModal(); + const [blockedRoleChange] = useOnyx(ONYXKEYS.BLOCKED_ROLE_CHANGE); + const lastShownCreatedRef = useRef(0); + const isModalOpenRef = useRef(false); + + useEffect(() => { + if (!blockedRoleChange?.created) { + if (isModalOpenRef.current) { + isModalOpenRef.current = false; + closeModalByID(APPROVE_ONLY_ROLE_BLOCKED_MODAL_ID); + } + return; + } + + if (Date.now() - blockedRoleChange.created > BLOCKED_ROLE_CHANGE_MAX_AGE_MS) { + clearBlockedRoleChange(); + return; + } + + if (lastShownCreatedRef.current === blockedRoleChange.created) { + return; + } + lastShownCreatedRef.current = blockedRoleChange.created; + isModalOpenRef.current = true; + + const {policyID, email, blockedReasons = []} = blockedRoleChange; + + // `OnyxUpdates.apply` runs `request.failureData` on the microtask after `response.onyxData`, which sets a + // generic inline error on the member row. Clear that row error once `failureData` has settled so the modal + // is the only feedback shown for a blocked role change. + if (policyID && email) { + Promise.resolve().then(() => { + clearUpdateMemberRoleError(policyID, email); + }); + } - const showApproveOnlyBlockedModal = (blockedReasons: string[]) => { const reasonTranslationKeys: Record = { [CONST.POLICY.APPROVE_ONLY_BLOCKED_REASONS.HAS_CARD_ON_POLICY]: 'workspace.people.approveOnlyRoleBlockedReasons.hasCardOnPolicy', [CONST.POLICY.APPROVE_ONLY_BLOCKED_REASONS.IS_RESTRICTED_BY_DOMAIN_GROUP]: 'workspace.people.approveOnlyRoleBlockedReasons.isRestrictedByDomainGroup', @@ -29,19 +58,29 @@ function useApproveOnlyRoleBlockedModal() { }; // Skip reasons outside the known set instead of showing wrong copy for a value the backend added later. const reasonLines = blockedReasons.filter((reason) => reason in reasonTranslationKeys).map((reason) => `• ${translate(reasonTranslationKeys[reason])}`); - if (reasonLines.length === 0) { - showRoleUpdateErrorModal(); - return; - } - showConfirmModal({ - title: translate('workspace.people.approveOnlyRoleBlockedTitle'), - prompt: [translate('workspace.people.approveOnlyRoleBlockedDescription'), '', ...reasonLines].join('\n'), - confirmText: translate('workspace.people.approveOnlyRoleBlockedConfirm'), - shouldShowCancelButton: false, - }); - }; - return {showApproveOnlyBlockedModal, showRoleUpdateErrorModal}; + const modalPromise = + reasonLines.length === 0 + ? showConfirmModal({ + id: APPROVE_ONLY_ROLE_BLOCKED_MODAL_ID, + title: translate('workspace.people.approveOnlyRoleBlockedTitle'), + prompt: translate('common.genericErrorMessage'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }) + : showConfirmModal({ + id: APPROVE_ONLY_ROLE_BLOCKED_MODAL_ID, + title: translate('workspace.people.approveOnlyRoleBlockedTitle'), + prompt: [translate('workspace.people.approveOnlyRoleBlockedDescription'), '', ...reasonLines].join('\n'), + confirmText: translate('workspace.people.approveOnlyRoleBlockedConfirm'), + shouldShowCancelButton: false, + }); + + modalPromise.then(() => { + isModalOpenRef.current = false; + clearBlockedRoleChange(policyID, email); + }); + }, [blockedRoleChange, closeModalByID, showConfirmModal, translate]); } export default useApproveOnlyRoleBlockedModal; diff --git a/src/hooks/usePolicyForMovingExpenses.ts b/src/hooks/usePolicyForMovingExpenses.ts index e7a3d3f62bc0..40ac73e81285 100644 --- a/src/hooks/usePolicyForMovingExpenses.ts +++ b/src/hooks/usePolicyForMovingExpenses.ts @@ -29,7 +29,6 @@ function isPolicyValidForMovingExpenses(policy: OnyxEntry, login: string return ( checkForUserPendingDelete(login, policy) && isPolicyMemberByRole(policy) && - // Moving an expense to a workspace creates it there, which requires create-expenses permission. canCreateExpensesOnPolicy(policy, login) && isGroupPolicy(policy) && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 312254999e1d..bbf7ebdb96e1 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -1886,7 +1886,6 @@ const SIDE_EFFECT_REQUEST_COMMANDS = { REVEAL_CARD_PIN: 'RevealCardPIN', CHANGE_CARD_PIN: 'ChangeCardPIN', OPEN_SEARCH_TAG_FILTERS_PAGE: 'OpenSearchTagFiltersPage', - UPDATE_WORKSPACE_MEMBERS_ROLE: 'UpdateWorkspaceMembersRole', } as const; type SideEffectRequestCommand = ValueOf; @@ -1938,7 +1937,6 @@ type SideEffectRequestCommandParameters = { [SIDE_EFFECT_REQUEST_COMMANDS.REVEAL_CARD_PIN]: Parameters.RevealCardPINParams; [SIDE_EFFECT_REQUEST_COMMANDS.CHANGE_CARD_PIN]: Parameters.ChangeCardPINParams; [SIDE_EFFECT_REQUEST_COMMANDS.OPEN_SEARCH_TAG_FILTERS_PAGE]: Parameters.OpenSearchTagFiltersPageParams; - [SIDE_EFFECT_REQUEST_COMMANDS.UPDATE_WORKSPACE_MEMBERS_ROLE]: Parameters.UpdateWorkspaceMembersRoleParams; }; type ApiRequestCommandParameters = WriteCommandParameters & ReadCommandParameters & SideEffectRequestCommandParameters; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index ed8b116005c9..f9113cb793e4 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -1972,7 +1972,6 @@ function isValidReport( return false; } - // A workspace chat is not a valid submit destination when the member lacks create-expenses permission. if (isPolicyExpenseChat && excludeWorkspacesWithoutCreateExpensePermission && !canCreateExpensesOnPolicy(policy)) { return false; } diff --git a/src/libs/PolicyUtils/index.ts b/src/libs/PolicyUtils/index.ts index aecd59350dad..f926c43fcd2e 100644 --- a/src/libs/PolicyUtils/index.ts +++ b/src/libs/PolicyUtils/index.ts @@ -117,7 +117,6 @@ function getActivePoliciesWithExpenseChat(policies: OnyxCollection | nul policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !!policy.name && !!policy.id && - // Members without create-expenses permission cannot use the workspace as an expense destination. canCreateExpensesOnPolicy(policy, currentUserLogin) && (isPaidGroupPolicy(policy) || isSubmitPolicy(policy)) && !isArchivedPolicy(policy), @@ -1575,7 +1574,6 @@ function getGroupPoliciesWhereReportCanBeCreated(policies: OnyxCollection, currentUserLogin?: string): boolean { return canRoleCreateExpenses(getPolicyRole(policy, currentUserLogin)); } diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index 8e66b9d36d85..ec285a7f2ddb 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -35,7 +35,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ImportedSpreadsheetMemberData, InvitedEmailsToAccountIDs, Policy, PolicyEmployee, PolicyOwnershipChangeChecks, Report, ReportAction, ReportActions} from '@src/types/onyx'; import type {ImportFinalModal} from '@src/types/onyx/ImportedSpreadsheet'; -import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import type {JoinWorkspaceResolution} from '@src/types/onyx/OriginalMessage'; import type {ApprovalRule} from '@src/types/onyx/Policy'; import type {NotificationPreference, Participant} from '@src/types/onyx/Report'; @@ -659,6 +659,7 @@ function buildUpdateWorkspaceMembersRoleOnyxData(policy: OnyxEntry, sele member[current.email] = { ...(previousEmployeeList[current.email] ?? {}), pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.editor.genericFailureMessage'), }; return member; }, {}), @@ -718,7 +719,7 @@ function buildUpdateWorkspaceMembersRoleOnyxData(policy: OnyxEntry, sele return {optimisticData, successData, failureData, memberRoles}; } -async function updateWorkspaceMembersRole(policy: OnyxEntry, selectedMemberEmails: string[], selectedMemberAccountIDs: number[], newRole: ValueOf) { +function updateWorkspaceMembersRole(policy: OnyxEntry, selectedMemberEmails: string[], selectedMemberAccountIDs: number[], newRole: ValueOf) { if (!policy?.id) { return; } @@ -733,31 +734,6 @@ async function updateWorkspaceMembersRole(policy: OnyxEntry, selectedMem employees: JSON.stringify(memberRoles.map((item) => ({email: item.email, role: item.role}))), }; - // A write request reports failures through the member rows, while the side-effects request reports them through page-level modals. - const setMemberErrorsOnFailure = (errors: Errors | null): OnyxUpdate => ({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, - value: { - employeeList: Object.fromEntries(memberRoles.map(({email}) => [email, {errors}])), - }, - }); - - if (newRole === CONST.POLICY.ROLE.APPROVE_ONLY) { - const modalFailureData = [...failureData, setMemberErrorsOnFailure(null)]; - // eslint-disable-next-line rulesdir/no-api-side-effects-method -- blocked reasons are only on the response, which API.write does not expose - return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.UPDATE_WORKSPACE_MEMBERS_ROLE, params, {optimisticData, successData, failureData: modalFailureData}).catch( - (error: unknown) => { - // The queue's failure rollback only covers WRITE requests, so a network rejection on a side-effects - // request leaves the optimistic role changes in Onyx. Revert them here before the error surfaces. - return Onyx.update(modalFailureData).then(() => { - throw error; - }); - }, - ); - } - - failureData.push(setMemberErrorsOnFailure(ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.editor.genericFailureMessage'))); - // eslint-disable-next-line rulesdir/no-multiple-api-calls -- the approve-only branch returns early API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_MEMBERS_ROLE, params, {optimisticData, successData, failureData}); } @@ -1304,6 +1280,13 @@ function clearUpdateMemberRoleError(policyID: string, login: string) { }); } +function clearBlockedRoleChange(policyID?: string, login?: string) { + Onyx.set(ONYXKEYS.BLOCKED_ROLE_CHANGE, null); + if (policyID && login) { + clearUpdateMemberRoleError(policyID, login); + } +} + function openWorkspaceMembersPage(policyID: string, clientMemberEmails: string[]) { if (!policyID || !clientMemberEmails) { Log.warn('openWorkspaceMembersPage invalid params', {policyID, clientMemberEmails}); @@ -1567,6 +1550,7 @@ export { clearDeleteMemberError, clearAddMemberError, clearUpdateMemberRoleError, + clearBlockedRoleChange, openWorkspaceMembersPage, setWorkspaceInviteMembersDraft, inviteMemberToWorkspace, diff --git a/src/pages/SetDefaultWorkspacePage.tsx b/src/pages/SetDefaultWorkspacePage.tsx index 2921d71bea36..c159ceb640e9 100644 --- a/src/pages/SetDefaultWorkspacePage.tsx +++ b/src/pages/SetDefaultWorkspacePage.tsx @@ -78,7 +78,6 @@ function SetDefaultWorkspacePage({route}: SetDefaultWorkspacePageProps) { shouldSortSelectedToTop: false, searchTerm: debouncedSearchTerm, localeCompare, - // A workspace cannot be the default expense destination when the member lacks permission to create expenses. additionalFilter: (newPolicy) => isGroupPolicy(newPolicy) && canCreateExpensesOnPolicy(newPolicy), }); diff --git a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index ccb39cd782d0..5237380336fb 100644 --- a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -336,7 +336,6 @@ function AttachmentPickerWithMenuItems({ rules, ]); - // Members without create-expenses permission cannot create reports on the workspace either. const createReportOption: PopoverMenuItem[] = !isPolicyExpenseChat(report) || !isGroupPolicyByType(policy?.type) || !isReportOwner(report) || !canCreateExpensesOnPolicy(policy) ? [] diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index b229676c32c3..609c0c19b224 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -12,7 +12,6 @@ import WorkspaceMembersTable from '@components/Tables/WorkspaceMembersTable'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; -import useApproveOnlyRoleBlockedModal from '@hooks/useApproveOnlyRoleBlockedModal'; import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLayoutSpacing from '@hooks/useLayoutSpacing'; @@ -142,7 +141,6 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const [isDownloadFailureModalVisible, setIsDownloadFailureModalVisible] = useState(false); const isOfflineAndNoMemberDataAvailable = isEmptyObject(policy?.employeeList) && isOffline; const {translate, formatPhoneNumber, localeCompare} = useLocalize(); - const {showApproveOnlyBlockedModal, showRoleUpdateErrorModal} = useApproveOnlyRoleBlockedModal(); const {isAccountLocked} = useLockedAccountState(); const {showLockedAccountModal} = useLockedAccountActions(); const [selectedEmployees, setSelectedEmployees] = useState([]); @@ -591,16 +589,6 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const hasSelectedRuleBot = selectedEmployees.some((email) => isRuleBotEnforcingRules(policyMemberEmailsToAccountIDs[email], policy)); - const showRequiresInternetModal = useCallback(() => { - showConfirmModal({ - title: translate('common.youAppearToBeOffline'), - prompt: translate('common.thisFeatureRequiresInternet'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - shouldHandleNavigationBack: true, - }); - }, [showConfirmModal, translate]); - const changeUserRole = (role: ValueOf) => { if (role !== CONST.POLICY.ROLE.ADMIN && hasSelectedRuleBot) { showRuleBotGuardModal('changeRole', policyID); @@ -613,29 +601,6 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers const accountIDsToUpdate = loginsToUpdate.map((login) => policyMemberEmailsToAccountIDs[login]).filter((id) => id !== undefined); setSelectedEmployees([]); - if (role === CONST.POLICY.ROLE.APPROVE_ONLY) { - // Blocked reasons are only returned in the server response, which we cannot receive while offline. - if (isOffline) { - showRequiresInternetModal(); - return; - } - updateWorkspaceMembersRole(policy, loginsToUpdate, accountIDsToUpdate, role) - .then((response) => { - const blockedReasons = response?.data?.blockedReasons ?? []; - if (blockedReasons.length > 0) { - showApproveOnlyBlockedModal(blockedReasons); - return; - } - // The action already reverted the optimistic role changes on failure, so only surface the error modal here. - if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { - showRoleUpdateErrorModal(); - } - }) - .catch(() => { - showRoleUpdateErrorModal(); - }); - return; - } updateWorkspaceMembersRole(policy, loginsToUpdate, accountIDsToUpdate, role); }; @@ -783,6 +748,16 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers return options; }; + const showRequiresInternetModal = useCallback(() => { + showConfirmModal({ + title: translate('common.youAppearToBeOffline'), + prompt: translate('common.thisFeatureRequiresInternet'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + shouldHandleNavigationBack: true, + }); + }, [showConfirmModal, translate]); + const secondaryActions = useMemo(() => { if (!canWriteMembers) { return []; diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index 926d16759cc5..4468f5bdd0bb 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -4,11 +4,8 @@ import ScreenWrapper from '@components/ScreenWrapper'; import WorkspaceMemberRoleList from '@components/WorkspaceMemberRoleList'; import type {ListItemType} from '@components/WorkspaceMemberRoleList'; -import useApproveOnlyRoleBlockedModal from '@hooks/useApproveOnlyRoleBlockedModal'; -import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useRedirectSubmitWorkspaceFeatureUpgrade from '@hooks/useRedirectSubmitWorkspaceFeatureUpgrade'; import useRuleBotGuardModal from '@hooks/useRuleBotGuardModal'; @@ -32,7 +29,7 @@ import type {PersonalDetailsList} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import React, {useCallback, useState} from 'react'; +import React, {useState} from 'react'; type WorkspaceMemberDetailsRolePageProps = Omit & PlatformStackScreenProps & { @@ -58,22 +55,7 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp upgradeFeatureAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.roles.alias, }); - const {showApproveOnlyBlockedModal, showRoleUpdateErrorModal} = useApproveOnlyRoleBlockedModal(); - const {isOffline} = useNetwork(); - const {showConfirmModal} = useConfirmModal(); - - const showRequiresInternetModal = useCallback(() => { - showConfirmModal({ - title: translate('common.youAppearToBeOffline'), - prompt: translate('common.thisFeatureRequiresInternet'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - shouldHandleNavigationBack: true, - }); - }, [showConfirmModal, translate]); - const saveRole = () => { - // The save button stays disabled until a different role is picked, so selectedRole is always set here. const value = selectedRole; if (!value || value === member?.role) { return; @@ -89,31 +71,6 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp showRuleBotGuardModal('changeRole', policyID); return; } - if (value === CONST.POLICY.ROLE.APPROVE_ONLY) { - // Blocked reasons are only returned in the server response, which we cannot receive while offline. - if (isOffline) { - showRequiresInternetModal(); - return; - } - updateWorkspaceMembersRole(policy, [memberLogin], [accountID], value) - .then((response) => { - const blockedReasons = response?.data?.blockedReasons ?? []; - if (blockedReasons.length > 0) { - showApproveOnlyBlockedModal(blockedReasons); - return; - } - // The action already reverted the optimistic role changes on failure, so only surface the error modal here. - if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { - showRoleUpdateErrorModal(); - return; - } - Navigation.goBack(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); - }) - .catch(() => { - showRoleUpdateErrorModal(); - }); - return; - } updateWorkspaceMembersRole(policy, [memberLogin], [accountID], value); Navigation.goBack(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); }; diff --git a/src/types/onyx/BlockedRoleChange.ts b/src/types/onyx/BlockedRoleChange.ts new file mode 100644 index 000000000000..f6e708e0deb4 --- /dev/null +++ b/src/types/onyx/BlockedRoleChange.ts @@ -0,0 +1,19 @@ +/** Model of a refused approve-only role change, delivered to the requesting client to show in a modal */ +type BlockedRoleChange = { + /** Workspace the refused role change targeted */ + policyID: string; + + /** Member whose role change was refused */ + email: string; + + /** Reason keys from Auth explaining why the role was refused */ + blockedReasons: string[]; + + /** Time in milliseconds when the refusal was generated */ + created: number; + + /** Server error message */ + message?: string; +}; + +export default BlockedRoleChange; diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts index 90306fe1268c..58f469b68d82 100644 --- a/src/types/onyx/Response.ts +++ b/src/types/onyx/Response.ts @@ -15,9 +15,6 @@ type Data = { /** Workspaces the vacation delegate is missing from, returned with jsonCode 305 by SetVacationDelegate */ policyDiff?: VacationDelegatePolicyDiff; - - /** Reasons the backend refused the approve-only role, returned by UpdateWorkspaceMembersRole */ - blockedReasons?: string[]; }; /** Model of server response */ diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 373c346a3087..4e0c56b5a1e6 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -23,6 +23,7 @@ import type BillingGraceEndPeriod from './BillingGraceEndPeriod'; import type BillingReceiptDetails from './BillingReceiptDetails'; import type BillingStatus from './BillingStatus'; import type BlockedFromConcierge from './BlockedFromConcierge'; +import type BlockedRoleChange from './BlockedRoleChange'; import type CancellationDetails from './CancellationDetails'; import type Card from './Card'; import type {CardList, IssueNewCard, ProvisioningCardData, WorkspaceCardsList} from './Card'; @@ -267,6 +268,7 @@ export type { BetaConfiguration, BetaOverrides, BlockedFromConcierge, + BlockedRoleChange, Card, CardList, CardOnWaitlist, From 98c7d4a7e5005a78500be83890788b649a939c31 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:38:36 +0530 Subject: [PATCH 40/56] Remove outdated blocked reasons comment --- src/hooks/useApproveOnlyRoleBlockedModal.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hooks/useApproveOnlyRoleBlockedModal.ts b/src/hooks/useApproveOnlyRoleBlockedModal.ts index 3b2a2ba57746..620f8e23fb94 100644 --- a/src/hooks/useApproveOnlyRoleBlockedModal.ts +++ b/src/hooks/useApproveOnlyRoleBlockedModal.ts @@ -56,7 +56,6 @@ function useApproveOnlyRoleBlockedModal() { [CONST.POLICY.APPROVE_ONLY_BLOCKED_REASONS.IS_RESTRICTED_BY_DOMAIN_GROUP]: 'workspace.people.approveOnlyRoleBlockedReasons.isRestrictedByDomainGroup', [CONST.POLICY.APPROVE_ONLY_BLOCKED_REASONS.IS_DEFAULT_POLICY]: 'workspace.people.approveOnlyRoleBlockedReasons.isDefaultPolicy', }; - // Skip reasons outside the known set instead of showing wrong copy for a value the backend added later. const reasonLines = blockedReasons.filter((reason) => reason in reasonTranslationKeys).map((reason) => `• ${translate(reasonTranslationKeys[reason])}`); const modalPromise = From 6e60cd3b96851ad4f2ab6ddbd991901b9901009f Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:31:20 +0530 Subject: [PATCH 41/56] Gate report preview and move-to-report on create-expenses permission and clean up role helpers --- .../EditableCell/usePopoverEditState.ts | 1 - .../AddExpenseActionButton.tsx | 5 ---- src/hooks/useApproveOnlyRoleBlockedModal.ts | 28 ++++++------------- src/hooks/usePolicyForMovingExpenses.ts | 8 +----- src/languages/en.ts | 2 +- src/libs/ReportPreviewActionUtils.ts | 3 +- src/libs/actions/Policy/Member.ts | 5 +--- .../iou/request/ParticipantSearchResults.tsx | 7 ++--- .../step/IOURequestEditReportCommon.tsx | 7 ++++- .../WorkspaceMemberDetailsRolePage.tsx | 11 ++++---- .../components/AddExpenseActionButtonTest.tsx | 3 +- tests/utils/collections/policies.ts | 2 +- 12 files changed, 30 insertions(+), 52 deletions(-) diff --git a/src/components/EditableCell/usePopoverEditState.ts b/src/components/EditableCell/usePopoverEditState.ts index 3c27c28ee07f..3d5d5e433703 100644 --- a/src/components/EditableCell/usePopoverEditState.ts +++ b/src/components/EditableCell/usePopoverEditState.ts @@ -139,7 +139,6 @@ type UsePopoverEditStateOptionsGeneric = { * - Value comparison to prevent no-op saves */ function usePopoverEditState(options: UsePopoverEditStateOptionsGeneric) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the impl is non-generic for React Compiler hoisting return usePopoverEditStateImpl(options as UsePopoverEditStateOptions) as ReturnType & { handleSave: (newValue: T) => void; }; diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/AddExpenseActionButton.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/AddExpenseActionButton.tsx index d38ed321b7ae..fdb0981e9f65 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/AddExpenseActionButton.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/AddExpenseActionButton.tsx @@ -7,7 +7,6 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {getDistanceExpenseTypeForPolicy} from '@libs/PolicyDistanceRatesUtils'; -import {canCreateExpensesOnPolicy} from '@libs/PolicyUtils'; import {getAddExpenseDropdownOptions} from '@userActions/IOU/StartExpenseFlows'; @@ -36,10 +35,6 @@ function AddExpenseActionButton() { isDistanceRequest: true, }); - if (!canCreateExpensesOnPolicy(policy)) { - return null; - } - return ( reason in reasonTranslationKeys).map((reason) => `• ${translate(reasonTranslationKeys[reason])}`); + const hasReasonLines = reasonLines.length > 0; - const modalPromise = - reasonLines.length === 0 - ? showConfirmModal({ - id: APPROVE_ONLY_ROLE_BLOCKED_MODAL_ID, - title: translate('workspace.people.approveOnlyRoleBlockedTitle'), - prompt: translate('common.genericErrorMessage'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }) - : showConfirmModal({ - id: APPROVE_ONLY_ROLE_BLOCKED_MODAL_ID, - title: translate('workspace.people.approveOnlyRoleBlockedTitle'), - prompt: [translate('workspace.people.approveOnlyRoleBlockedDescription'), '', ...reasonLines].join('\n'), - confirmText: translate('workspace.people.approveOnlyRoleBlockedConfirm'), - shouldShowCancelButton: false, - }); - - modalPromise.then(() => { + showConfirmModal({ + id: APPROVE_ONLY_ROLE_BLOCKED_MODAL_ID, + title: translate('workspace.people.approveOnlyRoleBlockedTitle'), + prompt: hasReasonLines ? [translate('workspace.people.approveOnlyRoleBlockedDescription'), '', ...reasonLines].join('\n') : translate('common.genericErrorMessage'), + confirmText: translate(hasReasonLines ? 'workspace.people.approveOnlyRoleBlockedConfirm' : 'common.buttonConfirm'), + shouldShowCancelButton: false, + }).then(() => { isModalOpenRef.current = false; - clearBlockedRoleChange(policyID, email); + clearBlockedRoleChange(); }); }, [blockedRoleChange, closeModalByID, showConfirmModal, translate]); } diff --git a/src/hooks/usePolicyForMovingExpenses.ts b/src/hooks/usePolicyForMovingExpenses.ts index 33480909caca..6087db6e014d 100644 --- a/src/hooks/usePolicyForMovingExpenses.ts +++ b/src/hooks/usePolicyForMovingExpenses.ts @@ -2,7 +2,7 @@ import {useActivePolicyContext} from '@components/ActivePolicyProvider'; import {useSession} from '@components/OnyxListItemProvider'; import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; -import {canCreateExpensesOnPolicy, canSubmitPerDiemExpenseFromWorkspace, getPolicyRole, isGroupPolicy, isPolicyMemberWithoutPendingDelete, isTimeTrackingEnabled} from '@libs/PolicyUtils'; +import {canCreateExpensesOnPolicy, canSubmitPerDiemExpenseFromWorkspace, isGroupPolicy, isPolicyMemberWithoutPendingDelete, isTimeTrackingEnabled} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -22,15 +22,9 @@ function checkForUserPendingDelete(login: string, policy: OnyxEntry) { return isPolicyMemberWithoutPendingDelete(login, policy); } -function isPolicyMemberByRole(policy: OnyxEntry, login: string) { - const role = getPolicyRole(policy, login); - return !!role && (Object.values(CONST.POLICY.ROLE) as string[]).includes(role); -} - function isPolicyValidForMovingExpenses(policy: OnyxEntry, login: string, isPerDiemRequest?: boolean, isTimeRequest?: boolean) { return ( checkForUserPendingDelete(login, policy) && - isPolicyMemberByRole(policy, login) && canCreateExpensesOnPolicy(policy, login) && isGroupPolicy(policy) && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && diff --git a/src/languages/en.ts b/src/languages/en.ts index c3cece8a6c82..7b5cc9d95a21 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -10755,7 +10755,7 @@ const translations = { `the Plaid connection to your business bank account is broken. Please reconnect your bank account ${maskedAccountNumber} so you can continue to use your Expensify Cards.`, addEmployee: (email: string, role: string, didJoinPolicy?: boolean) => { const translatedRole = String(translations.workspace.common.roleName(role)).toLowerCase(); - const article = role === CONST.POLICY.ROLE.AUDITOR ? 'an' : 'a'; + const article = role === CONST.POLICY.ROLE.AUDITOR || role === CONST.POLICY.ROLE.APPROVE_ONLY ? 'an' : 'a'; return didJoinPolicy ? `${email} joined via the workspace invite link` : `added ${email} as ${article} ${translatedRole}`; }, updateRole: (email: string, currentRole: string, newRole: string) => `updated the role of ${email} to ${newRole} (previously ${currentRole})`, diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index 9a9565440887..1adbb1b7f033 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -6,6 +6,7 @@ import type {ValueOf} from 'type-fest'; import { arePaymentsEnabled, + canCreateExpensesOnPolicy, canMemberWrite, getReimbursementChoice, getSubmitToAccountID, @@ -277,7 +278,7 @@ function getReportPreviewAction({ if (isSubmittingAnimationRunning) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.SUBMIT; } - if (isAddExpenseAction(report, transactions ?? [], isReportArchived, rules)) { + if (isAddExpenseAction(report, transactions ?? [], isReportArchived, rules) && canCreateExpensesOnPolicy(policy)) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.ADD_EXPENSE; } diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index ec285a7f2ddb..e5d1d3d9f7df 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -1280,11 +1280,8 @@ function clearUpdateMemberRoleError(policyID: string, login: string) { }); } -function clearBlockedRoleChange(policyID?: string, login?: string) { +function clearBlockedRoleChange() { Onyx.set(ONYXKEYS.BLOCKED_ROLE_CHANGE, null); - if (policyID && login) { - clearUpdateMemberRoleError(policyID, login); - } } function openWorkspaceMembersPage(policyID: string, clientMemberEmails: string[]) { diff --git a/src/pages/iou/request/ParticipantSearchResults.tsx b/src/pages/iou/request/ParticipantSearchResults.tsx index 5b51a302f105..0b1ba9231e6e 100644 --- a/src/pages/iou/request/ParticipantSearchResults.tsx +++ b/src/pages/iou/request/ParticipantSearchResults.tsx @@ -199,14 +199,13 @@ function ParticipantSearchResults({ const isGroupPolicy = isGroupPolicyUtil(policy); const activeAdminWorkspaces = getActiveAdminWorkspaces(allPolicies, currentUserLogin); + const canUseWorkspaceChats = iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.CREATE || iouType === CONST.IOU.TYPE.SPLIT || iouType === CONST.IOU.TYPE.TRACK; const getValidOptionsConfig = { selectedOptions: participants as Participant[], excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, - includeOwnedWorkspaceChats: - !shouldExcludeWorkspaces && (iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.CREATE || iouType === CONST.IOU.TYPE.SPLIT || iouType === CONST.IOU.TYPE.TRACK), - excludeWorkspacesWithoutCreateExpensePermission: - iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.CREATE || iouType === CONST.IOU.TYPE.SPLIT || iouType === CONST.IOU.TYPE.TRACK, + includeOwnedWorkspaceChats: !shouldExcludeWorkspaces && canUseWorkspaceChats, + excludeWorkspacesWithoutCreateExpensePermission: canUseWorkspaceChats, excludeNonAdminWorkspaces: action === CONST.IOU.ACTION.SHARE, includeP2P: !isCategorizeOrShareAction && !isPerDiemRequest && !isTimeRequest && !isTransactionFromCreditCardImport && !shouldExcludeP2P, includeInvoiceRooms: iouType === CONST.IOU.TYPE.INVOICE, diff --git a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx index 863467854081..8906b00bea8e 100644 --- a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx +++ b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx @@ -20,7 +20,7 @@ import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import useReportTransactions from '@hooks/useReportTransactions'; import Navigation from '@libs/Navigation/Navigation'; -import {canSubmitPerDiemExpenseFromWorkspace, isPerDiemEnabled, isPolicyAdmin, isTimeTrackingEnabled} from '@libs/PolicyUtils'; +import {canCreateExpensesOnPolicy, canSubmitPerDiemExpenseFromWorkspace, isPerDiemEnabled, isPolicyAdmin, isTimeTrackingEnabled} from '@libs/PolicyUtils'; import {canAddTransaction, getIconsForExpenseReport, isIOUReport, isOpenReport, isReportOwner, isSelfDM, sortOutstandingReportsBySelected} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import {isPerDiemRequest as isPerDiemRequestUtil} from '@libs/TransactionUtils'; @@ -172,6 +172,10 @@ function IOURequestEditReportCommon({ .filter((report) => { const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; + if (!canCreateExpensesOnPolicy(policy, personalDetails?.[resolvedReportOwnerAccountID]?.login)) { + return false; + } + if (isTimeRequest && !isTimeTrackingEnabled(policy)) { return false; } @@ -210,6 +214,7 @@ function IOURequestEditReportCommon({ localeCompare, allPolicies, currentUserPersonalDetails.accountID, + resolvedReportOwnerAccountID, hasMultipleSubmitters, isPerDiemRequest, isTimeRequest, diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index 4468f5bdd0bb..b9e3d8c1104b 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -56,22 +56,21 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp }); const saveRole = () => { - const value = selectedRole; - if (!value || value === member?.role) { + if (!selectedRole || selectedRole === member?.role) { return; } - if (!canMemberAssignRole(policy, currentUserLogin, value)) { + if (!canMemberAssignRole(policy, currentUserLogin, selectedRole)) { return; } // Guard the direct-navigation path: a reimburser must stay a valid payer, so reject any role that cannot pay. - if (isReimburser && !canRolePay(value)) { + if (isReimburser && !canRolePay(selectedRole)) { return; } - if (value !== CONST.POLICY.ROLE.ADMIN && isRuleBotEnforcingRules(accountID, policy)) { + if (selectedRole !== CONST.POLICY.ROLE.ADMIN && isRuleBotEnforcingRules(accountID, policy)) { showRuleBotGuardModal('changeRole', policyID); return; } - updateWorkspaceMembersRole(policy, [memberLogin], [accountID], value); + updateWorkspaceMembersRole(policy, [memberLogin], [accountID], selectedRole); Navigation.goBack(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); }; diff --git a/tests/ui/components/AddExpenseActionButtonTest.tsx b/tests/ui/components/AddExpenseActionButtonTest.tsx index 5b1cba0a9a21..e4c4eb32ec4e 100644 --- a/tests/ui/components/AddExpenseActionButtonTest.tsx +++ b/tests/ui/components/AddExpenseActionButtonTest.tsx @@ -4,13 +4,12 @@ import AddExpenseActionButton from '@components/ReportActionItem/MoneyRequestRep import {getAddExpenseDropdownOptions} from '@userActions/IOU/StartExpenseFlows'; -import CONST from '@src/CONST'; import type {Report} from '@src/types/onyx'; import React from 'react'; const mockIouReport = {reportID: '1001', parentReportID: '3003'} as Report; -const mockPolicy = {id: 'policy1', role: CONST.POLICY.ROLE.USER}; +const mockPolicy = {id: 'policy1'}; // Capture the props AddExpenseActionButton forwards to ButtonWithDropdownMenu. const mockButtonProps: {current: {customText?: string} | undefined} = {current: undefined}; diff --git a/tests/utils/collections/policies.ts b/tests/utils/collections/policies.ts index 67867e3a9dc3..c9685e87b8de 100644 --- a/tests/utils/collections/policies.ts +++ b/tests/utils/collections/policies.ts @@ -25,7 +25,7 @@ export default function createRandomPolicy(index: number, type?: ValueOf role !== CONST.POLICY.ROLE.APPROVE_ONLY)), owner: randEmail(), ownerAccountID: index, avatarURL: randAvatar(), From 85b9bfe42af10d360fb040f8a1b4464af87dffc1 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:27:07 +0530 Subject: [PATCH 42/56] Fix blocked role change Onyx export, role permission checks, and card assignee filtering --- src/ONYXKEYS.ts | 2 +- .../Tables/WorkspaceRoomsTable/index.tsx | 4 ---- src/components/WorkspaceMemberRoleList.tsx | 18 ++++++++++++--- src/hooks/useApproveOnlyRoleBlockedModal.ts | 23 +++++++++---------- src/hooks/useDefaultExpensePolicy.tsx | 14 +++++++---- src/languages/de.ts | 2 -- src/languages/el.ts | 2 -- src/languages/en.ts | 2 -- src/languages/es.ts | 2 -- src/languages/fr.ts | 2 -- src/languages/it.ts | 2 -- src/languages/ja.ts | 2 -- src/languages/nl.ts | 2 -- src/languages/pl.ts | 2 -- src/languages/pt-BR.ts | 2 -- src/languages/zh-hans.ts | 2 -- src/libs/ExportOnyxState/masking.ts | 1 + src/libs/PolicyUtils/permissions.ts | 4 ++-- src/libs/ReportUtils.ts | 3 +-- .../step/IOURequestEditReportCommon.tsx | 4 +++- .../companyCards/assignCard/AssigneeStep.tsx | 4 ++-- .../expensifyCard/issueNew/AssigneeStep.tsx | 4 ++-- .../WorkspaceMemberDetailsRolePage.tsx | 18 +++++---------- src/setup/index.ts | 1 + tests/ui/AssignCardAssigneeStepTest.tsx | 1 + tests/ui/IssueNewCardAssigneeStepTest.tsx | 1 + tests/unit/PolicyUtilsTest.ts | 11 +++++++++ 27 files changed, 67 insertions(+), 68 deletions(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index fc46aa78c5c2..b7b66dea1179 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1908,7 +1908,7 @@ type OnyxValuesMapping = { [ONYXKEYS.RECENTLY_USED_REPORT_FIELDS]: OnyxTypes.RecentlyUsedReportFields; [ONYXKEYS.RAM_ONLY_UPDATE_REQUIRED]: boolean; [ONYXKEYS.SUPPORTAL_PERMISSION_DENIED]: OnyxTypes.SupportalPermissionDenied | null; - [ONYXKEYS.BLOCKED_ROLE_CHANGE]: OnyxTypes.BlockedRoleChange; + [ONYXKEYS.BLOCKED_ROLE_CHANGE]: OnyxTypes.BlockedRoleChange | null; [ONYXKEYS.RESET_REQUIRED]: boolean; [ONYXKEYS.PLAID_CURRENT_EVENT]: string; [ONYXKEYS.NVP_PRIVATE_TAX_EXEMPT]: boolean; diff --git a/src/components/Tables/WorkspaceRoomsTable/index.tsx b/src/components/Tables/WorkspaceRoomsTable/index.tsx index da29b2783ae4..7c0f73981d70 100644 --- a/src/components/Tables/WorkspaceRoomsTable/index.tsx +++ b/src/components/Tables/WorkspaceRoomsTable/index.tsx @@ -154,10 +154,6 @@ function WorkspaceRoomsTable({rooms, policyID, highlightedReportID, onSearchStri ListFooterComponent={listFooterComponent} > {tableHeaderComponent} - diff --git a/src/components/WorkspaceMemberRoleList.tsx b/src/components/WorkspaceMemberRoleList.tsx index 1dbac69cbd6b..dd2708ecc9e6 100644 --- a/src/components/WorkspaceMemberRoleList.tsx +++ b/src/components/WorkspaceMemberRoleList.tsx @@ -16,7 +16,7 @@ import React from 'react'; import {View} from 'react-native'; import type {LocalizedTranslate} from './LocaleContextProvider'; -import type {ListItem} from './SelectionList/types'; +import type {ConfirmButtonOptions, ListItem} from './SelectionList/types'; import HeaderWithBackButton from './HeaderWithBackButton'; import SelectionList from './SelectionList'; @@ -38,6 +38,9 @@ type WorkspaceMemberRoleListProps = { /** When provided, restricts the selectable roles to this set (e.g. an Authorized Payer may only be an Admin or Payments Admin) */ allowedRoles?: Array>; + + /** Configuration for the confirm button */ + confirmButtonOptions?: ConfirmButtonOptions; }; /** @@ -113,7 +116,15 @@ function getAssignableWorkspaceMemberRoleItems( return workspaceRoles.filter((item) => canMemberAssignRole(policy, currentUserLogin, item.value) && (!allowedRoles || allowedRoles.includes(item.value))); } -function WorkspaceMemberRoleList({role, policy, navigateBackTo = undefined, isLoading = false, onSelectRole = () => {}, allowedRoles = undefined}: WorkspaceMemberRoleListProps) { +function WorkspaceMemberRoleList({ + role, + policy, + navigateBackTo = undefined, + isLoading = false, + onSelectRole = () => {}, + allowedRoles = undefined, + confirmButtonOptions = undefined, +}: WorkspaceMemberRoleListProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); @@ -132,7 +143,8 @@ function WorkspaceMemberRoleList({role, policy, navigateBackTo = undefined, isLo data={availableRoleItems} ListItem={SingleSelectListItem} onSelectRow={onSelectRole} - shouldSingleExecuteRowSelect + shouldSingleExecuteRowSelect={!confirmButtonOptions?.showButton} + confirmButtonOptions={confirmButtonOptions} initiallyFocusedItemKey={availableRoleItems.find((item) => item.isSelected)?.keyForList} addBottomSafeAreaPadding /> diff --git a/src/hooks/useApproveOnlyRoleBlockedModal.ts b/src/hooks/useApproveOnlyRoleBlockedModal.ts index 492302d31a73..57a0aa476c68 100644 --- a/src/hooks/useApproveOnlyRoleBlockedModal.ts +++ b/src/hooks/useApproveOnlyRoleBlockedModal.ts @@ -1,3 +1,7 @@ +/** + * Subscribes to BLOCKED_ROLE_CHANGE in Onyx and shows the blocked-reasons modal on the global modal stack + * when the backend refuses an approve-only role change. + */ import {clearBlockedRoleChange, clearUpdateMemberRoleError} from '@userActions/Policy/Member'; import CONST from '@src/CONST'; @@ -10,9 +14,14 @@ import useConfirmModal from './useConfirmModal'; import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; -const BLOCKED_ROLE_CHANGE_MAX_AGE_MS = 60_000; const APPROVE_ONLY_ROLE_BLOCKED_MODAL_ID = 'approveOnlyRoleBlocked'; +const REASON_TRANSLATION_KEYS: Record = { + [CONST.POLICY.APPROVE_ONLY_BLOCKED_REASONS.HAS_CARD_ON_POLICY]: 'workspace.people.approveOnlyRoleBlockedReasons.hasCardOnPolicy', + [CONST.POLICY.APPROVE_ONLY_BLOCKED_REASONS.IS_RESTRICTED_BY_DOMAIN_GROUP]: 'workspace.people.approveOnlyRoleBlockedReasons.isRestrictedByDomainGroup', + [CONST.POLICY.APPROVE_ONLY_BLOCKED_REASONS.IS_DEFAULT_POLICY]: 'workspace.people.approveOnlyRoleBlockedReasons.isDefaultPolicy', +}; + function useApproveOnlyRoleBlockedModal() { const {translate} = useLocalize(); const {showConfirmModal, closeModalByID} = useConfirmModal(); @@ -29,11 +38,6 @@ function useApproveOnlyRoleBlockedModal() { return; } - if (Date.now() - blockedRoleChange.created > BLOCKED_ROLE_CHANGE_MAX_AGE_MS) { - clearBlockedRoleChange(); - return; - } - if (lastShownCreatedRef.current === blockedRoleChange.created) { return; } @@ -51,12 +55,7 @@ function useApproveOnlyRoleBlockedModal() { }); } - const reasonTranslationKeys: Record = { - [CONST.POLICY.APPROVE_ONLY_BLOCKED_REASONS.HAS_CARD_ON_POLICY]: 'workspace.people.approveOnlyRoleBlockedReasons.hasCardOnPolicy', - [CONST.POLICY.APPROVE_ONLY_BLOCKED_REASONS.IS_RESTRICTED_BY_DOMAIN_GROUP]: 'workspace.people.approveOnlyRoleBlockedReasons.isRestrictedByDomainGroup', - [CONST.POLICY.APPROVE_ONLY_BLOCKED_REASONS.IS_DEFAULT_POLICY]: 'workspace.people.approveOnlyRoleBlockedReasons.isDefaultPolicy', - }; - const reasonLines = blockedReasons.filter((reason) => reason in reasonTranslationKeys).map((reason) => `• ${translate(reasonTranslationKeys[reason])}`); + const reasonLines = blockedReasons.filter((reason) => reason in REASON_TRANSLATION_KEYS).map((reason) => `• ${translate(REASON_TRANSLATION_KEYS[reason])}`); const hasReasonLines = reasonLines.length > 0; showConfirmModal({ diff --git a/src/hooks/useDefaultExpensePolicy.tsx b/src/hooks/useDefaultExpensePolicy.tsx index 2cf0ea1d88d5..447ae3cbf315 100644 --- a/src/hooks/useDefaultExpensePolicy.tsx +++ b/src/hooks/useDefaultExpensePolicy.tsx @@ -1,15 +1,19 @@ import isTeachersUnitePolicyID from '@libs/isTeachersUnitePolicyID'; -import {isGroupPolicy, isPolicyAccessible} from '@libs/PolicyUtils'; +import {canCreateExpensesOnPolicy, isGroupPolicy, isPolicyAccessible} from '@libs/PolicyUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy} from '@src/types/onyx'; -import type {OnyxCollection} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useOnyx from './useOnyx'; import usePreferredPolicy from './usePreferredPolicy'; +function isEligibleDefaultExpensePolicy(policy: OnyxEntry, login: string): policy is Policy { + return !!policy && isGroupPolicy(policy) && isPolicyAccessible(policy, login) && canCreateExpensesOnPolicy(policy, login) && !isTeachersUnitePolicyID(policy.id); +} + /** * Selector that finds the single qualifying group policy ID from the collection. * Returns only an ID (stable) — prevents re-renders when unrelated policies change. @@ -21,7 +25,7 @@ function getSingleGroupPolicyID(policies: OnyxCollection, login: string) let singlePolicyID: string | undefined; for (const policy of Object.values(policies)) { - if (!policy || !isGroupPolicy(policy) || !isPolicyAccessible(policy, login) || isTeachersUnitePolicyID(policy.id)) { + if (!isEligibleDefaultExpensePolicy(policy, login)) { continue; } if (!singlePolicyID) { @@ -50,11 +54,11 @@ export default function useDefaultExpensePolicy() { const [singleGroupPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${singleGroupPolicyID}`); // Teachers Unite only supports expenses via split expense , so it can never be the default destination for a new expense. - if (isRestrictedToPreferredPolicy && isGroupPolicy(preferredPolicy) && isPolicyAccessible(preferredPolicy, login) && !isTeachersUnitePolicyID(preferredPolicy?.id)) { + if (isRestrictedToPreferredPolicy && isEligibleDefaultExpensePolicy(preferredPolicy, login)) { return preferredPolicy; } - if (isGroupPolicy(activePolicy) && isPolicyAccessible(activePolicy, login) && !isTeachersUnitePolicyID(activePolicy?.id)) { + if (isEligibleDefaultExpensePolicy(activePolicy, login)) { return activePolicy; } diff --git a/src/languages/de.ts b/src/languages/de.ts index bea5bc81e4a3..e2a91dfd1bc5 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -4843,8 +4843,6 @@ ${amount} für ${merchant} – ${date}`, archived: 'Archiviert', workspaceStatus: 'Workspace-Status', findRoom: 'Raum finden', - emptyRoomsTitle: 'Noch keine Räume', - emptyRoomsSubtitle: 'Räume, die in diesem Arbeitsbereich erstellt werden, erscheinen hier.', edit: 'Arbeitsbereich bearbeiten', enabled: 'Aktiviert', disabled: 'Deaktiviert', diff --git a/src/languages/el.ts b/src/languages/el.ts index 23da27d8d4ab..bb7983235bd9 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -4908,8 +4908,6 @@ ${amount} για ${merchant} - ${date}`, workspaceStatus: 'Κατάσταση χώρου εργασίας', findDomain: 'Εύρεση τομέα', findRoom: 'Βρείτε δωμάτιο', - emptyRoomsTitle: 'Δεν υπάρχουν ακόμη δωμάτια', - emptyRoomsSubtitle: 'Τα δωμάτια που δημιουργούνται σε αυτόν τον χώρο εργασίας θα εμφανίζονται εδώ.', edit: 'Επεξεργασία χώρου εργασίας', enabled: 'Ενεργοποιημένο', disabled: 'Απενεργοποιημένο', diff --git a/src/languages/en.ts b/src/languages/en.ts index fbcf526069c1..45aa29342236 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5018,8 +5018,6 @@ const translations = { workspaceStatus: 'Workspace status', findDomain: 'Find domain', findRoom: 'Find room', - emptyRoomsTitle: 'No rooms yet', - emptyRoomsSubtitle: 'Rooms created in this workspace will appear here.', edit: 'Edit workspace', enabled: 'Enabled', disabled: 'Disabled', diff --git a/src/languages/es.ts b/src/languages/es.ts index 6257355489e5..b483335af4e3 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4759,8 +4759,6 @@ ${amount} para ${merchant} - ${date}`, archived: 'Archivado', workspaceStatus: 'Estado del espacio de trabajo', findRoom: 'Encontrar sala', - emptyRoomsTitle: 'Aún no hay salas', - emptyRoomsSubtitle: 'Las salas creadas en este espacio de trabajo aparecerán aquí.', edit: 'Editar espacio de trabajo', enabled: 'Activada', disabled: 'Desactivada', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index e988f9eade1a..c7f108969395 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -4850,8 +4850,6 @@ ${amount} pour ${merchant} - ${date}`, archived: 'Archivé', workspaceStatus: "Statut de l'espace de travail", findRoom: 'Trouver un salon', - emptyRoomsTitle: 'Aucun salon pour le moment', - emptyRoomsSubtitle: 'Les salons créés dans cet espace de travail apparaîtront ici.', edit: 'Modifier l’espace de travail', enabled: 'Activé', disabled: 'Désactivé', diff --git a/src/languages/it.ts b/src/languages/it.ts index 5ddfe60baee3..7378f5fc51de 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -4812,8 +4812,6 @@ ${amount} per ${merchant} - ${date}`, archived: 'Archiviato', workspaceStatus: 'Stato dello spazio di lavoro', findRoom: 'Trova stanza', - emptyRoomsTitle: 'Ancora nessuna stanza', - emptyRoomsSubtitle: 'Le stanze create in questo spazio di lavoro appariranno qui.', edit: 'Modifica spazio di lavoro', enabled: 'Abilitato', disabled: 'Disattivato', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 68afb04063e9..616547ad439d 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -4777,8 +4777,6 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' archived: 'アーカイブ済み', workspaceStatus: 'ワークスペースのステータス', findRoom: 'ルームを探す', - emptyRoomsTitle: 'ルームはまだありません', - emptyRoomsSubtitle: 'このワークスペースで作成されたルームはここに表示されます。', edit: 'ワークスペースを編集', enabled: '有効', disabled: '無効', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index a657f051e91e..74b7b8f10731 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -4813,8 +4813,6 @@ ${amount} voor ${merchant} - ${date}`, archived: 'Gearchiveerd', workspaceStatus: 'Werkruimtestatus', findRoom: 'Kamer zoeken', - emptyRoomsTitle: 'Nog geen kamers', - emptyRoomsSubtitle: 'Kamers die in deze werkruimte worden aangemaakt, verschijnen hier.', edit: 'Werkruimte bewerken', enabled: 'Ingeschakeld', disabled: 'Uitgeschakeld', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index d5d0f6279a54..3ef2d09b3378 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -4845,8 +4845,6 @@ ${amount} dla ${merchant} - ${date}`, archived: 'Zarchiwizowana', workspaceStatus: 'Status przestrzeni roboczej', findRoom: 'Znajdź pokój', - emptyRoomsTitle: 'Brak pokoi', - emptyRoomsSubtitle: 'Pokoje utworzone w tym obszarze roboczym pojawią się tutaj.', edit: 'Edytuj przestrzeń roboczą', enabled: 'Włączone', disabled: 'Wyłączone', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index f67471c2f4d6..0024e88d3a7c 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -4805,8 +4805,6 @@ ${amount} para ${merchant} - ${date}`, archived: 'Arquivado', workspaceStatus: 'Status do espaço de trabalho', findRoom: 'Encontrar sala', - emptyRoomsTitle: 'Ainda não há salas', - emptyRoomsSubtitle: 'As salas criadas neste espaço de trabalho aparecerão aqui.', edit: 'Editar espaço de trabalho', enabled: 'Ativado', disabled: 'Desativado', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 2526db8c48a9..ed39b2d156d3 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -4667,8 +4667,6 @@ ${amount},商户:${merchant} - 日期:${date}`, archived: '已归档', workspaceStatus: '工作区状态', findRoom: '查找房间', - emptyRoomsTitle: '暂无房间', - emptyRoomsSubtitle: '在此工作区创建的房间将显示在这里。', edit: '编辑工作区', enabled: '已启用', disabled: '已禁用', diff --git a/src/libs/ExportOnyxState/masking.ts b/src/libs/ExportOnyxState/masking.ts index 19414a189282..5a5be03c9b86 100644 --- a/src/libs/ExportOnyxState/masking.ts +++ b/src/libs/ExportOnyxState/masking.ts @@ -394,6 +394,7 @@ const onyxKeysToMaskFragileData = new Set([ ONYXKEYS.APPROVAL_WORKFLOW, ONYXKEYS.ASSIGN_CARD, ONYXKEYS.BILLING_RECEIPT_DETAILS, + ONYXKEYS.BLOCKED_ROLE_CHANGE, ONYXKEYS.COLLECTION.AGENT, ONYXKEYS.COLLECTION.ATTACHMENT, ONYXKEYS.COLLECTION.BANK_ACCOUNT_SHARE_DETAILS, diff --git a/src/libs/PolicyUtils/permissions.ts b/src/libs/PolicyUtils/permissions.ts index 427a58fc5010..1f2ef145e6d7 100644 --- a/src/libs/PolicyUtils/permissions.ts +++ b/src/libs/PolicyUtils/permissions.ts @@ -249,8 +249,8 @@ function canRoleCreateExpenses(role: string | undefined): boolean { return !!role && ROLE_PERMISSION_BUNDLES[role]?.[CONST.POLICY.POLICY_FEATURE.CREATE_EXPENSES] === CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE; } -function canCreateExpensesOnPolicy(policy: OnyxInputOrEntry, currentUserLogin?: string): boolean { - return canRoleCreateExpenses(getPolicyRole(policy, currentUserLogin)); +function canCreateExpensesOnPolicy(policy: OnyxInputOrEntry, login?: string): boolean { + return canRoleCreateExpenses(getPolicyRole(policy, login, false) ?? policy?.role); } /** Check if the passed employee is an approver in the policy's employeeList */ diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index f831e2d0b172..3dd22c035391 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -142,7 +142,6 @@ import { arePaymentsEnabled, canMemberWrite as canMemberWritePolicyUtils, canCreateExpensesOnPolicy, - canRoleCreateExpenses, canSendInvoiceFromWorkspace, getActivePolicies, getConnectedIntegration, @@ -14222,7 +14221,7 @@ function isWorkspaceEligibleForReportChange(submitterEmail: string | undefined, return false; } // Moving a report creates expenses there, so the submitter must hold create-expenses permission. - if (!canRoleCreateExpenses(getPolicyRole(newPolicy, submitterEmail, false) ?? newPolicy.role)) { + if (!canCreateExpensesOnPolicy(newPolicy, submitterEmail)) { return false; } return !!newPolicy.role && !isPendingDeletePolicy(newPolicy); diff --git a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx index 8906b00bea8e..2960879731db 100644 --- a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx +++ b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx @@ -165,6 +165,8 @@ function IOURequestEditReportCommon({ return []; } + const reportOwnerLogin = personalDetails?.[resolvedReportOwnerAccountID]?.login; + return outstandingReports .sort((report1, report2) => sortOutstandingReportsBySelected(report1, report2, selectedReportID, localeCompare)) .filter((report) => !debouncedSearchValue || report?.reportName?.toLowerCase().includes(debouncedSearchValue.toLowerCase())) @@ -172,7 +174,7 @@ function IOURequestEditReportCommon({ .filter((report) => { const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; - if (!canCreateExpensesOnPolicy(policy, personalDetails?.[resolvedReportOwnerAccountID]?.login)) { + if (!canCreateExpensesOnPolicy(policy, reportOwnerLogin)) { return false; } diff --git a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx index bec30d8ee302..febc6d844aaf 100644 --- a/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/AssigneeStep.tsx @@ -25,7 +25,7 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import {getSearchValueForPhoneOrEmail} from '@libs/OptionsListUtils'; import {getHeaderMessage} from '@libs/PersonalDetailOptionsListUtils'; -import {canMemberWrite, filterGuideAndAccountManager, getGuideAndAccountManagerInfo, getIneligibleInvitees, isDeletedPolicyEmployee} from '@libs/PolicyUtils'; +import {canMemberWrite, canRoleCreateExpenses, filterGuideAndAccountManager, getGuideAndAccountManagerInfo, getIneligibleInvitees, isDeletedPolicyEmployee} from '@libs/PolicyUtils'; import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import sortAlphabetically from '@libs/sortAlphabetically'; import tokenizedSearch from '@libs/tokenizedSearch'; @@ -218,7 +218,7 @@ function AssigneeStep({route}: AssigneeStepProps) { const membersDetails: AssigneeListItem[] = []; if (policy?.employeeList) { for (const [email, policyEmployee] of Object.entries(policy.employeeList ?? {})) { - if (isDeletedPolicyEmployee(policyEmployee, isOffline)) { + if (isDeletedPolicyEmployee(policyEmployee, isOffline) || (policyEmployee.role && !canRoleCreateExpenses(policyEmployee.role))) { continue; } diff --git a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx index 002024bd7cae..8c49287d2eff 100644 --- a/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/AssigneeStep.tsx @@ -21,7 +21,7 @@ import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigat import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import {getSearchValueForPhoneOrEmail} from '@libs/OptionsListUtils'; import {getHeaderMessage} from '@libs/PersonalDetailOptionsListUtils'; -import {canMemberWrite, filterGuideAndAccountManager, getGuideAndAccountManagerInfo, getIneligibleInvitees, isDeletedPolicyEmployee} from '@libs/PolicyUtils'; +import {canMemberWrite, canRoleCreateExpenses, filterGuideAndAccountManager, getGuideAndAccountManagerInfo, getIneligibleInvitees, isDeletedPolicyEmployee} from '@libs/PolicyUtils'; import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import sortAlphabetically from '@libs/sortAlphabetically'; import tokenizedSearch from '@libs/tokenizedSearch'; @@ -150,7 +150,7 @@ function AssigneeStep({policy, stepNames, startStepIndex, route}: AssigneeStepPr const membersDetails: AssigneeListItem[] = []; if (policy?.employeeList) { for (const [email, policyEmployee] of Object.entries(policy.employeeList ?? {})) { - if (isDeletedPolicyEmployee(policyEmployee, isOffline)) { + if (isDeletedPolicyEmployee(policyEmployee, isOffline) || (policyEmployee.role && !canRoleCreateExpenses(policyEmployee.role))) { continue; } diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index b9e3d8c1104b..cb42d5b4e557 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -1,5 +1,3 @@ -import Button from '@components/Button'; -import FixedFooter from '@components/FixedFooter'; import ScreenWrapper from '@components/ScreenWrapper'; import WorkspaceMemberRoleList from '@components/WorkspaceMemberRoleList'; import type {ListItemType} from '@components/WorkspaceMemberRoleList'; @@ -91,17 +89,13 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp onSelectRole={(item: ListItemType) => setSelectedRole(item.value)} allowedRoles={allowedRoles} navigateBackTo={ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)} + confirmButtonOptions={{ + showButton: true, + text: translate('common.save'), + onConfirm: saveRole, + isDisabled: !selectedRole || selectedRole === member?.role, + }} /> - - - ); diff --git a/src/setup/index.ts b/src/setup/index.ts index 9a1f7ba8a065..4f99c939c690 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -66,6 +66,7 @@ export default function () { [ONYXKEYS.RAM_ONLY_IS_PRODUCT_MARKETING_WINDOW_COVERED]: false, // Ensure the Supportal permission modal doesn't persist across reloads [ONYXKEYS.SUPPORTAL_PERMISSION_DENIED]: null, + [ONYXKEYS.BLOCKED_ROLE_CHANGE]: null, [ONYXKEYS.IS_OPEN_APP_FAILURE_MODAL_OPEN]: false, // Without a default this server-owned NVP has no row until it arrives, and its loading status holds the Search router behind a skeleton [ONYXKEYS.RECENT_SEARCHES]: {}, diff --git a/tests/ui/AssignCardAssigneeStepTest.tsx b/tests/ui/AssignCardAssigneeStepTest.tsx index cf22eec82e40..3ed5d3a05a3d 100644 --- a/tests/ui/AssignCardAssigneeStepTest.tsx +++ b/tests/ui/AssignCardAssigneeStepTest.tsx @@ -100,6 +100,7 @@ jest.mock('@libs/PersonalDetailsUtils', () => ({ })); jest.mock('@libs/PolicyUtils', () => ({ canMemberWrite: jest.fn(() => false), + canRoleCreateExpenses: jest.fn((role?: string) => role !== 'approveOnly'), filterGuideAndAccountManager: jest.fn((items: unknown[]) => items), getGuideAndAccountManagerInfo: jest.fn(() => ({assignedGuideEmail: undefined, accountManagerLogin: undefined, exclusions: {}})), getIneligibleInvitees: jest.fn(() => []), diff --git a/tests/ui/IssueNewCardAssigneeStepTest.tsx b/tests/ui/IssueNewCardAssigneeStepTest.tsx index 46826b25160a..ac61c68a1b53 100644 --- a/tests/ui/IssueNewCardAssigneeStepTest.tsx +++ b/tests/ui/IssueNewCardAssigneeStepTest.tsx @@ -94,6 +94,7 @@ jest.mock('@hooks/usePersonalDetailSearchSelector', () => jest.mock('@libs/PolicyUtils', () => ({ canMemberWrite: jest.fn(() => true), + canRoleCreateExpenses: jest.fn((role?: string) => role !== 'approveOnly'), filterGuideAndAccountManager: (list: unknown) => list, getGuideAndAccountManagerInfo: jest.fn(() => ({assignedGuideEmail: undefined, accountManagerLogin: undefined, exclusions: {}})), getIneligibleInvitees: jest.fn(() => []), diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 2fcfc1d6eafb..276de7845bf8 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -548,6 +548,17 @@ describe('PolicyUtils', () => { expect(canCreateExpensesOnPolicy(legacyPolicy, memberLogin)).toBe(true); }); + it('resolves the target member role from employeeList even when policy.role belongs to another user', () => { + const policyWithDifferentViewerRole = createMock({ + role: CONST.POLICY.ROLE.ADMIN, + employeeList: { + [memberLogin]: {role: CONST.POLICY.ROLE.APPROVE_ONLY}, + }, + }); + + expect(canCreateExpensesOnPolicy(policyWithDifferentViewerRole, memberLogin)).toBe(false); + }); + it('blocks Guest assignment even on Control workspaces', () => { const controlPolicy = buildPolicy(CONST.POLICY.ROLE.ADMIN); From 2a19db757db99120e6b03e5809f3918a0e4b19ec Mon Sep 17 00:00:00 2001 From: Shridhar Goel <35566748+ShridharGoel@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:36:27 +0530 Subject: [PATCH 43/56] Update PolicyUtilsTest.ts --- tests/unit/PolicyUtilsTest.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index 2d45fd5bf8ed..9681dec97b8d 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -32,7 +32,6 @@ import { getActivePolicies, getActivePoliciesWithExpenseChat, getActivePoliciesWithExpenseChatAndPerDiemEnabled, - getGroupPoliciesWhereReportCanBeCreated, getAllTaxRates, getAllTaxRatesNamesAndValues, getApprovalWorkflow, From aec986d8823a8dd20d204e9831337c9f06723be3 Mon Sep 17 00:00:00 2001 From: Shridhar Goel <35566748+ShridharGoel@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:19:47 +0530 Subject: [PATCH 44/56] Update index.ts --- src/libs/PolicyUtils/index.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/libs/PolicyUtils/index.ts b/src/libs/PolicyUtils/index.ts index e07540d6948e..1baa2a4f60a4 100644 --- a/src/libs/PolicyUtils/index.ts +++ b/src/libs/PolicyUtils/index.ts @@ -18,10 +18,7 @@ import type {ValueOf} from 'type-fest'; import {Str} from 'expensify-common'; -import {hasAccountingConnections} from './connections'; -import {getPerDiemCustomUnit, isPerDiemEligiblePolicy} from './customUnits'; -import {isTimeTrackingEnabled} from './features'; -import {canCreateExpensesOnPolicy, canMemberWrite, getPolicyRole, isPolicyAdmin, isPolicyOwner, isPolicyUser} from './permissions'; +import {canMemberWrite} from './permissions'; import {getActivePolicies, shouldShowPolicy} from './policy'; import {isPaidGroupPolicy} from './policyType'; From 36149858d16746dd69f078362234c742d69e5001 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:33:39 +0530 Subject: [PATCH 45/56] Exclude workspaces without create-expense permission from report creation list --- src/libs/PolicyUtils/policy.ts | 6 ++++-- tests/utils/collections/policies.ts | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/libs/PolicyUtils/policy.ts b/src/libs/PolicyUtils/policy.ts index de6fe7237552..965264823075 100644 --- a/src/libs/PolicyUtils/policy.ts +++ b/src/libs/PolicyUtils/policy.ts @@ -21,7 +21,7 @@ import type {ValueOf} from 'type-fest'; import {hasAccountingConnections} from './connections'; import {getPerDiemCustomUnit, isPerDiemEligiblePolicy} from './customUnits'; import {isTimeTrackingEnabled} from './features'; -import {canEditWorkspaceSettings, canMemberWrite, getPolicyRole, isPolicyAdmin, isPolicyOwner, isPolicyUser} from './permissions'; +import {canCreateExpensesOnPolicy, canEditWorkspaceSettings, canMemberWrite, getPolicyRole, isPolicyAdmin, isPolicyOwner, isPolicyUser} from './permissions'; import {isArchivedPolicy, isControlPolicy, isGroupPolicy, isPaidGroupPolicy, isSubmitPolicy} from './policyType'; type WorkspaceDetails = { @@ -269,7 +269,8 @@ function hasAnyPaidPolicy(policies: OnyxCollection | null) { /** * Returns the group workspaces where the user can create a report: paid (Team/Corporate) workspaces, * plus Submit workspaces. Submit workspaces are free but still support report creation, so they belong - * here even though they're excluded from `getGroupPaidPolicies`. + * here even though they're excluded from `getGroupPaidPolicies`. Workspaces whose role for the user + * cannot create expenses (e.g. approve-only) are excluded. */ function getGroupPoliciesWhereReportCanBeCreated(policies: OnyxCollection | null, currentUserLogin?: string) { if (isEmptyObject(policies)) { @@ -281,6 +282,7 @@ function getGroupPoliciesWhereReportCanBeCreated(policies: OnyxCollection role !== CONST.POLICY.ROLE.APPROVE_ONLY)), + // `owner` is a display-only role derived from `policy.owner` in the UI, it has no permission bundle, + // so randomizing it makes permission checks fail closed nondeterministically. + role: rand(Object.values(CONST.POLICY.ROLE).filter((role) => role !== CONST.POLICY.ROLE.APPROVE_ONLY && role !== CONST.POLICY.ROLE.OWNER)), owner: randEmail(), ownerAccountID: index, avatarURL: randAvatar(), From 4d49c884d000c42c8f1ce14c6c583f9a57e19043 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:43:44 +0530 Subject: [PATCH 46/56] Add approve-only to selectable roles and fix CI failures --- src/libs/BankAccountUtils.ts | 1 - src/libs/Fullstory/types.ts | 2 +- src/libs/PolicyUtils/permissions.ts | 2 ++ src/pages/workspace/MemberRightIcon.tsx | 3 ++- tests/unit/OptionsListUtilsTest.tsx | 2 +- 5 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/libs/BankAccountUtils.ts b/src/libs/BankAccountUtils.ts index 27594a3b2bb8..1ed341db2d52 100644 --- a/src/libs/BankAccountUtils.ts +++ b/src/libs/BankAccountUtils.ts @@ -143,7 +143,6 @@ function isFilterableBankAccount(bankAccount: OnyxEntry): function isBankAccountEligibleForGlobalReimbursements(accountData: AccountData | undefined, bankCurrency?: string): boolean { return ( // `||` not `??`: bankCurrency can be an empty string, which should fall through to additionalData. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing (bankCurrency || accountData?.additionalData?.currency) === CONST.CURRENCY.USD && accountData?.type === CONST.BANK_ACCOUNT.TYPE.BUSINESS && !accountData.additionalData?.verifications?.corpay && diff --git a/src/libs/Fullstory/types.ts b/src/libs/Fullstory/types.ts index d8f1b7c4620c..21be38fa85d5 100644 --- a/src/libs/Fullstory/types.ts +++ b/src/libs/Fullstory/types.ts @@ -15,7 +15,7 @@ type FullstoryUserVars = { user_status?: 'new' | 'returning'; has_completed_onboarding?: boolean; onb_step?: 'registration' | 'accounting' | 'completed'; - user_role?: 'admin' | 'paymentsAdmin' | 'cardAdmin' | 'peopleAdmin' | 'auditor' | 'editor' | 'member' | 'guest'; + user_role?: 'admin' | 'paymentsAdmin' | 'cardAdmin' | 'peopleAdmin' | 'auditor' | 'editor' | 'member' | 'approveOnly' | 'guest'; workspace_state?: 'has_workspaces' | 'no_workspaces'; workspace_count?: number; workspace_member_count?: number; diff --git a/src/libs/PolicyUtils/permissions.ts b/src/libs/PolicyUtils/permissions.ts index 81adb200a9ab..6ba72e53b329 100644 --- a/src/libs/PolicyUtils/permissions.ts +++ b/src/libs/PolicyUtils/permissions.ts @@ -234,6 +234,7 @@ const SELECTABLE_ROLES = [ CONST.POLICY.ROLE.PEOPLE_ADMIN, CONST.POLICY.ROLE.PAYMENTS_ADMIN, CONST.POLICY.ROLE.USER, + CONST.POLICY.ROLE.APPROVE_ONLY, ] as const; /** @@ -286,6 +287,7 @@ const POLICY_ROLE_RANKING = [ CONST.POLICY.ROLE.AUDITOR, CONST.POLICY.ROLE.EDITOR, CONST.POLICY.ROLE.USER, + CONST.POLICY.ROLE.APPROVE_ONLY, CONST.POLICY.ROLE.GUEST, ] as const; diff --git a/src/pages/workspace/MemberRightIcon.tsx b/src/pages/workspace/MemberRightIcon.tsx index 126fc7f6498d..b3ca5f2e3183 100644 --- a/src/pages/workspace/MemberRightIcon.tsx +++ b/src/pages/workspace/MemberRightIcon.tsx @@ -31,7 +31,8 @@ export default function MemberRightIcon({role, owner, login, badgeStyles}: Membe role === CONST.POLICY.ROLE.GUEST || role === CONST.POLICY.ROLE.CARD_ADMIN || role === CONST.POLICY.ROLE.PEOPLE_ADMIN || - role === CONST.POLICY.ROLE.PAYMENTS_ADMIN + role === CONST.POLICY.ROLE.PAYMENTS_ADMIN || + role === CONST.POLICY.ROLE.APPROVE_ONLY ) { badgeText = translate('workspace.common.roleName', role); } else if (role === CONST.POLICY.ROLE.EDITOR) { diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 6d2c75018f0f..73ced628baa4 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -1471,7 +1471,7 @@ describe('OptionsListUtils', () => { action: CONST.IOU.ACTION.CREATE, personalDetails: PERSONAL_DETAILS, }, - translateLocal, + {translate: translateLocal, formatPhoneNumber}, undefined, ); }; From 8a51786538a35820b7a0d960217c344a425c10a9 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:19:07 +0530 Subject: [PATCH 47/56] Add missing formatPhoneNumber to test helper config --- tests/unit/OptionsListUtilsTest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 73ced628baa4..cf0d2923c3e8 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -1447,7 +1447,7 @@ describe('OptionsListUtils', () => { createMockReportAttributesDerived(workspaceChatReports, PERSONAL_DETAILS, CURRENT_USER_ACCOUNT_ID), EMPTY_PRIVATE_IS_ARCHIVED_MAP, policies, - {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, isSearching: true}, + {currentUserAccountID: CURRENT_USER_ACCOUNT_ID, dateFnsLocale: undefined, convertToDisplayString, conciergeReportID: undefined, isSearching: true, formatPhoneNumber}, undefined, ); From af63850317d20ec8e126a69e36e627eef17fc38f Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:52:56 +0530 Subject: [PATCH 48/56] Skip create-expenses gate for non-workspace reports --- .../MoneyRequestReportView/MoneyRequestReportListFooter.tsx | 3 ++- .../SearchMoneyRequestReportEmptyState.tsx | 3 ++- src/libs/ReportSecondaryActionUtils.ts | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportListFooter.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportListFooter.tsx index 28188ecbe4f0..3d06ba49ebaf 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportListFooter.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportListFooter.tsx @@ -86,7 +86,8 @@ function MoneyRequestReportListFooter({report, policy, transactions, hasPendingA const formattedTaxAmount = convertToDisplayString(taxTotal, report?.currency); const shouldShowExpenseReportBreakDown = hasNonReimbursableTransactions(transactions); const shouldShowBreakdown = shouldShowExpenseReportBreakDown || !!billableTotal || (!!taxTotal && isTaxEnabled); - const shouldShowAddExpenseButton = canCreateExpensesOnPolicy(policy) && canAddTransaction(report, rules, isReportArchived) && isCurrentUserSubmitter(report); + // Non-workspace reports have no policy, so the create-expenses gate only applies to policy-bound reports. + const shouldShowAddExpenseButton = (!report?.policyID || canCreateExpensesOnPolicy(policy)) && canAddTransaction(report, rules, isReportArchived) && isCurrentUserSubmitter(report); const breakdownRows: Array<{text: TranslationPaths; value: string; shouldShow: boolean}> = [ {text: 'cardTransactions.outOfPocket', value: formattedOutOfPocketAmount, shouldShow: !!nonReimbursableSpend}, diff --git a/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx b/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx index 50a683db990e..b1018984e3e9 100644 --- a/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx +++ b/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx @@ -48,7 +48,8 @@ function SearchMoneyRequestReportEmptyState({report, policy, onLayout}: {report: const reportId = report.reportID; const isReportArchived = isArchivedReport(reportNameValuePairs); const icons = useMemoizedLazyExpensifyIcons(['ReceiptPlus']); - const canAddTransactionToReport = canCreateExpensesOnPolicy(policy) && canAddTransaction(report, rules, isReportArchived); + // Non-workspace reports have no policy, so the create-expenses gate only applies to policy-bound reports. + const canAddTransactionToReport = (!report?.policyID || canCreateExpensesOnPolicy(policy)) && canAddTransaction(report, rules, isReportArchived); const blockDistanceRequestIfNeeded = useBlockDistanceRequest({ policyID: policy?.id, isDistanceRequest: true, diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index 65201f64a6b9..eca489fead7b 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -1089,7 +1089,8 @@ function getSecondaryReportActions({ options.push(CONST.REPORT.SECONDARY_ACTIONS.PAY); } - if (isAddExpenseAction(report, reportTransactions, rules, isChatReportArchived || isArchivedReport(reportNameValuePairs)) && canCreateExpensesOnPolicy(policy)) { + // Non-workspace reports have no policy, so the create-expenses gate only applies to policy-bound reports. + if (isAddExpenseAction(report, reportTransactions, rules, isChatReportArchived || isArchivedReport(reportNameValuePairs)) && (!report?.policyID || canCreateExpensesOnPolicy(policy))) { options.push(CONST.REPORT.SECONDARY_ACTIONS.ADD_EXPENSE); } From 1a6c99dd7f0849ee78182383d9a89ecb40ebe502 Mon Sep 17 00:00:00 2001 From: Shridhar Goel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:21:39 +0530 Subject: [PATCH 49/56] Updates --- .../MoneyRequestReportView/MoneyRequestReportListFooter.tsx | 1 - .../SearchMoneyRequestReportEmptyState.tsx | 1 - src/libs/ReportSecondaryActionUtils.ts | 1 - 3 files changed, 3 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportListFooter.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportListFooter.tsx index 3d06ba49ebaf..b986699d1766 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportListFooter.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportListFooter.tsx @@ -86,7 +86,6 @@ function MoneyRequestReportListFooter({report, policy, transactions, hasPendingA const formattedTaxAmount = convertToDisplayString(taxTotal, report?.currency); const shouldShowExpenseReportBreakDown = hasNonReimbursableTransactions(transactions); const shouldShowBreakdown = shouldShowExpenseReportBreakDown || !!billableTotal || (!!taxTotal && isTaxEnabled); - // Non-workspace reports have no policy, so the create-expenses gate only applies to policy-bound reports. const shouldShowAddExpenseButton = (!report?.policyID || canCreateExpensesOnPolicy(policy)) && canAddTransaction(report, rules, isReportArchived) && isCurrentUserSubmitter(report); const breakdownRows: Array<{text: TranslationPaths; value: string; shouldShow: boolean}> = [ diff --git a/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx b/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx index b1018984e3e9..fd8c10107350 100644 --- a/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx +++ b/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx @@ -48,7 +48,6 @@ function SearchMoneyRequestReportEmptyState({report, policy, onLayout}: {report: const reportId = report.reportID; const isReportArchived = isArchivedReport(reportNameValuePairs); const icons = useMemoizedLazyExpensifyIcons(['ReceiptPlus']); - // Non-workspace reports have no policy, so the create-expenses gate only applies to policy-bound reports. const canAddTransactionToReport = (!report?.policyID || canCreateExpensesOnPolicy(policy)) && canAddTransaction(report, rules, isReportArchived); const blockDistanceRequestIfNeeded = useBlockDistanceRequest({ policyID: policy?.id, diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index eca489fead7b..a9e71a7af323 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -1089,7 +1089,6 @@ function getSecondaryReportActions({ options.push(CONST.REPORT.SECONDARY_ACTIONS.PAY); } - // Non-workspace reports have no policy, so the create-expenses gate only applies to policy-bound reports. if (isAddExpenseAction(report, reportTransactions, rules, isChatReportArchived || isArchivedReport(reportNameValuePairs)) && (!report?.policyID || canCreateExpensesOnPolicy(policy))) { options.push(CONST.REPORT.SECONDARY_ACTIONS.ADD_EXPENSE); } From 8aaa59cd178f69e6b92dae82c9809a354f457dbb Mon Sep 17 00:00:00 2001 From: Shridhar Goel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:11:55 +0530 Subject: [PATCH 50/56] Update src/libs/actions/Policy/Member.ts Co-authored-by: Florent De'Neve --- src/libs/actions/Policy/Member.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index a7beb3afc831..33a2ffb714b0 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -874,7 +874,7 @@ function buildAddMembersToWorkspaceOnyxData( doesPersonalDetailExistByAccountID[accountID] = !newPersonalDetailAccountIDs.has(accountID); } - // Approve-only members get no workspace chat from the backend, so building an optimistic one would leave a phantom report in Onyx. + // Roles that cannot create expenses don't get a workspace chat, so don't build an optimistic one. const membersChats = canRoleCreateExpenses(effectiveRole) ? createPolicyExpenseChats({ policyID, From 1f8402936790119df0347945f72ab84ec3a1bcfa Mon Sep 17 00:00:00 2001 From: Shridhar Goel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:12:19 +0530 Subject: [PATCH 51/56] Update src/libs/PolicyUtils/permissions.ts Co-authored-by: Florent De'Neve --- src/libs/PolicyUtils/permissions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/PolicyUtils/permissions.ts b/src/libs/PolicyUtils/permissions.ts index 6ba72e53b329..a21b14b0d464 100644 --- a/src/libs/PolicyUtils/permissions.ts +++ b/src/libs/PolicyUtils/permissions.ts @@ -216,7 +216,7 @@ function canMemberAssignRole(policy: OnyxInputOrEntry, login: string, ro // Reaching here: USER always, plus GUEST/AUDITOR/APPROVE_ONLY only on corporate policies (control-only roles are // already filtered out on non-corporate policies above). Assigning these needs the - // MEMBERS permission, and only on corporate policies. APPROVE_ONLY stays within a People Admin's + // MEMBERS permission, and only on corporate policies. The following roles stays within a People Admin's // authority because it is less permissive than USER. const isNonElevatedRole = role === CONST.POLICY.ROLE.USER || role === CONST.POLICY.ROLE.GUEST || role === CONST.POLICY.ROLE.AUDITOR || role === CONST.POLICY.ROLE.APPROVE_ONLY; return isCorporatePolicy && canMemberWrite(policy, login, CONST.POLICY.POLICY_FEATURE.MEMBERS) && isNonElevatedRole; From 8ee6dcf03b8ca8f5f2479dfb729d429a818ea85c Mon Sep 17 00:00:00 2001 From: Shridhar Goel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:16:16 +0530 Subject: [PATCH 52/56] Update permissions.ts --- src/libs/PolicyUtils/permissions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/PolicyUtils/permissions.ts b/src/libs/PolicyUtils/permissions.ts index a21b14b0d464..b6c4ba825ccc 100644 --- a/src/libs/PolicyUtils/permissions.ts +++ b/src/libs/PolicyUtils/permissions.ts @@ -216,8 +216,8 @@ function canMemberAssignRole(policy: OnyxInputOrEntry, login: string, ro // Reaching here: USER always, plus GUEST/AUDITOR/APPROVE_ONLY only on corporate policies (control-only roles are // already filtered out on non-corporate policies above). Assigning these needs the - // MEMBERS permission, and only on corporate policies. The following roles stays within a People Admin's - // authority because it is less permissive than USER. + // MEMBERS permission, and only on corporate policies. The following roles stay within a People Admin's + // authority because they are less permissive than the "People Admin" role. const isNonElevatedRole = role === CONST.POLICY.ROLE.USER || role === CONST.POLICY.ROLE.GUEST || role === CONST.POLICY.ROLE.AUDITOR || role === CONST.POLICY.ROLE.APPROVE_ONLY; return isCorporatePolicy && canMemberWrite(policy, login, CONST.POLICY.POLICY_FEATURE.MEMBERS) && isNonElevatedRole; } From 2deaad14bff4228cf81959e60321ab81c3923a1b Mon Sep 17 00:00:00 2001 From: Shridhar Goel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:18:51 +0530 Subject: [PATCH 53/56] Apply batched suggestions from code review Co-authored-by: Florent De'Neve --- .../workspace/members/WorkspaceMemberDetailsRolePage.tsx | 2 +- src/types/onyx/BlockedRoleChange.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index cb42d5b4e557..90a8592492a8 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -92,7 +92,7 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp confirmButtonOptions={{ showButton: true, text: translate('common.save'), - onConfirm: saveRole, + onConfirm: singleExecution(saveRole), isDisabled: !selectedRole || selectedRole === member?.role, }} /> diff --git a/src/types/onyx/BlockedRoleChange.ts b/src/types/onyx/BlockedRoleChange.ts index f6e708e0deb4..1e55fc32f9b3 100644 --- a/src/types/onyx/BlockedRoleChange.ts +++ b/src/types/onyx/BlockedRoleChange.ts @@ -1,12 +1,12 @@ /** Model of a refused approve-only role change, delivered to the requesting client to show in a modal */ type BlockedRoleChange = { - /** Workspace the refused role change targeted */ + /** Policy the refused role change targeted */ policyID: string; /** Member whose role change was refused */ email: string; - /** Reason keys from Auth explaining why the role was refused */ + /** Reason keys from the API explaining why the role was refused */ blockedReasons: string[]; /** Time in milliseconds when the refusal was generated */ From 166d64f9864617d500d8b044873e9cfc9933c2b3 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:20:41 +0530 Subject: [PATCH 54/56] Remove created field from blocked role change --- src/hooks/useApproveOnlyRoleBlockedModal.ts | 11 +++++++---- .../members/WorkspaceMemberDetailsRolePage.tsx | 2 ++ src/types/onyx/BlockedRoleChange.ts | 3 --- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/hooks/useApproveOnlyRoleBlockedModal.ts b/src/hooks/useApproveOnlyRoleBlockedModal.ts index 57a0aa476c68..37f1e61cb985 100644 --- a/src/hooks/useApproveOnlyRoleBlockedModal.ts +++ b/src/hooks/useApproveOnlyRoleBlockedModal.ts @@ -7,6 +7,9 @@ import {clearBlockedRoleChange, clearUpdateMemberRoleError} from '@userActions/P import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; +import type BlockedRoleChange from '@src/types/onyx/BlockedRoleChange'; + +import type {OnyxEntry} from 'react-native-onyx'; import {useEffect, useRef} from 'react'; @@ -26,11 +29,11 @@ function useApproveOnlyRoleBlockedModal() { const {translate} = useLocalize(); const {showConfirmModal, closeModalByID} = useConfirmModal(); const [blockedRoleChange] = useOnyx(ONYXKEYS.BLOCKED_ROLE_CHANGE); - const lastShownCreatedRef = useRef(0); + const lastShownRef = useRef>(undefined); const isModalOpenRef = useRef(false); useEffect(() => { - if (!blockedRoleChange?.created) { + if (!blockedRoleChange) { if (isModalOpenRef.current) { isModalOpenRef.current = false; closeModalByID(APPROVE_ONLY_ROLE_BLOCKED_MODAL_ID); @@ -38,10 +41,10 @@ function useApproveOnlyRoleBlockedModal() { return; } - if (lastShownCreatedRef.current === blockedRoleChange.created) { + if (lastShownRef.current === blockedRoleChange) { return; } - lastShownCreatedRef.current = blockedRoleChange.created; + lastShownRef.current = blockedRoleChange; isModalOpenRef.current = true; const {policyID, email, blockedReasons = []} = blockedRoleChange; diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx index 90a8592492a8..9c9ae213425a 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsRolePage.tsx @@ -6,6 +6,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useLocalize from '@hooks/useLocalize'; import useRedirectSubmitWorkspaceFeatureUpgrade from '@hooks/useRedirectSubmitWorkspaceFeatureUpgrade'; import useRuleBotGuardModal from '@hooks/useRuleBotGuardModal'; +import useSingleExecution from '@hooks/useSingleExecution'; import {updateWorkspaceMembersRole} from '@libs/actions/Policy/Member'; import {isRuleBotEnforcingRules} from '@libs/AgentRulesUtils'; @@ -38,6 +39,7 @@ function WorkspaceMemberDetailsRolePage({policy, personalDetails, route}: Worksp const accountID = Number(route.params.accountID); const policyID = route.params.policyID; const showRuleBotGuardModal = useRuleBotGuardModal(); + const {singleExecution} = useSingleExecution(); const {translate} = useLocalize(); const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); const memberLogin = personalDetails?.[accountID]?.login ?? ''; diff --git a/src/types/onyx/BlockedRoleChange.ts b/src/types/onyx/BlockedRoleChange.ts index 1e55fc32f9b3..96f22d07ab1b 100644 --- a/src/types/onyx/BlockedRoleChange.ts +++ b/src/types/onyx/BlockedRoleChange.ts @@ -9,9 +9,6 @@ type BlockedRoleChange = { /** Reason keys from the API explaining why the role was refused */ blockedReasons: string[]; - /** Time in milliseconds when the refusal was generated */ - created: number; - /** Server error message */ message?: string; }; From edaeda5cc1dbdfdbccbeb403ccab1e508eb273fd Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:46:00 +0530 Subject: [PATCH 55/56] Centralize create-expenses gate in canAddTransaction --- .../MoneyRequestReportListFooter.tsx | 4 ++-- .../SearchMoneyRequestReportEmptyState.tsx | 3 +-- src/libs/Fullstory/types.ts | 7 +++++-- src/libs/PolicyUtils/permissions.ts | 2 +- src/libs/ReportPreviewActionUtils.ts | 3 +-- src/libs/ReportSecondaryActionUtils.ts | 3 +-- src/libs/ReportUtils.ts | 9 +++++---- src/types/onyx/BlockedRoleChange.ts | 3 --- tests/actions/IOU/GetMoneyRequestInformationTest.ts | 7 ++++++- 9 files changed, 22 insertions(+), 19 deletions(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportListFooter.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportListFooter.tsx index b986699d1766..e0fd15a511c0 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportListFooter.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportListFooter.tsx @@ -14,7 +14,7 @@ import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; import useThemeStyles from '@hooks/useThemeStyles'; import {getDistanceExpenseTypeForPolicy} from '@libs/PolicyDistanceRatesUtils'; -import {canCreateExpensesOnPolicy, isPolicyTaxEnabled} from '@libs/PolicyUtils'; +import {isPolicyTaxEnabled} from '@libs/PolicyUtils'; import {canAddTransaction, getBillableAndTaxTotal, getMoneyRequestSpendBreakdown, getReportOfflinePendingActionAndErrors, isCurrentUserSubmitter} from '@libs/ReportUtils'; import {hasNonReimbursableTransactions} from '@libs/TransactionUtils'; @@ -86,7 +86,7 @@ function MoneyRequestReportListFooter({report, policy, transactions, hasPendingA const formattedTaxAmount = convertToDisplayString(taxTotal, report?.currency); const shouldShowExpenseReportBreakDown = hasNonReimbursableTransactions(transactions); const shouldShowBreakdown = shouldShowExpenseReportBreakDown || !!billableTotal || (!!taxTotal && isTaxEnabled); - const shouldShowAddExpenseButton = (!report?.policyID || canCreateExpensesOnPolicy(policy)) && canAddTransaction(report, rules, isReportArchived) && isCurrentUserSubmitter(report); + const shouldShowAddExpenseButton = canAddTransaction(report, rules, isReportArchived) && isCurrentUserSubmitter(report); const breakdownRows: Array<{text: TranslationPaths; value: string; shouldShow: boolean}> = [ {text: 'cardTransactions.outOfPocket', value: formattedOutOfPocketAmount, shouldShow: !!nonReimbursableSpend}, diff --git a/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx b/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx index fd8c10107350..b8e51f2d52df 100644 --- a/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx +++ b/src/components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState.tsx @@ -8,7 +8,6 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {getDistanceExpenseTypeForPolicy} from '@libs/PolicyDistanceRatesUtils'; -import {canCreateExpensesOnPolicy} from '@libs/PolicyUtils'; import {canAddTransaction, isArchivedReport, isTeachersUniteReport} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import {cancelSpan} from '@libs/telemetry/activeSpans'; @@ -48,7 +47,7 @@ function SearchMoneyRequestReportEmptyState({report, policy, onLayout}: {report: const reportId = report.reportID; const isReportArchived = isArchivedReport(reportNameValuePairs); const icons = useMemoizedLazyExpensifyIcons(['ReceiptPlus']); - const canAddTransactionToReport = (!report?.policyID || canCreateExpensesOnPolicy(policy)) && canAddTransaction(report, rules, isReportArchived); + const canAddTransactionToReport = canAddTransaction(report, rules, isReportArchived); const blockDistanceRequestIfNeeded = useBlockDistanceRequest({ policyID: policy?.id, isDistanceRequest: true, diff --git a/src/libs/Fullstory/types.ts b/src/libs/Fullstory/types.ts index 21be38fa85d5..a65a9645f7b3 100644 --- a/src/libs/Fullstory/types.ts +++ b/src/libs/Fullstory/types.ts @@ -1,8 +1,10 @@ +import type {POLICY_ROLE_RANKING} from '@libs/PolicyUtils/permissions'; + import type CONST from '@src/CONST'; import type {OnyxInputOrEntry, Report, Session, UserMetadata} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; +import type {TupleToUnion, ValueOf} from 'type-fest'; type FSClass = ValueOf; @@ -15,7 +17,8 @@ type FullstoryUserVars = { user_status?: 'new' | 'returning'; has_completed_onboarding?: boolean; onb_step?: 'registration' | 'accounting' | 'completed'; - user_role?: 'admin' | 'paymentsAdmin' | 'cardAdmin' | 'peopleAdmin' | 'auditor' | 'editor' | 'member' | 'approveOnly' | 'guest'; + // Every ranked policy role, with USER renamed to FullStory's 'member'. + user_role?: Exclude, typeof CONST.POLICY.ROLE.USER> | 'member'; workspace_state?: 'has_workspaces' | 'no_workspaces'; workspace_count?: number; workspace_member_count?: number; diff --git a/src/libs/PolicyUtils/permissions.ts b/src/libs/PolicyUtils/permissions.ts index b6c4ba825ccc..4a02019e9b64 100644 --- a/src/libs/PolicyUtils/permissions.ts +++ b/src/libs/PolicyUtils/permissions.ts @@ -279,7 +279,7 @@ function getPolicyRole(policy: OnyxInputOrEntry, currentUserLogin?: stri /** * Workspace roles from highest to lowest privilege, matching the role hierarchy in Auth. */ -const POLICY_ROLE_RANKING = [ +export const POLICY_ROLE_RANKING = [ CONST.POLICY.ROLE.ADMIN, CONST.POLICY.ROLE.PAYMENTS_ADMIN, CONST.POLICY.ROLE.CARD_ADMIN, diff --git a/src/libs/ReportPreviewActionUtils.ts b/src/libs/ReportPreviewActionUtils.ts index af3f29683d99..1741e3122864 100644 --- a/src/libs/ReportPreviewActionUtils.ts +++ b/src/libs/ReportPreviewActionUtils.ts @@ -7,7 +7,6 @@ import type {ValueOf} from 'type-fest'; import { arePaymentsEnabled, canAdminPayReport, - canCreateExpensesOnPolicy, getSubmitToAccountID, getValidConnectedIntegration, hasDynamicExternalWorkflow, @@ -272,7 +271,7 @@ function getReportPreviewAction({ if (isSubmittingAnimationRunning) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.SUBMIT; } - if (isAddExpenseAction(report, transactions ?? [], isReportArchived, rules) && canCreateExpensesOnPolicy(policy)) { + if (isAddExpenseAction(report, transactions ?? [], isReportArchived, rules)) { return CONST.REPORT.REPORT_PREVIEW_ACTIONS.ADD_EXPENSE; } diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index a9e71a7af323..72df9606c94f 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -33,7 +33,6 @@ import { getValidConnectedIntegration, hasDynamicExternalWorkflow, isArchivedOrPendingDeletePolicy, - canCreateExpensesOnPolicy, isGroupPolicy, isInstantSubmitEnabled, isPolicyAdmin, @@ -1089,7 +1088,7 @@ function getSecondaryReportActions({ options.push(CONST.REPORT.SECONDARY_ACTIONS.PAY); } - if (isAddExpenseAction(report, reportTransactions, rules, isChatReportArchived || isArchivedReport(reportNameValuePairs)) && (!report?.policyID || canCreateExpensesOnPolicy(policy))) { + if (isAddExpenseAction(report, reportTransactions, rules, isChatReportArchived || isArchivedReport(reportNameValuePairs))) { options.push(CONST.REPORT.SECONDARY_ACTIONS.ADD_EXPENSE); } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index d6320b6e639b..7ae7e8756a27 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -3185,6 +3185,10 @@ function canAddTransaction(moneyRequestReport: OnyxEntry, rules: OnyxCol // This will be fixed as part of https://github.com/Expensify/Expensify/issues/507850 const policy = getPolicy(moneyRequestReport?.policyID); + if (policy && !canCreateExpensesOnPolicy(policy)) { + return false; + } + if (isExpenseReport(moneyRequestReport) && (!isCurrentUserSubmitter(moneyRequestReport) || !isGroupPolicyPolicyUtils(policy))) { return false; } @@ -11637,10 +11641,7 @@ function canRequestMoney(report: OnyxEntry, policy: OnyxEntry, o return false; } - let isOwnPolicyExpenseChat = report?.isOwnPolicyExpenseChat ?? false; - if (isExpenseReport(report) && getParentReport(report)) { - isOwnPolicyExpenseChat = !!getParentReport(report)?.isOwnPolicyExpenseChat; - } + const isOwnPolicyExpenseChat = report?.isOwnPolicyExpenseChat ?? false; // In case there are no other participants than the current user and it's not user's own policy expense chat, they can't submit expenses from such report. // Expense reports are exempt because they are gated by canAddTransaction below, the same check the "Add expense" button uses. An empty expense report diff --git a/src/types/onyx/BlockedRoleChange.ts b/src/types/onyx/BlockedRoleChange.ts index 96f22d07ab1b..3d51177699c0 100644 --- a/src/types/onyx/BlockedRoleChange.ts +++ b/src/types/onyx/BlockedRoleChange.ts @@ -8,9 +8,6 @@ type BlockedRoleChange = { /** Reason keys from the API explaining why the role was refused */ blockedReasons: string[]; - - /** Server error message */ - message?: string; }; export default BlockedRoleChange; diff --git a/tests/actions/IOU/GetMoneyRequestInformationTest.ts b/tests/actions/IOU/GetMoneyRequestInformationTest.ts index 5b6dcf8fe5c0..b9ea2f99f926 100644 --- a/tests/actions/IOU/GetMoneyRequestInformationTest.ts +++ b/tests/actions/IOU/GetMoneyRequestInformationTest.ts @@ -177,7 +177,12 @@ describe('getMoneyRequestInformation', () => { const moneyRequestReportID = 'iou-report-lookup-1'; const differentPolicyID = 'policy-different'; await Onyx.set(ONYXKEYS.SESSION, {accountID: PAYEE_ACCOUNT_ID, email: 'payee@example.com'}); - await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${differentPolicyID}`, {id: differentPolicyID, type: CONST.POLICY.TYPE.CORPORATE, name: 'Test Policy'}); + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${differentPolicyID}`, { + id: differentPolicyID, + type: CONST.POLICY.TYPE.CORPORATE, + name: 'Test Policy', + role: CONST.POLICY.ROLE.USER, + }); await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReportID}`, { reportID: moneyRequestReportID, policyID: differentPolicyID, From b68bbd5f61162df4db27dacddbf3f6b54d9c60f6 Mon Sep 17 00:00:00 2001 From: ShridharGoel <35566748+ShridharGoel@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:18:03 +0530 Subject: [PATCH 56/56] Move POLICY_ROLE_RANKING export to export block --- src/libs/PolicyUtils/permissions.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/PolicyUtils/permissions.ts b/src/libs/PolicyUtils/permissions.ts index 4a02019e9b64..5b89f3a04516 100644 --- a/src/libs/PolicyUtils/permissions.ts +++ b/src/libs/PolicyUtils/permissions.ts @@ -279,7 +279,7 @@ function getPolicyRole(policy: OnyxInputOrEntry, currentUserLogin?: stri /** * Workspace roles from highest to lowest privilege, matching the role hierarchy in Auth. */ -export const POLICY_ROLE_RANKING = [ +const POLICY_ROLE_RANKING = [ CONST.POLICY.ROLE.ADMIN, CONST.POLICY.ROLE.PAYMENTS_ADMIN, CONST.POLICY.ROLE.CARD_ADMIN, @@ -397,6 +397,7 @@ function canEditWorkspaceSettings(policy: OnyxInputOrEntry, login?: stri export { isPolicyAdmin, isPolicyOwner, + POLICY_ROLE_RANKING, isRoomMemberProtectedByPolicyRole, isControlPolicyOnlyRole, canMemberRead,