From 273f04c6a324bda033dc5d346cad26db8ff883ca Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Tue, 25 Nov 2025 15:12:20 -0600 Subject: [PATCH] [PM-26513] Desktop Archive Upgrade (#16964) * always shows desktop archive filter regardless of the users premium status * include spec files in tsconfig * add upgrade path for desktop * combine duplicate class instances * remove optional chaining * update tests to avoid null assertions * add test files to the spec tsconfig * implement signal for premium badge component * remove badge template reference --- .../filters/status-filter.component.html | 9 +- .../filters/status-filter.component.spec.ts | 98 +++++++++++++++++++ .../filters/status-filter.component.ts | 43 +++++++- .../vault/vault-filter/vault-filter.module.ts | 3 +- .../src/vault/app/vault/vault-v2.component.ts | 7 +- apps/desktop/tsconfig.spec.json | 3 +- .../components/vault-filter.component.ts | 8 +- 7 files changed, 157 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/vault/app/vault/vault-filter/filters/status-filter.component.spec.ts diff --git a/apps/desktop/src/vault/app/vault/vault-filter/filters/status-filter.component.html b/apps/desktop/src/vault/app/vault/vault-filter/filters/status-filter.component.html index 9cb98145cf..8b06477844 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/filters/status-filter.component.html +++ b/apps/desktop/src/vault/app/vault/vault-filter/filters/status-filter.component.html @@ -30,20 +30,23 @@
  • + @if (!(canArchive$ | async)) { + + }
  • { + let component: StatusFilterComponent; + let fixture: ComponentFixture; + let cipherArchiveService: jest.Mocked; + let accountService: FakeAccountService; + + const mockUserId = Utils.newGuid() as UserId; + const event = new Event("click"); + + beforeEach(async () => { + accountService = mockAccountServiceWith(mockUserId); + cipherArchiveService = mock(); + + await TestBed.configureTestingModule({ + declarations: [StatusFilterComponent], + providers: [ + { provide: AccountService, useValue: accountService }, + { provide: CipherArchiveService, useValue: cipherArchiveService }, + { provide: PremiumUpgradePromptService, useValue: mock() }, + { + provide: BillingAccountProfileStateService, + useValue: mock(), + }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + ], + imports: [JslibModule, PremiumBadgeComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(StatusFilterComponent); + component = fixture.componentInstance; + component.activeFilter = new VaultFilter(); + fixture.detectChanges(); + }); + + describe("handleArchiveFilter", () => { + const applyFilter = jest.fn(); + let promptForPremiumSpy: jest.SpyInstance; + + beforeEach(() => { + applyFilter.mockClear(); + component["applyFilter"] = applyFilter; + + promptForPremiumSpy = jest.spyOn(component["premiumBadgeComponent"]()!, "promptForPremium"); + }); + + it("should apply archive filter when userCanArchive returns true", async () => { + cipherArchiveService.userCanArchive$.mockReturnValue(of(true)); + cipherArchiveService.archivedCiphers$.mockReturnValue(of([])); + + await component["handleArchiveFilter"](event); + + expect(applyFilter).toHaveBeenCalledWith("archive"); + expect(promptForPremiumSpy).not.toHaveBeenCalled(); + }); + + it("should apply archive filter when userCanArchive returns false but hasArchivedCiphers is true", async () => { + const mockCipher = new CipherView(); + mockCipher.id = "test-id"; + + cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); + cipherArchiveService.archivedCiphers$.mockReturnValue(of([mockCipher])); + + await component["handleArchiveFilter"](event); + + expect(applyFilter).toHaveBeenCalledWith("archive"); + expect(promptForPremiumSpy).not.toHaveBeenCalled(); + }); + + it("should prompt for premium when userCanArchive returns false and hasArchivedCiphers is false", async () => { + cipherArchiveService.userCanArchive$.mockReturnValue(of(false)); + cipherArchiveService.archivedCiphers$.mockReturnValue(of([])); + + await component["handleArchiveFilter"](event); + + expect(applyFilter).not.toHaveBeenCalled(); + expect(promptForPremiumSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/desktop/src/vault/app/vault/vault-filter/filters/status-filter.component.ts b/apps/desktop/src/vault/app/vault/vault-filter/filters/status-filter.component.ts index db546f76a2..95ffd3f021 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/filters/status-filter.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-filter/filters/status-filter.component.ts @@ -1,6 +1,11 @@ -import { Component } from "@angular/core"; +import { Component, viewChild } from "@angular/core"; +import { combineLatest, firstValueFrom, map, switchMap } from "rxjs"; +import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { StatusFilterComponent as BaseStatusFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/status-filter.component"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -9,4 +14,38 @@ import { StatusFilterComponent as BaseStatusFilterComponent } from "@bitwarden/a templateUrl: "status-filter.component.html", standalone: false, }) -export class StatusFilterComponent extends BaseStatusFilterComponent {} +export class StatusFilterComponent extends BaseStatusFilterComponent { + private readonly premiumBadgeComponent = viewChild(PremiumBadgeComponent); + + private userId$ = this.accountService.activeAccount$.pipe(getUserId); + protected canArchive$ = this.userId$.pipe( + switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId)), + ); + + protected hasArchivedCiphers$ = this.userId$.pipe( + switchMap((userId) => + this.cipherArchiveService.archivedCiphers$(userId).pipe(map((ciphers) => ciphers.length > 0)), + ), + ); + + constructor( + private accountService: AccountService, + private cipherArchiveService: CipherArchiveService, + ) { + super(); + } + + protected async handleArchiveFilter(event: Event) { + const [canArchive, hasArchivedCiphers] = await firstValueFrom( + combineLatest([this.canArchive$, this.hasArchivedCiphers$]), + ); + + if (canArchive || hasArchivedCiphers) { + this.applyFilter("archive"); + } else if (this.premiumBadgeComponent()) { + // The `premiumBadgeComponent` should always be defined here, adding the + // if to satisfy TypeScript. + await this.premiumBadgeComponent().promptForPremium(event); + } + } +} diff --git a/apps/desktop/src/vault/app/vault/vault-filter/vault-filter.module.ts b/apps/desktop/src/vault/app/vault/vault-filter/vault-filter.module.ts index 8729996c83..54a6d33ca6 100644 --- a/apps/desktop/src/vault/app/vault/vault-filter/vault-filter.module.ts +++ b/apps/desktop/src/vault/app/vault/vault-filter/vault-filter.module.ts @@ -1,6 +1,7 @@ import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; +import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { DeprecatedVaultFilterService as DeprecatedVaultFilterServiceAbstraction } from "@bitwarden/angular/vault/abstractions/deprecated-vault-filter.service"; import { VaultFilterService } from "@bitwarden/angular/vault/vault-filter/services/vault-filter.service"; @@ -13,7 +14,7 @@ import { TypeFilterComponent } from "./filters/type-filter.component"; import { VaultFilterComponent } from "./vault-filter.component"; @NgModule({ - imports: [CommonModule, JslibModule], + imports: [CommonModule, JslibModule, PremiumBadgeComponent], declarations: [ VaultFilterComponent, CollectionFilterComponent, diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-v2.component.ts index bdade04bac..6c4ebe13f1 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -565,10 +565,15 @@ export class VaultV2Component } } - if (userCanArchive && !cipher.organizationId && !cipher.isDeleted && !cipher.isArchived) { + if (!cipher.organizationId && !cipher.isDeleted && !cipher.isArchived) { menu.push({ label: this.i18nService.t("archiveVerb"), click: async () => { + if (!userCanArchive) { + await this.premiumUpgradePromptService.promptForPremium(); + return; + } + await this.archiveCipherUtilitiesService.archiveCipher(cipher); await this.refreshCurrentCipher(); }, diff --git a/apps/desktop/tsconfig.spec.json b/apps/desktop/tsconfig.spec.json index d52d889aa7..e6627a8ce4 100644 --- a/apps/desktop/tsconfig.spec.json +++ b/apps/desktop/tsconfig.spec.json @@ -4,5 +4,6 @@ "isolatedModules": true, "emitDecoratorMetadata": false }, - "files": ["./test.setup.ts"] + "files": ["./test.setup.ts"], + "include": ["src/**/*.spec.ts"] } diff --git a/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts b/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts index 9b1d6286a9..659db1bb92 100644 --- a/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts +++ b/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts @@ -88,14 +88,10 @@ export class VaultFilterComponent implements OnInit { this.folders$ = await this.vaultFilterService.buildNestedFolders(); this.collections = await this.initCollections(); - const userCanArchive = await firstValueFrom( - this.cipherArchiveService.userCanArchive$(this.activeUserId), - ); - const showArchiveVault = await firstValueFrom( - this.cipherArchiveService.showArchiveVault$(this.activeUserId), + this.showArchiveVaultFilter = await firstValueFrom( + this.cipherArchiveService.hasArchiveFlagEnabled$(), ); - this.showArchiveVaultFilter = userCanArchive || showArchiveVault; this.isLoaded = true; }