diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 7ce9ef45d69..df4b9a9001c 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -4272,6 +4272,21 @@ "filters": { "message": "Filters" }, + "filterVault": { + "message": "Filter vault" + }, + "filterApplied": { + "message": "One filter applied" + }, + "filterAppliedPlural": { + "message": "$COUNT$ filters applied", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "personalDetails": { "message": "Personal details" }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.html new file mode 100644 index 00000000000..05deeec0d3d --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.html @@ -0,0 +1,38 @@ +
+
+ +
+
+ +

+ {{ supportingText }} +

+
+ {{ numberOfAppliedFilters$ | async }} +
+
+
+ +
+ +
+
diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts new file mode 100644 index 00000000000..38ec6056d19 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts @@ -0,0 +1,162 @@ +import { CommonModule } from "@angular/common"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormBuilder } from "@angular/forms"; +import { By } from "@angular/platform-browser"; +import { ActivatedRoute } from "@angular/router"; +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, Subject } from "rxjs"; + +import { CollectionService } from "@bitwarden/admin-console/common"; +import { SearchService } from "@bitwarden/common/abstractions/search.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { MessageSender } from "@bitwarden/common/platform/messaging"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { AutofillService } from "../../../../../autofill/services/abstractions/autofill.service"; +import { VaultPopupItemsService } from "../../../../../vault/popup/services/vault-popup-items.service"; +import { + PopupListFilter, + VaultPopupListFiltersService, +} from "../../../../../vault/popup/services/vault-popup-list-filters.service"; + +import { VaultHeaderV2Component } from "./vault-header-v2.component"; + +describe("VaultHeaderV2Component", () => { + let component: VaultHeaderV2Component; + let fixture: ComponentFixture; + + const emptyForm: PopupListFilter = { + organization: null, + collection: null, + folder: null, + cipherType: null, + }; + + const numberOfAppliedFilters$ = new BehaviorSubject(0); + const state$ = new Subject(); + + // Mock state provider update + const update = jest.fn().mockResolvedValue(undefined); + + /** When it exists, returns the notification badge debug element */ + const getBadge = () => fixture.debugElement.query(By.css('[data-testid="filter-badge"]')); + + beforeEach(async () => { + update.mockClear(); + + await TestBed.configureTestingModule({ + imports: [VaultHeaderV2Component, CommonModule], + providers: [ + { + provide: CipherService, + useValue: mock({ cipherViews$: new BehaviorSubject([]) }), + }, + { provide: VaultSettingsService, useValue: mock() }, + { provide: FolderService, useValue: mock() }, + { provide: OrganizationService, useValue: mock() }, + { provide: CollectionService, useValue: mock() }, + { provide: PolicyService, useValue: mock() }, + { provide: SearchService, useValue: mock() }, + { provide: PlatformUtilsService, useValue: mock() }, + { provide: AutofillService, useValue: mock() }, + { provide: PasswordRepromptService, useValue: mock() }, + { provide: MessageSender, useValue: mock() }, + { provide: AccountService, useValue: mock() }, + { provide: LogService, useValue: mock() }, + { + provide: VaultPopupItemsService, + useValue: mock({ latestSearchText$: new BehaviorSubject("") }), + }, + { + provide: SyncService, + useValue: mock({ activeUserLastSync$: () => new Subject() }), + }, + { provide: ActivatedRoute, useValue: { queryParams: new BehaviorSubject({}) } }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + { + provide: VaultPopupListFiltersService, + useValue: { + numberOfAppliedFilters$, + filters$: new BehaviorSubject(emptyForm), + filterForm: new FormBuilder().group(emptyForm), + filterVisibilityState$: state$, + updateFilterVisibility: update, + }, + }, + { + provide: StateProvider, + useValue: { getGlobal: () => ({ state$, update }) }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(VaultHeaderV2Component); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("does not show filter badge when no filters are selected", () => { + state$.next(false); + numberOfAppliedFilters$.next(0); + fixture.detectChanges(); + + expect(getBadge()).toBeNull(); + }); + + it("does not show filter badge when disclosure is open", () => { + state$.next(true); + numberOfAppliedFilters$.next(1); + fixture.detectChanges(); + + expect(getBadge()).toBeNull(); + }); + + it("shows the notification badge when there are populated filters and the disclosure is closed", async () => { + state$.next(false); + numberOfAppliedFilters$.next(1); + fixture.detectChanges(); + + expect(getBadge()).not.toBeNull(); + }); + + it("displays the number of filters populated", () => { + numberOfAppliedFilters$.next(1); + state$.next(false); + fixture.detectChanges(); + + expect(getBadge().nativeElement.textContent.trim()).toBe("1"); + + numberOfAppliedFilters$.next(2); + + fixture.detectChanges(); + + expect(getBadge().nativeElement.textContent.trim()).toBe("2"); + + numberOfAppliedFilters$.next(4); + + fixture.detectChanges(); + + expect(getBadge().nativeElement.textContent.trim()).toBe("4"); + }); + + it("defaults the initial state to true", (done) => { + // The initial value of the `state$` variable above is undefined + component["initialDisclosureVisibility$"].subscribe((initialVisibility) => { + expect(initialVisibility).toBeTrue(); + done(); + }); + + // Update the state to null + state$.next(null); + }); +}); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.ts new file mode 100644 index 00000000000..c7183f6fa28 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.ts @@ -0,0 +1,70 @@ +import { CommonModule } from "@angular/common"; +import { Component, inject, NgZone, ViewChild } from "@angular/core"; +import { combineLatest, map, take } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DisclosureTriggerForDirective, IconButtonModule } from "@bitwarden/components"; + +import { DisclosureComponent } from "../../../../../../../../libs/components/src/disclosure/disclosure.component"; +import { runInsideAngular } from "../../../../../platform/browser/run-inside-angular.operator"; +import { VaultPopupListFiltersService } from "../../../../../vault/popup/services/vault-popup-list-filters.service"; +import { VaultListFiltersComponent } from "../vault-list-filters/vault-list-filters.component"; +import { VaultV2SearchComponent } from "../vault-search/vault-v2-search.component"; + +@Component({ + selector: "app-vault-header-v2", + templateUrl: "vault-header-v2.component.html", + standalone: true, + imports: [ + VaultV2SearchComponent, + VaultListFiltersComponent, + DisclosureComponent, + IconButtonModule, + DisclosureTriggerForDirective, + CommonModule, + JslibModule, + ], +}) +export class VaultHeaderV2Component { + @ViewChild(DisclosureComponent) disclosure: DisclosureComponent; + + /** Emits the visibility status of the disclosure component. */ + protected isDisclosureShown$ = this.vaultPopupListFiltersService.filterVisibilityState$.pipe( + runInsideAngular(inject(NgZone)), // Browser state updates can happen outside of `ngZone` + map((v) => v ?? true), + ); + + // Only use the first value to avoid an infinite loop from two-way binding + protected initialDisclosureVisibility$ = this.isDisclosureShown$.pipe(take(1)); + + protected numberOfAppliedFilters$ = this.vaultPopupListFiltersService.numberOfAppliedFilters$; + + /** Emits true when the number of filters badge should be applied. */ + protected showBadge$ = combineLatest([ + this.numberOfAppliedFilters$, + this.isDisclosureShown$, + ]).pipe(map(([numberOfFilters, disclosureShown]) => numberOfFilters !== 0 && !disclosureShown)); + + protected buttonSupportingText$ = this.numberOfAppliedFilters$.pipe( + map((numberOfFilters) => { + if (numberOfFilters === 0) { + return null; + } + if (numberOfFilters === 1) { + return this.i18nService.t("filterApplied"); + } + + return this.i18nService.t("filterAppliedPlural", numberOfFilters); + }), + ); + + constructor( + private vaultPopupListFiltersService: VaultPopupListFiltersService, + private i18nService: I18nService, + ) {} + + async toggleFilters(isShown: boolean) { + await this.vaultPopupListFiltersService.updateFilterVisibility(isShown); + } +} diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.html index d9c4fbeee15..56f35c41f6d 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-filters/vault-list-filters.component.html @@ -1,4 +1,4 @@ -
+
- - -
+ + diff --git a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html index 9eba5f8f906..4798ddf4dfb 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html @@ -27,8 +27,7 @@ slot="above-scroll-area" *ngIf="vaultState !== VaultStateEnum.Empty && !(loading$ | async)" > - - + diff --git a/apps/browser/src/vault/popup/components/vault/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault/vault-v2.component.ts index 21c71332997..68aa40cbf62 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-v2.component.ts @@ -23,8 +23,7 @@ import { NewItemDropdownV2Component, NewItemInitialValues, } from "../vault-v2/new-item-dropdown/new-item-dropdown-v2.component"; -import { VaultListFiltersComponent } from "../vault-v2/vault-list-filters/vault-list-filters.component"; -import { VaultV2SearchComponent } from "../vault-v2/vault-search/vault-v2-search.component"; +import { VaultHeaderV2Component } from "../vault-v2/vault-header/vault-header-v2.component"; enum VaultState { Empty, @@ -46,12 +45,11 @@ enum VaultState { CommonModule, AutofillVaultListItemsComponent, VaultListItemsContainerComponent, - VaultListFiltersComponent, ButtonModule, RouterLink, - VaultV2SearchComponent, NewItemDropdownV2Component, ScrollingModule, + VaultHeaderV2Component, ], providers: [VaultUiOnboardingService], }) diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts index 02ad7375f6a..580514de610 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts @@ -9,6 +9,7 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { ProductTierType } from "@bitwarden/common/billing/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { StateProvider } from "@bitwarden/common/platform/state"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -50,6 +51,9 @@ describe("VaultPopupListFiltersService", () => { policyAppliesToActiveUser$: jest.fn(() => policyAppliesToActiveUser$), }; + const state$ = new BehaviorSubject(false); + const update = jest.fn().mockResolvedValue(undefined); + beforeEach(() => { memberOrganizations$.next([]); decryptedCollections$.next([]); @@ -83,6 +87,10 @@ describe("VaultPopupListFiltersService", () => { provide: PolicyService, useValue: policyService, }, + { + provide: StateProvider, + useValue: { getGlobal: () => ({ state$, update }) }, + }, { provide: FormBuilder, useClass: FormBuilder }, ], }); @@ -102,6 +110,20 @@ describe("VaultPopupListFiltersService", () => { }); }); + describe("numberOfAppliedFilters$", () => { + it("updates as the form value changes", (done) => { + service.numberOfAppliedFilters$.subscribe((number) => { + expect(number).toBe(2); + done(); + }); + + service.filterForm.patchValue({ + organization: { id: "1234" } as Organization, + folder: { id: "folder11" } as FolderView, + }); + }); + }); + describe("organizations$", () => { it('does not add "myVault" to the list of organizations when there are no organizations', (done) => { memberOrganizations$.next([]); @@ -451,4 +473,24 @@ describe("VaultPopupListFiltersService", () => { }); }); }); + + describe("filterVisibilityState", () => { + it("exposes stored state through filterVisibilityState$", (done) => { + state$.next(true); + + service.filterVisibilityState$.subscribe((filterVisibility) => { + expect(filterVisibility).toBeTrue(); + done(); + }); + }); + + it("updates stored filter state", async () => { + await service.updateFilterVisibility(false); + + expect(update).toHaveBeenCalledOnce(); + // Get callback passed to `update` + const updateCallback = update.mock.calls[0][0]; + expect(updateCallback()).toBe(false); + }); + }); }); diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts index 590807cff60..32eaeb27d4e 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts @@ -6,6 +6,7 @@ import { distinctUntilChanged, map, Observable, + shareReplay, startWith, switchMap, tap, @@ -20,6 +21,11 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { ProductTierType } from "@bitwarden/common/billing/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { + KeyDefinition, + StateProvider, + VAULT_SETTINGS_DISK, +} from "@bitwarden/common/platform/state"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -29,6 +35,10 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; import { ChipSelectOption } from "@bitwarden/components"; +const FILTER_VISIBILITY_KEY = new KeyDefinition(VAULT_SETTINGS_DISK, "filterVisibility", { + deserializer: (obj) => obj, +}); + /** All available cipher filters */ export type PopupListFilter = { organization: Organization | null; @@ -66,6 +76,15 @@ export class VaultPopupListFiltersService { startWith(INITIAL_FILTERS), ) as Observable; + /** Emits the number of applied filters. */ + numberOfAppliedFilters$ = this.filters$.pipe( + map((filters) => Object.values(filters).filter((filter) => Boolean(filter)).length), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + /** Stored state for the visibility of the filters. */ + private filterVisibilityState = this.stateProvider.getGlobal(FILTER_VISIBILITY_KEY); + /** * Static list of ciphers views used in synchronous context */ @@ -89,12 +108,16 @@ export class VaultPopupListFiltersService { private collectionService: CollectionService, private formBuilder: FormBuilder, private policyService: PolicyService, + private stateProvider: StateProvider, ) { this.filterForm.controls.organization.valueChanges .pipe(takeUntilDestroyed()) .subscribe(this.validateOrganizationChange.bind(this)); } + /** Stored state for the visibility of the filters. */ + filterVisibilityState$ = this.filterVisibilityState.state$; + /** * Observable whose value is a function that filters an array of `CipherView` objects based on the current filters */ @@ -332,6 +355,11 @@ export class VaultPopupListFiltersService { ), ); + /** Updates the stored state for filter visibility. */ + async updateFilterVisibility(isVisible: boolean): Promise { + await this.filterVisibilityState.update(() => isVisible); + } + /** * Converts the given item into the `ChipSelectOption` structure */ diff --git a/libs/components/src/disclosure/disclosure.component.ts b/libs/components/src/disclosure/disclosure.component.ts index 58c67ad0f0e..518bf5f279d 100644 --- a/libs/components/src/disclosure/disclosure.component.ts +++ b/libs/components/src/disclosure/disclosure.component.ts @@ -1,4 +1,11 @@ -import { Component, HostBinding, Input, booleanAttribute } from "@angular/core"; +import { + Component, + EventEmitter, + HostBinding, + Input, + Output, + booleanAttribute, +} from "@angular/core"; let nextId = 0; @@ -8,14 +15,26 @@ let nextId = 0; template: ``, }) export class DisclosureComponent { + private _open: boolean; + + /** Emits the visibility of the disclosure content */ + @Output() openChange = new EventEmitter(); + /** * Optionally init the disclosure in its opened state */ - @Input({ transform: booleanAttribute }) open?: boolean = false; + @Input({ transform: booleanAttribute }) set open(isOpen: boolean) { + this._open = isOpen; + this.openChange.emit(isOpen); + } @HostBinding("class") get classList() { return this.open ? "" : "tw-hidden"; } @HostBinding("id") id = `bit-disclosure-${nextId++}`; + + get open(): boolean { + return this._open; + } }