mirror of
https://github.com/bitwarden/browser.git
synced 2025-01-04 18:37:45 +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:
parent
c17f582768
commit
ca839b3d80
@ -4272,6 +4272,21 @@
|
|||||||
"filters": {
|
"filters": {
|
||||||
"message": "Filters"
|
"message": "Filters"
|
||||||
},
|
},
|
||||||
|
"filterVault": {
|
||||||
|
"message": "Filter vault"
|
||||||
|
},
|
||||||
|
"filterApplied": {
|
||||||
|
"message": "One filter applied"
|
||||||
|
},
|
||||||
|
"filterAppliedPlural": {
|
||||||
|
"message": "$COUNT$ filters applied",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"content": "$1",
|
||||||
|
"example": "3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"personalDetails": {
|
"personalDetails": {
|
||||||
"message": "Personal details"
|
"message": "Personal details"
|
||||||
},
|
},
|
||||||
|
@ -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>
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
<div role="toolbar" [ariaLabel]="'filters' | i18n">
|
<div role="toolbar" [attr.aria-label]="'filters' | i18n">
|
||||||
<form
|
<form
|
||||||
[formGroup]="filterForm"
|
[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"
|
class="tw-gap-2 tw-mt-2 tw-grid tw-grid-cols-2 sm:tw-grid-cols-3 lg:tw-grid-cols-4"
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
<div class="tw-mb-2">
|
<bit-search
|
||||||
<bit-search
|
|
||||||
[placeholder]="'search' | i18n"
|
[placeholder]="'search' | i18n"
|
||||||
[(ngModel)]="searchText"
|
[(ngModel)]="searchText"
|
||||||
(ngModelChange)="onSearchTextChanged()"
|
(ngModelChange)="onSearchTextChanged()"
|
||||||
appAutofocus
|
appAutofocus
|
||||||
>
|
>
|
||||||
</bit-search>
|
</bit-search>
|
||||||
</div>
|
|
||||||
|
@ -27,8 +27,7 @@
|
|||||||
slot="above-scroll-area"
|
slot="above-scroll-area"
|
||||||
*ngIf="vaultState !== VaultStateEnum.Empty && !(loading$ | async)"
|
*ngIf="vaultState !== VaultStateEnum.Empty && !(loading$ | async)"
|
||||||
>
|
>
|
||||||
<app-vault-v2-search> </app-vault-v2-search>
|
<app-vault-header-v2></app-vault-header-v2>
|
||||||
<app-vault-list-filters></app-vault-list-filters>
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container *ngIf="vaultState !== VaultStateEnum.Empty">
|
<ng-container *ngIf="vaultState !== VaultStateEnum.Empty">
|
||||||
|
@ -23,8 +23,7 @@ import {
|
|||||||
NewItemDropdownV2Component,
|
NewItemDropdownV2Component,
|
||||||
NewItemInitialValues,
|
NewItemInitialValues,
|
||||||
} from "../vault-v2/new-item-dropdown/new-item-dropdown-v2.component";
|
} from "../vault-v2/new-item-dropdown/new-item-dropdown-v2.component";
|
||||||
import { VaultListFiltersComponent } from "../vault-v2/vault-list-filters/vault-list-filters.component";
|
import { VaultHeaderV2Component } from "../vault-v2/vault-header/vault-header-v2.component";
|
||||||
import { VaultV2SearchComponent } from "../vault-v2/vault-search/vault-v2-search.component";
|
|
||||||
|
|
||||||
enum VaultState {
|
enum VaultState {
|
||||||
Empty,
|
Empty,
|
||||||
@ -46,12 +45,11 @@ enum VaultState {
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
AutofillVaultListItemsComponent,
|
AutofillVaultListItemsComponent,
|
||||||
VaultListItemsContainerComponent,
|
VaultListItemsContainerComponent,
|
||||||
VaultListFiltersComponent,
|
|
||||||
ButtonModule,
|
ButtonModule,
|
||||||
RouterLink,
|
RouterLink,
|
||||||
VaultV2SearchComponent,
|
|
||||||
NewItemDropdownV2Component,
|
NewItemDropdownV2Component,
|
||||||
ScrollingModule,
|
ScrollingModule,
|
||||||
|
VaultHeaderV2Component,
|
||||||
],
|
],
|
||||||
providers: [VaultUiOnboardingService],
|
providers: [VaultUiOnboardingService],
|
||||||
})
|
})
|
||||||
|
@ -9,6 +9,7 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
|||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||||
@ -50,6 +51,9 @@ describe("VaultPopupListFiltersService", () => {
|
|||||||
policyAppliesToActiveUser$: jest.fn(() => policyAppliesToActiveUser$),
|
policyAppliesToActiveUser$: jest.fn(() => policyAppliesToActiveUser$),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const state$ = new BehaviorSubject<boolean>(false);
|
||||||
|
const update = jest.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
memberOrganizations$.next([]);
|
memberOrganizations$.next([]);
|
||||||
decryptedCollections$.next([]);
|
decryptedCollections$.next([]);
|
||||||
@ -83,6 +87,10 @@ describe("VaultPopupListFiltersService", () => {
|
|||||||
provide: PolicyService,
|
provide: PolicyService,
|
||||||
useValue: policyService,
|
useValue: policyService,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: StateProvider,
|
||||||
|
useValue: { getGlobal: () => ({ state$, update }) },
|
||||||
|
},
|
||||||
{ provide: FormBuilder, useClass: FormBuilder },
|
{ 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$", () => {
|
describe("organizations$", () => {
|
||||||
it('does not add "myVault" to the list of organizations when there are no organizations', (done) => {
|
it('does not add "myVault" to the list of organizations when there are no organizations', (done) => {
|
||||||
memberOrganizations$.next([]);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -6,6 +6,7 @@ import {
|
|||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
map,
|
map,
|
||||||
Observable,
|
Observable,
|
||||||
|
shareReplay,
|
||||||
startWith,
|
startWith,
|
||||||
switchMap,
|
switchMap,
|
||||||
tap,
|
tap,
|
||||||
@ -20,6 +21,11 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
|
|||||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
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 { ServiceUtils } from "@bitwarden/common/vault/service-utils";
|
||||||
import { ChipSelectOption } from "@bitwarden/components";
|
import { ChipSelectOption } from "@bitwarden/components";
|
||||||
|
|
||||||
|
const FILTER_VISIBILITY_KEY = new KeyDefinition<boolean>(VAULT_SETTINGS_DISK, "filterVisibility", {
|
||||||
|
deserializer: (obj) => obj,
|
||||||
|
});
|
||||||
|
|
||||||
/** All available cipher filters */
|
/** All available cipher filters */
|
||||||
export type PopupListFilter = {
|
export type PopupListFilter = {
|
||||||
organization: Organization | null;
|
organization: Organization | null;
|
||||||
@ -66,6 +76,15 @@ export class VaultPopupListFiltersService {
|
|||||||
startWith(INITIAL_FILTERS),
|
startWith(INITIAL_FILTERS),
|
||||||
) as Observable<PopupListFilter>;
|
) 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
|
* Static list of ciphers views used in synchronous context
|
||||||
*/
|
*/
|
||||||
@ -89,12 +108,16 @@ export class VaultPopupListFiltersService {
|
|||||||
private collectionService: CollectionService,
|
private collectionService: CollectionService,
|
||||||
private formBuilder: FormBuilder,
|
private formBuilder: FormBuilder,
|
||||||
private policyService: PolicyService,
|
private policyService: PolicyService,
|
||||||
|
private stateProvider: StateProvider,
|
||||||
) {
|
) {
|
||||||
this.filterForm.controls.organization.valueChanges
|
this.filterForm.controls.organization.valueChanges
|
||||||
.pipe(takeUntilDestroyed())
|
.pipe(takeUntilDestroyed())
|
||||||
.subscribe(this.validateOrganizationChange.bind(this));
|
.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
|
* 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
|
* Converts the given item into the `ChipSelectOption` structure
|
||||||
*/
|
*/
|
||||||
|
@ -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;
|
let nextId = 0;
|
||||||
|
|
||||||
@ -8,14 +15,26 @@ let nextId = 0;
|
|||||||
template: `<ng-content></ng-content>`,
|
template: `<ng-content></ng-content>`,
|
||||||
})
|
})
|
||||||
export class DisclosureComponent {
|
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
|
* 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() {
|
@HostBinding("class") get classList() {
|
||||||
return this.open ? "" : "tw-hidden";
|
return this.open ? "" : "tw-hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostBinding("id") id = `bit-disclosure-${nextId++}`;
|
@HostBinding("id") id = `bit-disclosure-${nextId++}`;
|
||||||
|
|
||||||
|
get open(): boolean {
|
||||||
|
return this._open;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user