mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 13:53:34 +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>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
class="filter-option"
|
class="filter-option tw-flex tw-items-center tw-gap-2 [&>span]:tw-w-min"
|
||||||
*ngIf="!hideArchive"
|
|
||||||
[ngClass]="{ active: activeFilter.status === 'archive' }"
|
[ngClass]="{ active: activeFilter.status === 'archive' }"
|
||||||
|
*ngIf="!hideArchive"
|
||||||
>
|
>
|
||||||
<span class="filter-buttons">
|
<span class="filter-buttons">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="filter-button"
|
class="filter-button"
|
||||||
(click)="applyFilter('archive')"
|
(click)="handleArchiveFilter($event)"
|
||||||
[attr.aria-pressed]="activeFilter.status === 'archive'"
|
[attr.aria-pressed]="activeFilter.status === 'archive'"
|
||||||
>
|
>
|
||||||
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i> {{ "archiveNoun" | i18n }}
|
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i> {{ "archiveNoun" | i18n }}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
|
@if (!(canArchive$ | async)) {
|
||||||
|
<app-premium-badge></app-premium-badge>
|
||||||
|
}
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
class="filter-option"
|
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 { 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
|
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
// 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",
|
templateUrl: "status-filter.component.html",
|
||||||
standalone: false,
|
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 { CommonModule } from "@angular/common";
|
||||||
import { NgModule } from "@angular/core";
|
import { NgModule } from "@angular/core";
|
||||||
|
|
||||||
|
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
|
||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import { DeprecatedVaultFilterService as DeprecatedVaultFilterServiceAbstraction } from "@bitwarden/angular/vault/abstractions/deprecated-vault-filter.service";
|
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";
|
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";
|
import { VaultFilterComponent } from "./vault-filter.component";
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [CommonModule, JslibModule],
|
imports: [CommonModule, JslibModule, PremiumBadgeComponent],
|
||||||
declarations: [
|
declarations: [
|
||||||
VaultFilterComponent,
|
VaultFilterComponent,
|
||||||
CollectionFilterComponent,
|
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({
|
menu.push({
|
||||||
label: this.i18nService.t("archiveVerb"),
|
label: this.i18nService.t("archiveVerb"),
|
||||||
click: async () => {
|
click: async () => {
|
||||||
|
if (!userCanArchive) {
|
||||||
|
await this.premiumUpgradePromptService.promptForPremium();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.archiveCipherUtilitiesService.archiveCipher(cipher);
|
await this.archiveCipherUtilitiesService.archiveCipher(cipher);
|
||||||
await this.refreshCurrentCipher();
|
await this.refreshCurrentCipher();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,5 +4,6 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"emitDecoratorMetadata": false
|
"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.folders$ = await this.vaultFilterService.buildNestedFolders();
|
||||||
this.collections = await this.initCollections();
|
this.collections = await this.initCollections();
|
||||||
|
|
||||||
const userCanArchive = await firstValueFrom(
|
this.showArchiveVaultFilter = await firstValueFrom(
|
||||||
this.cipherArchiveService.userCanArchive$(this.activeUserId),
|
this.cipherArchiveService.hasArchiveFlagEnabled$(),
|
||||||
);
|
|
||||||
const showArchiveVault = await firstValueFrom(
|
|
||||||
this.cipherArchiveService.showArchiveVault$(this.activeUserId),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
this.showArchiveVaultFilter = userCanArchive || showArchiveVault;
|
|
||||||
this.isLoaded = true;
|
this.isLoaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user