From 3049cfad7dfa1f6c7975ff47d7f4d08822eadf18 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Tue, 16 Dec 2025 15:47:58 -0600 Subject: [PATCH] [PM-29103] Premium Prompt for Archive (#17800) * prompt for premium using badge workflow rather than premium page * update test * address claude feedback --- .../settings/vault-settings-v2.component.html | 9 +- .../vault-settings-v2.component.spec.ts | 199 ++++++++++++++++++ .../settings/vault-settings-v2.component.ts | 18 +- 3 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 apps/browser/src/vault/popup/settings/vault-settings-v2.component.spec.ts diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html index c042af8cbac..407015d3a06 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html +++ b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html @@ -37,14 +37,19 @@ @if (showArchiveItem()) { @if (userCanArchive()) { - + {{ "archiveNoun" | i18n }} } @else { - + {{ "archiveNoun" | i18n }} @if (!userHasArchivedItems()) { diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.spec.ts b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.spec.ts new file mode 100644 index 00000000000..fc30a3f8899 --- /dev/null +++ b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.spec.ts @@ -0,0 +1,199 @@ +import { ChangeDetectionStrategy, Component, DebugElement, input } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { provideRouter, Router } from "@angular/router"; +import { mock } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { NudgesService } from "@bitwarden/angular/vault"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { DialogService, ToastService } from "@bitwarden/components"; + +import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; +import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; + +import { VaultSettingsV2Component } from "./vault-settings-v2.component"; + +@Component({ + selector: "popup-header", + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class MockPopupHeaderComponent { + readonly pageTitle = input(); + readonly showBackButton = input(); +} + +@Component({ + selector: "popup-page", + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class MockPopupPageComponent {} + +@Component({ + selector: "app-pop-out", + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class MockPopOutComponent { + readonly show = input(true); +} + +describe("VaultSettingsV2Component", () => { + let component: VaultSettingsV2Component; + let fixture: ComponentFixture; + let router: Router; + let mockCipherArchiveService: jest.Mocked; + + const mockActiveAccount$ = new BehaviorSubject<{ id: string }>({ + id: "user-id", + }); + const mockUserCanArchive$ = new BehaviorSubject(false); + const mockHasArchiveFlagEnabled$ = new BehaviorSubject(true); + const mockArchivedCiphers$ = new BehaviorSubject([]); + const mockShowNudgeBadge$ = new BehaviorSubject(false); + + const queryByTestId = (testId: string): DebugElement | null => { + return fixture.debugElement.query(By.css(`[data-test-id="${testId}"]`)); + }; + + const setArchiveState = ( + canArchive: boolean, + archivedItems: CipherView[] = [], + flagEnabled = true, + ) => { + mockUserCanArchive$.next(canArchive); + mockArchivedCiphers$.next(archivedItems); + mockHasArchiveFlagEnabled$.next(flagEnabled); + fixture.detectChanges(); + }; + + beforeEach(async () => { + mockCipherArchiveService = mock({ + userCanArchive$: jest.fn().mockReturnValue(mockUserCanArchive$), + hasArchiveFlagEnabled$: jest.fn().mockReturnValue(mockHasArchiveFlagEnabled$), + archivedCiphers$: jest.fn().mockReturnValue(mockArchivedCiphers$), + }); + + await TestBed.configureTestingModule({ + imports: [VaultSettingsV2Component], + providers: [ + provideRouter([ + { path: "archive", component: VaultSettingsV2Component }, + { path: "premium", component: VaultSettingsV2Component }, + ]), + { provide: SyncService, useValue: mock() }, + { provide: ToastService, useValue: mock() }, + { provide: ConfigService, useValue: mock() }, + { provide: DialogService, useValue: mock() }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: CipherArchiveService, useValue: mockCipherArchiveService }, + { + provide: NudgesService, + useValue: { showNudgeBadge$: jest.fn().mockReturnValue(mockShowNudgeBadge$) }, + }, + + { + provide: BillingAccountProfileStateService, + useValue: mock(), + }, + { + provide: AccountService, + useValue: { activeAccount$: mockActiveAccount$ }, + }, + ], + }) + .overrideComponent(VaultSettingsV2Component, { + remove: { + imports: [PopupHeaderComponent, PopupPageComponent, PopOutComponent], + }, + add: { + imports: [MockPopupHeaderComponent, MockPopupPageComponent, MockPopOutComponent], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(VaultSettingsV2Component); + component = fixture.componentInstance; + router = TestBed.inject(Router); + jest.spyOn(router, "navigate"); + }); + + describe("archive link", () => { + it("shows direct archive link when user can archive", () => { + setArchiveState(true); + + const archiveLink = queryByTestId("archive-link"); + + expect(archiveLink.nativeElement.getAttribute("routerLink")).toBe("/archive"); + }); + + it("routes to archive when user has archived items but cannot archive", async () => { + setArchiveState(false, [{ id: "cipher1" } as CipherView]); + + const premiumArchiveLink = queryByTestId("premium-archive-link"); + + premiumArchiveLink.nativeElement.click(); + await fixture.whenStable(); + + expect(router.navigate).toHaveBeenCalledWith(["/archive"]); + }); + + it("prompts for premium when user cannot archive and has no archived items", async () => { + setArchiveState(false, []); + const badge = component["premiumBadgeComponent"](); + jest.spyOn(badge, "promptForPremium"); + + const premiumArchiveLink = queryByTestId("premium-archive-link"); + + premiumArchiveLink.nativeElement.click(); + await fixture.whenStable(); + + expect(badge.promptForPremium).toHaveBeenCalled(); + }); + }); + + describe("archive visibility", () => { + it("displays archive link when user can archive", () => { + setArchiveState(true); + + const archiveLink = queryByTestId("archive-link"); + + expect(archiveLink).toBeTruthy(); + expect(component["userCanArchive"]()).toBe(true); + }); + + it("hides archive link when feature flag is disabled", () => { + setArchiveState(false, [], false); + + const archiveLink = queryByTestId("archive-link"); + const premiumArchiveLink = queryByTestId("premium-archive-link"); + + expect(archiveLink).toBeNull(); + expect(premiumArchiveLink).toBeNull(); + expect(component["showArchiveItem"]()).toBe(false); + }); + + it("shows premium badge when user has no archived items and cannot archive", () => { + setArchiveState(false, []); + + expect(component["premiumBadgeComponent"]()).toBeTruthy(); + expect(component["userHasArchivedItems"]()).toBe(false); + }); + + it("hides premium badge when user has archived items", () => { + setArchiveState(false, [{ id: "cipher1" } as CipherView]); + + expect(component["premiumBadgeComponent"]()).toBeUndefined(); + expect(component["userHasArchivedItems"]()).toBe(true); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts index e085cb21c2d..c1d90d678cb 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts +++ b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from "@angular/common"; -import { Component, OnDestroy, OnInit } from "@angular/core"; +import { Component, OnDestroy, OnInit, viewChild } from "@angular/core"; import { toSignal } from "@angular/core/rxjs-interop"; import { Router, RouterModule } from "@angular/router"; import { firstValueFrom, map, switchMap } from "rxjs"; @@ -42,6 +42,8 @@ import { BrowserPremiumUpgradePromptService } from "../services/browser-premium- ], }) export class VaultSettingsV2Component implements OnInit, OnDestroy { + private readonly premiumBadgeComponent = viewChild(PremiumBadgeComponent); + lastSync = "--"; private userId$ = this.accountService.activeAccount$.pipe(getUserId); @@ -117,4 +119,18 @@ export class VaultSettingsV2Component implements OnInit, OnDestroy { this.lastSync = this.i18nService.t("never"); } } + + /** + * When a user can archive or has previously archived items, route them to + * the archive page. Otherwise, prompt them to upgrade to premium. + */ + async conditionallyRouteToArchive(event: Event) { + event.preventDefault(); + const premiumBadge = this.premiumBadgeComponent(); + if (this.userCanArchive() || this.userHasArchivedItems()) { + await this.router.navigate(["/archive"]); + } else if (premiumBadge) { + await premiumBadge.promptForPremium(event); + } + } }