1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-01-02 18:17:46 +01:00

[PM-14019] Toggle Vault Filters (#11929)

* move vault headings to their own component

* update aria-label to bind to the data attribute

* move vault headings to the vault-v2 folder

* integrate disclosure trigger to hide vault filters

* remove built in margin on search component

- spacing will be managed by the parent component

* add event emitter so consuming components can know when disclosure status has changed

* add filter badge when filters are selected and the filters are hidden

* persist filter visibility state to disk

* add supporting text for the filter button

* remove extra file

* only read from stored state on component launch.

- I noticed delays when trying to use stored state as the source of truth

* use two-way data binding for change event

* update vault headers to use two way data binds from disclosure component

- also adjust consuming changes

* add border thickness

* add ticket to the FIXME

* move number of filters observable into service

* move state coordination into filter service

* only expose state and update methods from filter service

* simplify observables to avoid needed state lifecycle methods

* remove comment

* fix test imports

* update badge colors

---------

Co-authored-by: Matt Bishop <mbishop@bitwarden.com>
This commit is contained in:
Nick Krantz 2024-11-19 11:49:42 -06:00 committed by GitHub
parent c17f582768
commit ca839b3d80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 387 additions and 18 deletions

View File

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

View File

@ -0,0 +1,38 @@
<div class="tw-flex tw-gap-1 tw-items-center">
<div class="tw-flex-1">
<app-vault-v2-search></app-vault-v2-search>
</div>
<div class="tw-relative">
<button
type="button"
bitIconButton="bwi-sliders"
[buttonType]="'muted'"
[bitDisclosureTriggerFor]="disclosureRef"
[appA11yTitle]="'filterVault' | i18n"
aria-describedby="filters-applied"
></button>
<p
class="tw-sr-only"
id="filters-applied"
*ngIf="buttonSupportingText$ | async as supportingText"
>
{{ supportingText }}
</p>
<div
*ngIf="showBadge$ | async"
class="tw-flex tw-items-center tw-justify-center tw-z-10 tw-absolute tw-rounded-full tw-h-[15px] tw-w-[15px] tw-top-[1px] tw-right-[1px] tw-text-notification-600 tw-text-[8px] tw-border-notification-600 tw-border-[0.5px] tw-border-solid tw-bg-notification-100 tw-leading-normal"
data-testid="filter-badge"
>
{{ numberOfAppliedFilters$ | async }}
</div>
</div>
</div>
<bit-disclosure
#disclosureRef
[open]="initialDisclosureVisibility$ | async"
(openChange)="toggleFilters($event)"
>
<div class="tw-pt-2">
<app-vault-list-filters></app-vault-list-filters>
</div>
</bit-disclosure>

View File

@ -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<VaultHeaderV2Component>;
const emptyForm: PopupListFilter = {
organization: null,
collection: null,
folder: null,
cipherType: null,
};
const numberOfAppliedFilters$ = new BehaviorSubject<number>(0);
const state$ = new Subject<boolean | null>();
// 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<CipherService>({ cipherViews$: new BehaviorSubject([]) }),
},
{ provide: VaultSettingsService, useValue: mock<VaultSettingsService>() },
{ provide: FolderService, useValue: mock<FolderService>() },
{ provide: OrganizationService, useValue: mock<OrganizationService>() },
{ provide: CollectionService, useValue: mock<CollectionService>() },
{ provide: PolicyService, useValue: mock<PolicyService>() },
{ provide: SearchService, useValue: mock<SearchService>() },
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
{ provide: AutofillService, useValue: mock<AutofillService>() },
{ provide: PasswordRepromptService, useValue: mock<PasswordRepromptService>() },
{ provide: MessageSender, useValue: mock<MessageSender>() },
{ provide: AccountService, useValue: mock<AccountService>() },
{ provide: LogService, useValue: mock<LogService>() },
{
provide: VaultPopupItemsService,
useValue: mock<VaultPopupItemsService>({ latestSearchText$: new BehaviorSubject("") }),
},
{
provide: SyncService,
useValue: mock<SyncService>({ 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);
});
});

View File

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

View File

@ -1,4 +1,4 @@
<div role="toolbar" [ariaLabel]="'filters' | i18n">
<div role="toolbar" [attr.aria-label]="'filters' | i18n">
<form
[formGroup]="filterForm"
class="tw-gap-2 tw-mt-2 tw-grid tw-grid-cols-2 sm:tw-grid-cols-3 lg:tw-grid-cols-4"

View File

@ -1,9 +1,7 @@
<div class="tw-mb-2">
<bit-search
<bit-search
[placeholder]="'search' | i18n"
[(ngModel)]="searchText"
(ngModelChange)="onSearchTextChanged()"
appAutofocus
>
</bit-search>
</div>
>
</bit-search>

View File

@ -27,8 +27,7 @@
slot="above-scroll-area"
*ngIf="vaultState !== VaultStateEnum.Empty && !(loading$ | async)"
>
<app-vault-v2-search> </app-vault-v2-search>
<app-vault-list-filters></app-vault-list-filters>
<app-vault-header-v2></app-vault-header-v2>
</ng-container>
<ng-container *ngIf="vaultState !== VaultStateEnum.Empty">

View File

@ -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],
})

View File

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

View File

@ -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<boolean>(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<PopupListFilter>;
/** 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<void> {
await this.filterVisibilityState.update(() => isVisible);
}
/**
* Converts the given item into the `ChipSelectOption` structure
*/

View File

@ -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: `<ng-content></ng-content>`,
})
export class DisclosureComponent {
private _open: boolean;
/** Emits the visibility of the disclosure content */
@Output() openChange = new EventEmitter<boolean>();
/**
* 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;
}
}