Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PM-14019] Toggle Vault Filters #11929

Merged
merged 27 commits into from
Nov 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
60b7e14
move vault headings to their own component
nick-livefront Nov 7, 2024
82ae9de
update aria-label to bind to the data attribute
nick-livefront Nov 7, 2024
c577669
move vault headings to the vault-v2 folder
nick-livefront Nov 7, 2024
b2c50ff
integrate disclosure trigger to hide vault filters
nick-livefront Nov 7, 2024
39b2e25
remove built in margin on search component
nick-livefront Nov 7, 2024
4b062b0
add event emitter so consuming components can know when disclosure stโ€ฆ
nick-livefront Nov 8, 2024
0b2f620
add filter badge when filters are selected and the filters are hidden
nick-livefront Nov 8, 2024
1a8c58d
persist filter visibility state to disk
nick-livefront Nov 8, 2024
44e6928
add supporting text for the filter button
nick-livefront Nov 8, 2024
9270abf
remove extra file
nick-livefront Nov 8, 2024
8d8ac57
only read from stored state on component launch.
nick-livefront Nov 8, 2024
292c2c4
Merge branch 'main' into vault/pm-14019/hide-vault-filters
withinfocus Nov 11, 2024
69713d0
use two-way data binding for change event
nick-livefront Nov 12, 2024
07e9ee4
update vault headers to use two way data binds from disclosure component
nick-livefront Nov 12, 2024
9bd3ff6
add border thickness
nick-livefront Nov 12, 2024
fc50caf
add ticket to the FIXME
nick-livefront Nov 12, 2024
2b41dd3
move number of filters observable into service
nick-livefront Nov 12, 2024
5b98732
move state coordination into filter service
nick-livefront Nov 12, 2024
83d3cb3
Merge branch 'main' of https://github.com/bitwarden/clients into vaulโ€ฆ
nick-livefront Nov 12, 2024
3a9eb5f
only expose state and update methods from filter service
nick-livefront Nov 13, 2024
7923b1d
simplify observables to avoid needed state lifecycle methods
nick-livefront Nov 13, 2024
f4fa7ff
Merge branch 'main' of https://github.com/bitwarden/clients into vaulโ€ฆ
nick-livefront Nov 14, 2024
6e00bc5
remove comment
nick-livefront Nov 14, 2024
5a7183e
fix test imports
nick-livefront Nov 14, 2024
9266aef
Merge branch 'main' of https://github.com/bitwarden/clients into vaulโ€ฆ
nick-livefront Nov 18, 2024
64782db
update badge colors
nick-livefront Nov 18, 2024
440e37f
Merge branch 'main' into vault/pm-14019/hide-vault-filters
nick-livefront Nov 19, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/browser/src/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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);
});
});
shane-melton marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
<div class="tw-mb-2">
<bit-search
[placeholder]="'search' | i18n"
[(ngModel)]="searchText"
(ngModelChange)="onSearchTextChanged()"
appAutofocus
>
</bit-search>
</div>
<bit-search
[placeholder]="'search' | i18n"
[(ngModel)]="searchText"
(ngModelChange)="onSearchTextChanged()"
appAutofocus
>
</bit-search>
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@
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";

Check warning on line 26 in apps/browser/src/vault/popup/components/vault/vault-v2.component.ts

View check run for this annotation

Codecov / codecov/patch

apps/browser/src/vault/popup/components/vault/vault-v2.component.ts#L26

Added line #L26 was not covered by tests

enum VaultState {
Empty,
Expand All @@ -46,12 +45,11 @@
CommonModule,
AutofillVaultListItemsComponent,
VaultListItemsContainerComponent,
VaultListFiltersComponent,
ButtonModule,
RouterLink,
VaultV2SearchComponent,
NewItemDropdownV2Component,
ScrollingModule,
VaultHeaderV2Component,
],
providers: [VaultUiOnboardingService],
})
Expand Down
Loading
Loading