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;
}