1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[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
This commit is contained in:
Nick Krantz
2025-11-25 15:12:20 -06:00
committed by GitHub
parent 854f2abd28
commit 273f04c6a3
7 changed files with 157 additions and 14 deletions

View File

@@ -30,20 +30,23 @@
</span>
</li>
<li
class="filter-option"
*ngIf="!hideArchive"
class="filter-option tw-flex tw-items-center tw-gap-2 [&>span]:tw-w-min"
[ngClass]="{ active: activeFilter.status === 'archive' }"
*ngIf="!hideArchive"
>
<span class="filter-buttons">
<button
type="button"
class="filter-button"
(click)="applyFilter('archive')"
(click)="handleArchiveFilter($event)"
[attr.aria-pressed]="activeFilter.status === 'archive'"
>
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>&nbsp;{{ "archiveNoun" | i18n }}
</button>
</span>
@if (!(canArchive$ | async)) {
<app-premium-badge></app-premium-badge>
}
</li>
<li
class="filter-option"

View File

@@ -0,0 +1,98 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { StatusFilterComponent } from "./status-filter.component";
describe("StatusFilterComponent", () => {
let component: StatusFilterComponent;
let fixture: ComponentFixture<StatusFilterComponent>;
let cipherArchiveService: jest.Mocked<CipherArchiveService>;
let accountService: FakeAccountService;
const mockUserId = Utils.newGuid() as UserId;
const event = new Event("click");
beforeEach(async () => {
accountService = mockAccountServiceWith(mockUserId);
cipherArchiveService = mock<CipherArchiveService>();
await TestBed.configureTestingModule({
declarations: [StatusFilterComponent],
providers: [
{ provide: AccountService, useValue: accountService },
{ provide: CipherArchiveService, useValue: cipherArchiveService },
{ provide: PremiumUpgradePromptService, useValue: mock<PremiumUpgradePromptService>() },
{
provide: BillingAccountProfileStateService,
useValue: mock<BillingAccountProfileStateService>(),
},
{ 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();
});
});
});

View File

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

View File

@@ -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,

View File

@@ -565,10 +565,15 @@ export class VaultV2Component<C extends CipherViewLike>
}
}
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();
},

View File

@@ -4,5 +4,6 @@
"isolatedModules": true,
"emitDecoratorMetadata": false
},
"files": ["./test.setup.ts"]
"files": ["./test.setup.ts"],
"include": ["src/**/*.spec.ts"]
}

View File

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