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:
@@ -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> {{ "archiveNoun" | i18n }}
|
||||
</button>
|
||||
</span>
|
||||
@if (!(canArchive$ | async)) {
|
||||
<app-premium-badge></app-premium-badge>
|
||||
}
|
||||
</li>
|
||||
<li
|
||||
class="filter-option"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
"isolatedModules": true,
|
||||
"emitDecoratorMetadata": false
|
||||
},
|
||||
"files": ["./test.setup.ts"]
|
||||
"files": ["./test.setup.ts"],
|
||||
"include": ["src/**/*.spec.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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user