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