1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 08:13:42 +00:00

[PM-16133] - [Vault] Implement persistence on filters in Vault view (#13303)

* WIP - cache vault filter/search

* wip - vault filter persistence

* finalize popup list filters. wip tests

* finalize tests

* rename test to mock

* add remaining specs

* fix types. remove use of observable

* fix type error

* remove unecessary check for undefined userId

* remove observable. fix types and tests

* fix tests
This commit is contained in:
Jordan Aasen
2025-02-24 09:13:02 -08:00
committed by GitHub
parent 8cf490a8c1
commit 030acc6421
6 changed files with 403 additions and 55 deletions

View File

@@ -77,7 +77,7 @@ describe("VaultHeaderV2Component", () => {
{ provide: LogService, useValue: mock<LogService>() }, { provide: LogService, useValue: mock<LogService>() },
{ {
provide: VaultPopupItemsService, provide: VaultPopupItemsService,
useValue: mock<VaultPopupItemsService>({ latestSearchText$: new BehaviorSubject("") }), useValue: mock<VaultPopupItemsService>({ searchText$: new BehaviorSubject("") }),
}, },
{ {
provide: SyncService, provide: SyncService,

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component } from "@angular/core"; import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
@@ -20,7 +18,7 @@ const SearchTextDebounceInterval = 200;
templateUrl: "vault-v2-search.component.html", templateUrl: "vault-v2-search.component.html",
}) })
export class VaultV2SearchComponent { export class VaultV2SearchComponent {
searchText: string; searchText: string = "";
private searchText$ = new Subject<string>(); private searchText$ = new Subject<string>();
@@ -30,11 +28,11 @@ export class VaultV2SearchComponent {
} }
onSearchTextChanged() { onSearchTextChanged() {
this.searchText$.next(this.searchText); this.vaultPopupItemsService.applyFilter(this.searchText);
} }
subscribeToLatestSearchText(): Subscription { subscribeToLatestSearchText(): Subscription {
return this.vaultPopupItemsService.latestSearchText$ return this.vaultPopupItemsService.searchText$
.pipe( .pipe(
takeUntilDestroyed(), takeUntilDestroyed(),
filter((data) => !!data), filter((data) => !!data),

View File

@@ -1,4 +1,5 @@
import { TestBed } from "@angular/core/testing"; import { WritableSignal, signal } from "@angular/core";
import { TestBed, discardPeriodicTasks, fakeAsync, tick } from "@angular/core/testing";
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom, timeout } from "rxjs"; import { BehaviorSubject, firstValueFrom, timeout } from "rxjs";
@@ -21,6 +22,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { InlineMenuFieldQualificationService } from "../../../autofill/services/inline-menu-field-qualification.service"; import { InlineMenuFieldQualificationService } from "../../../autofill/services/inline-menu-field-qualification.service";
import { BrowserApi } from "../../../platform/browser/browser-api"; import { BrowserApi } from "../../../platform/browser/browser-api";
import { PopupViewCacheService } from "../../../platform/popup/view-cache/popup-view-cache.service";
import { VaultPopupAutofillService } from "./vault-popup-autofill.service"; import { VaultPopupAutofillService } from "./vault-popup-autofill.service";
import { VaultPopupItemsService } from "./vault-popup-items.service"; import { VaultPopupItemsService } from "./vault-popup-items.service";
@@ -35,6 +37,10 @@ describe("VaultPopupItemsService", () => {
let mockOrg: Organization; let mockOrg: Organization;
let mockCollections: CollectionView[]; let mockCollections: CollectionView[];
let activeUserLastSync$: BehaviorSubject<Date>; let activeUserLastSync$: BehaviorSubject<Date>;
let viewCacheService: {
signal: jest.Mock;
mockSignal: WritableSignal<string | null>;
};
let ciphersSubject: BehaviorSubject<Record<CipherId, CipherData>>; let ciphersSubject: BehaviorSubject<Record<CipherId, CipherData>>;
let localDataSubject: BehaviorSubject<Record<CipherId, LocalData>>; let localDataSubject: BehaviorSubject<Record<CipherId, LocalData>>;
@@ -125,6 +131,12 @@ describe("VaultPopupItemsService", () => {
activeUserLastSync$ = new BehaviorSubject(new Date()); activeUserLastSync$ = new BehaviorSubject(new Date());
syncServiceMock.activeUserLastSync$.mockReturnValue(activeUserLastSync$); syncServiceMock.activeUserLastSync$.mockReturnValue(activeUserLastSync$);
const testSearchSignal = createMockSignal<string | null>("");
viewCacheService = {
mockSignal: testSearchSignal,
signal: jest.fn((options) => testSearchSignal),
};
testBed = TestBed.configureTestingModule({ testBed = TestBed.configureTestingModule({
providers: [ providers: [
{ provide: CipherService, useValue: cipherServiceMock }, { provide: CipherService, useValue: cipherServiceMock },
@@ -141,6 +153,7 @@ describe("VaultPopupItemsService", () => {
provide: InlineMenuFieldQualificationService, provide: InlineMenuFieldQualificationService,
useValue: inlineMenuFieldQualificationServiceMock, useValue: inlineMenuFieldQualificationServiceMock,
}, },
{ provide: PopupViewCacheService, useValue: viewCacheService },
], ],
}); });
@@ -455,15 +468,32 @@ describe("VaultPopupItemsService", () => {
describe("applyFilter", () => { describe("applyFilter", () => {
it("should call search Service with the new search term", (done) => { it("should call search Service with the new search term", (done) => {
const searchText = "Hello"; const searchText = "Hello";
service.applyFilter(searchText);
const searchServiceSpy = jest.spyOn(searchService, "searchCiphers"); const searchServiceSpy = jest.spyOn(searchService, "searchCiphers");
service.applyFilter(searchText);
service.favoriteCiphers$.subscribe(() => { service.favoriteCiphers$.subscribe(() => {
expect(searchServiceSpy).toHaveBeenCalledWith(searchText, null, expect.anything()); expect(searchServiceSpy).toHaveBeenCalledWith(searchText, undefined, expect.anything());
done(); done();
}); });
}); });
}); });
it("should update searchText$ when applyFilter is called", fakeAsync(() => {
let latestValue: string | null;
service.searchText$.subscribe((val) => {
latestValue = val;
});
tick();
expect(latestValue!).toEqual("");
service.applyFilter("test search");
tick();
expect(latestValue!).toEqual("test search");
expect(viewCacheService.mockSignal()).toEqual("test search");
discardPeriodicTasks();
}));
}); });
// A function to generate a list of ciphers of different types // A function to generate a list of ciphers of different types
@@ -518,3 +548,9 @@ function cipherFactory(count: number): Record<CipherId, CipherView> {
} }
return Object.fromEntries(ciphers.map((c) => [c.id, c])); return Object.fromEntries(ciphers.map((c) => [c.id, c]));
} }
function createMockSignal<T>(initialValue: T): WritableSignal<T> {
const s = signal(initialValue);
s.set = (value: T) => s.update(() => value);
return s;
}

View File

@@ -1,8 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line import { inject, Injectable, NgZone } from "@angular/core";
// @ts-strict-ignore import { toObservable } from "@angular/core/rxjs-interop";
import { Injectable, NgZone } from "@angular/core";
import { import {
BehaviorSubject,
combineLatest, combineLatest,
concatMap, concatMap,
distinctUntilChanged, distinctUntilChanged,
@@ -27,13 +25,14 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SyncService } from "@bitwarden/common/platform/sync"; import { SyncService } from "@bitwarden/common/platform/sync";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { runInsideAngular } from "../../../platform/browser/run-inside-angular.operator"; import { runInsideAngular } from "../../../platform/browser/run-inside-angular.operator";
import { PopupViewCacheService } from "../../../platform/popup/view-cache/popup-view-cache.service";
import { waitUntil } from "../../util"; import { waitUntil } from "../../util";
import { PopupCipherView } from "../views/popup-cipher.view"; import { PopupCipherView } from "../views/popup-cipher.view";
@@ -47,7 +46,12 @@ import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-fi
providedIn: "root", providedIn: "root",
}) })
export class VaultPopupItemsService { export class VaultPopupItemsService {
private _searchText$ = new BehaviorSubject<string>(""); private cachedSearchText = inject(PopupViewCacheService).signal<string>({
key: "vault-search-text",
initialValue: "",
});
readonly searchText$ = toObservable(this.cachedSearchText);
/** /**
* Subject that emits whenever new ciphers are being processed/filtered. * Subject that emits whenever new ciphers are being processed/filtered.
@@ -55,10 +59,13 @@ export class VaultPopupItemsService {
*/ */
private _ciphersLoading$ = new Subject<void>(); private _ciphersLoading$ = new Subject<void>();
latestSearchText$: Observable<string> = this._searchText$.asObservable(); private activeUserId$ = this.accountService.activeAccount$.pipe(
map((a) => a?.id),
filter((userId): userId is UserId => userId !== null),
);
private organizations$ = this.accountService.activeAccount$.pipe( private organizations$ = this.activeUserId$.pipe(
switchMap((account) => this.organizationService.organizations$(account?.id)), switchMap((userId) => this.organizationService.organizations$(userId)),
); );
/** /**
* Observable that contains the list of other cipher types that should be shown * Observable that contains the list of other cipher types that should be shown
@@ -88,7 +95,7 @@ export class VaultPopupItemsService {
*/ */
private _allDecryptedCiphers$: Observable<CipherView[]> = this.accountService.activeAccount$.pipe( private _allDecryptedCiphers$: Observable<CipherView[]> = this.accountService.activeAccount$.pipe(
map((a) => a?.id), map((a) => a?.id),
filter((userId) => userId != null), filter((userId): userId is UserId => userId != null),
switchMap((userId) => switchMap((userId) =>
merge(this.cipherService.ciphers$(userId), this.cipherService.localData$(userId)).pipe( merge(this.cipherService.ciphers$(userId), this.cipherService.localData$(userId)).pipe(
runInsideAngular(this.ngZone), runInsideAngular(this.ngZone),
@@ -131,13 +138,13 @@ export class VaultPopupItemsService {
* Observable that indicates whether there is search text present that is searchable. * Observable that indicates whether there is search text present that is searchable.
* @private * @private
*/ */
private _hasSearchText$ = this._searchText$.pipe( private _hasSearchText = this.searchText$.pipe(
switchMap((searchText) => this.searchService.isSearchable(searchText)), switchMap((searchText) => this.searchService.isSearchable(searchText)),
); );
private _filteredCipherList$: Observable<PopupCipherView[]> = combineLatest([ private _filteredCipherList$: Observable<PopupCipherView[]> = combineLatest([
this._activeCipherList$, this._activeCipherList$,
this._searchText$, this.searchText$,
this.vaultPopupListFiltersService.filterFunction$, this.vaultPopupListFiltersService.filterFunction$,
]).pipe( ]).pipe(
map(([ciphers, searchText, filterFunction]): [CipherView[], string] => [ map(([ciphers, searchText, filterFunction]): [CipherView[], string] => [
@@ -146,7 +153,9 @@ export class VaultPopupItemsService {
]), ]),
switchMap( switchMap(
([ciphers, searchText]) => ([ciphers, searchText]) =>
this.searchService.searchCiphers(searchText, null, ciphers) as Promise<PopupCipherView[]>, this.searchService.searchCiphers(searchText, undefined, ciphers) as Promise<
PopupCipherView[]
>,
), ),
shareReplay({ refCount: true, bufferSize: 1 }), shareReplay({ refCount: true, bufferSize: 1 }),
); );
@@ -163,7 +172,7 @@ export class VaultPopupItemsService {
this.vaultPopupAutofillService.currentAutofillTab$, this.vaultPopupAutofillService.currentAutofillTab$,
]).pipe( ]).pipe(
switchMap(([ciphers, otherTypes, tab]) => { switchMap(([ciphers, otherTypes, tab]) => {
if (!tab) { if (!tab || !tab.url) {
return of([]); return of([]);
} }
return this.cipherService.filterCiphersForUrl(ciphers, tab.url, otherTypes); return this.cipherService.filterCiphersForUrl(ciphers, tab.url, otherTypes);
@@ -215,7 +224,7 @@ export class VaultPopupItemsService {
* Observable that indicates whether a filter or search text is currently applied to the ciphers. * Observable that indicates whether a filter or search text is currently applied to the ciphers.
*/ */
hasFilterApplied$ = combineLatest([ hasFilterApplied$ = combineLatest([
this._hasSearchText$, this._hasSearchText,
this.vaultPopupListFiltersService.filters$, this.vaultPopupListFiltersService.filters$,
]).pipe( ]).pipe(
map(([hasSearchText, filters]) => { map(([hasSearchText, filters]) => {
@@ -248,7 +257,7 @@ export class VaultPopupItemsService {
return false; return false;
} }
const org = orgs.find((o) => o.id === filters.organization.id); const org = orgs.find((o) => o.id === filters?.organization?.id);
return org ? !org.enabled : false; return org ? !org.enabled : false;
}), }),
); );
@@ -292,7 +301,7 @@ export class VaultPopupItemsService {
) {} ) {}
applyFilter(newSearchText: string) { applyFilter(newSearchText: string) {
this._searchText$.next(newSearchText); this.cachedSearchText.set(newSearchText);
} }
/** /**

View File

@@ -1,8 +1,9 @@
import { TestBed } from "@angular/core/testing"; import { Injector, WritableSignal, runInInjectionContext, signal } from "@angular/core";
import { TestBed, discardPeriodicTasks, fakeAsync, tick } from "@angular/core/testing";
import { FormBuilder } from "@angular/forms"; import { FormBuilder } from "@angular/forms";
import { BehaviorSubject, skipWhile } from "rxjs"; import { BehaviorSubject, skipWhile } from "rxjs";
import { CollectionService, Collection, CollectionView } from "@bitwarden/admin-console/common"; import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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 { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { PolicyType } from "@bitwarden/common/admin-console/enums";
@@ -19,17 +20,27 @@ import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-filters.service"; import { PopupViewCacheService } from "../../../platform/popup/view-cache/popup-view-cache.service";
import {
CachedFilterState,
MY_VAULT_ID,
VaultPopupListFiltersService,
} from "./vault-popup-list-filters.service";
describe("VaultPopupListFiltersService", () => { describe("VaultPopupListFiltersService", () => {
let service: VaultPopupListFiltersService; let service: VaultPopupListFiltersService;
const _memberOrganizations$ = new BehaviorSubject<Organization[]>([]); let _memberOrganizations$ = new BehaviorSubject<Organization[]>([]);
const memberOrganizations$ = (userId: UserId) => _memberOrganizations$; const memberOrganizations$ = (userId: UserId) => _memberOrganizations$;
const organizations$ = new BehaviorSubject<Organization[]>([]); const organizations$ = new BehaviorSubject<Organization[]>([]);
const folderViews$ = new BehaviorSubject([]); let folderViews$ = new BehaviorSubject([]);
const cipherViews$ = new BehaviorSubject({}); const cipherViews$ = new BehaviorSubject({});
const decryptedCollections$ = new BehaviorSubject<CollectionView[]>([]); let decryptedCollections$ = new BehaviorSubject<CollectionView[]>([]);
const policyAppliesToActiveUser$ = new BehaviorSubject<boolean>(false); const policyAppliesToActiveUser$ = new BehaviorSubject<boolean>(false);
let viewCacheService: {
signal: jest.Mock;
mockSignal: WritableSignal<CachedFilterState>;
};
const collectionService = { const collectionService = {
decryptedCollections$, decryptedCollections$,
@@ -61,12 +72,19 @@ describe("VaultPopupListFiltersService", () => {
const update = jest.fn().mockResolvedValue(undefined); const update = jest.fn().mockResolvedValue(undefined);
beforeEach(() => { beforeEach(() => {
_memberOrganizations$.next([]); _memberOrganizations$ = new BehaviorSubject<Organization[]>([]); // Fresh instance per test
decryptedCollections$.next([]); folderViews$ = new BehaviorSubject([]); // Fresh instance per test
decryptedCollections$ = new BehaviorSubject<CollectionView[]>([]); // Fresh instance per test
policyAppliesToActiveUser$.next(false); policyAppliesToActiveUser$.next(false);
policyService.policyAppliesToActiveUser$.mockClear(); policyService.policyAppliesToActiveUser$.mockClear();
const accountService = mockAccountServiceWith("userId" as UserId); const accountService = mockAccountServiceWith("userId" as UserId);
const mockCachedSignal = createMockSignal<CachedFilterState>({});
viewCacheService = {
mockSignal: mockCachedSignal,
signal: jest.fn(() => mockCachedSignal),
};
collectionService.getAllNested = () => Promise.resolve([]); collectionService.getAllNested = () => Promise.resolve([]);
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -104,6 +122,10 @@ describe("VaultPopupListFiltersService", () => {
provide: AccountService, provide: AccountService,
useValue: accountService, useValue: accountService,
}, },
{
provide: PopupViewCacheService,
useValue: viewCacheService,
},
], ],
}); });
@@ -440,7 +462,7 @@ describe("VaultPopupListFiltersService", () => {
}); });
it("filters by collection", (done) => { it("filters by collection", (done) => {
const collection = { id: "1234" } as Collection; const collection = { id: "1234" } as CollectionView;
service.filterFunction$.subscribe((filterFunction) => { service.filterFunction$.subscribe((filterFunction) => {
expect(filterFunction(ciphers)).toEqual([ciphers[1]]); expect(filterFunction(ciphers)).toEqual([ciphers[1]]);
@@ -505,4 +527,194 @@ describe("VaultPopupListFiltersService", () => {
expect(updateCallback()).toBe(false); expect(updateCallback()).toBe(false);
}); });
}); });
describe("caching", () => {
it("initializes form from cached state", fakeAsync(() => {
const cachedState: CachedFilterState = {
organizationId: MY_VAULT_ID,
collectionId: "test-collection-id",
folderId: "test-folder-id",
cipherType: CipherType.Login,
};
const seededOrganizations: Organization[] = [
{ id: MY_VAULT_ID, name: "Test Org" } as Organization,
];
const seededCollections: CollectionView[] = [
{
id: "test-collection-id",
organizationId: MY_VAULT_ID,
name: "Test collection",
} as CollectionView,
];
const seededFolderViews: FolderView[] = [
{ id: "test-folder-id", name: "Test Folder" } as FolderView,
];
const { service } = createSeededVaultPopupListFiltersService(
seededOrganizations,
seededCollections,
seededFolderViews,
cachedState,
);
tick();
expect(service.filterForm.value).toEqual({
organization: { id: MY_VAULT_ID },
collection: {
id: "test-collection-id",
organizationId: MY_VAULT_ID,
name: "Test collection",
},
folder: { id: "test-folder-id", name: "Test Folder" },
cipherType: CipherType.Login,
}); });
discardPeriodicTasks();
}));
it("serializes filters to cache on changes", fakeAsync(() => {
const seededOrganizations: Organization[] = [
{ id: "test-org-id", name: "Org" } as Organization,
];
const seededCollections: CollectionView[] = [
{
id: "test-collection-id",
organizationId: "test-org-id",
name: "Test collection",
} as CollectionView,
];
const seededFolderViews: FolderView[] = [
{ id: "test-folder-id", name: "Test Folder" } as FolderView,
];
const { service, cachedSignal } = createSeededVaultPopupListFiltersService(
seededOrganizations,
seededCollections,
seededFolderViews,
{},
);
const testOrg = { id: "test-org-id", name: "Org" } as Organization;
const testCollection = {
id: "test-collection-id",
organizationId: "test-org-id",
name: "Test collection",
} as CollectionView;
const testFolder = { id: "test-folder-id", name: "Test Folder" } as FolderView;
service.filterForm.patchValue({
organization: testOrg,
collection: testCollection,
folder: testFolder,
cipherType: CipherType.Card,
});
tick(300);
// force another emission by patching with the same value again. workaround for debounce times
service.filterForm.patchValue({
organization: testOrg,
collection: testCollection,
folder: testFolder,
cipherType: CipherType.Card,
});
tick(300);
expect(cachedSignal()).toEqual({
organizationId: "test-org-id",
collectionId: "test-collection-id",
folderId: "test-folder-id",
cipherType: CipherType.Card,
});
discardPeriodicTasks();
}));
});
});
function createMockSignal<T>(initialValue: T): WritableSignal<T> {
const s = signal(initialValue);
s.set = (value: T) => s.update(() => value);
return s;
}
// Helper function to create a seeded VaultPopupListFiltersService
function createSeededVaultPopupListFiltersService(
organizations: Organization[],
collections: CollectionView[],
folderViews: FolderView[],
cachedState: CachedFilterState = {},
): { service: VaultPopupListFiltersService; cachedSignal: WritableSignal<CachedFilterState> } {
const seededMemberOrganizations$ = new BehaviorSubject<Organization[]>(organizations);
const seededCollections$ = new BehaviorSubject<CollectionView[]>(collections);
const seededFolderViews$ = new BehaviorSubject<FolderView[]>(folderViews);
const organizationServiceMock = {
memberOrganizations$: (userId: string) => seededMemberOrganizations$,
organizations$: seededMemberOrganizations$,
} as any;
const collectionServiceMock = {
decryptedCollections$: seededCollections$,
getAllNested: () =>
Promise.resolve(
seededCollections$.value.map((c) => ({
children: [],
node: c,
parent: null,
})),
),
} as any;
const folderServiceMock = {
folderViews$: () => seededFolderViews$,
} as any;
const cipherServiceMock = {
cipherViews$: () => new BehaviorSubject({}),
} as any;
const i18nServiceMock = {
t: (key: string) => key,
} as any;
const policyServiceMock = {
policyAppliesToActiveUser$: jest.fn(() => new BehaviorSubject(false)),
} as any;
const stateProviderMock = {
getGlobal: () => ({
state$: new BehaviorSubject(false),
update: jest.fn().mockResolvedValue(undefined),
}),
} as any;
const accountServiceMock = mockAccountServiceWith("userId" as UserId);
const formBuilderInstance = new FormBuilder();
const seededCachedSignal = createMockSignal<CachedFilterState>(cachedState);
const viewCacheServiceMock = {
signal: jest.fn(() => seededCachedSignal),
mockSignal: seededCachedSignal,
} as any;
// Get an injector from TestBed so that we can run in an injection context.
const injector = TestBed.inject(Injector);
let service: VaultPopupListFiltersService;
runInInjectionContext(injector, () => {
service = new VaultPopupListFiltersService(
folderServiceMock,
cipherServiceMock,
organizationServiceMock,
i18nServiceMock,
collectionServiceMock,
formBuilderInstance,
policyServiceMock,
stateProviderMock,
accountServiceMock,
viewCacheServiceMock,
);
});
return { service: service!, cachedSignal: seededCachedSignal };
}

View File

@@ -1,20 +1,22 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Injectable } from "@angular/core"; import { Injectable } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder } from "@angular/forms"; import { FormBuilder } from "@angular/forms";
import { import {
combineLatest, combineLatest,
debounceTime,
distinctUntilChanged, distinctUntilChanged,
filter,
map, map,
Observable, Observable,
of,
shareReplay, shareReplay,
startWith, startWith,
switchMap, switchMap,
take,
tap, tap,
} from "rxjs"; } from "rxjs";
import { CollectionService, Collection, CollectionView } from "@bitwarden/admin-console/common"; import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { DynamicTreeNode } from "@bitwarden/angular/vault/vault-filter/models/dynamic-tree-node.model"; import { DynamicTreeNode } from "@bitwarden/angular/vault/vault-filter/models/dynamic-tree-node.model";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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 { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -29,6 +31,7 @@ import {
StateProvider, StateProvider,
VAULT_SETTINGS_DISK, VAULT_SETTINGS_DISK,
} from "@bitwarden/common/platform/state"; } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
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";
@@ -38,14 +41,26 @@ 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";
import { PopupViewCacheService } from "../../../platform/popup/view-cache/popup-view-cache.service";
const FILTER_VISIBILITY_KEY = new KeyDefinition<boolean>(VAULT_SETTINGS_DISK, "filterVisibility", { const FILTER_VISIBILITY_KEY = new KeyDefinition<boolean>(VAULT_SETTINGS_DISK, "filterVisibility", {
deserializer: (obj) => obj, deserializer: (obj) => obj,
}); });
/**
* Serialized state of the PopupListFilter for interfacing with the PopupViewCacheService
*/
export interface CachedFilterState {
organizationId?: string;
collectionId?: string;
folderId?: string;
cipherType?: CipherType | null;
}
/** All available cipher filters */ /** All available cipher filters */
export type PopupListFilter = { export type PopupListFilter = {
organization: Organization | null; organization: Organization | null;
collection: Collection | null; collection: CollectionView | null;
folder: FolderView | null; folder: FolderView | null;
cipherType: CipherType | null; cipherType: CipherType | null;
}; };
@@ -76,8 +91,9 @@ export class VaultPopupListFiltersService {
* Observable for `filterForm` value * Observable for `filterForm` value
*/ */
filters$ = this.filterForm.valueChanges.pipe( filters$ = this.filterForm.valueChanges.pipe(
startWith(INITIAL_FILTERS), startWith(this.filterForm.value),
) as Observable<PopupListFilter>; shareReplay({ bufferSize: 1, refCount: true }),
);
/** Emits the number of applied filters. */ /** Emits the number of applied filters. */
numberOfAppliedFilters$ = this.filters$.pipe( numberOfAppliedFilters$ = this.filters$.pipe(
@@ -93,7 +109,65 @@ export class VaultPopupListFiltersService {
*/ */
private cipherViews: CipherView[] = []; private cipherViews: CipherView[] = [];
private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id)); private activeUserId$ = this.accountService.activeAccount$.pipe(
map((a) => a?.id),
filter((userId): userId is UserId => userId !== null),
);
private serializeFilters(): CachedFilterState {
return {
organizationId: this.filterForm.value.organization?.id,
collectionId: this.filterForm.value.collection?.id,
folderId: this.filterForm.value.folder?.id,
cipherType: this.filterForm.value.cipherType,
};
}
private deserializeFilters(state: CachedFilterState): void {
combineLatest([this.organizations$, this.collections$, this.folders$])
.pipe(take(1))
.subscribe(([orgOptions, collectionOptions, folderOptions]) => {
const patchValue: PopupListFilter = {
organization: null,
collection: null,
folder: null,
cipherType: null,
};
if (state.organizationId) {
if (state.organizationId === MY_VAULT_ID) {
patchValue.organization = { id: MY_VAULT_ID } as Organization;
} else {
const orgOption = orgOptions.find((o) => o.value?.id === state.organizationId);
patchValue.organization = orgOption?.value || null;
}
}
if (state.collectionId) {
const collection = collectionOptions
.flatMap((c) => this.flattenOptions(c))
.find((c) => c.value?.id === state.collectionId)?.value;
patchValue.collection = collection || null;
}
if (state.folderId) {
const folder = folderOptions
.flatMap((f) => this.flattenOptions(f))
.find((f) => f.value?.id === state.folderId)?.value;
patchValue.folder = folder || null;
}
if (state.cipherType) {
patchValue.cipherType = state.cipherType;
}
this.filterForm.patchValue(patchValue);
});
}
private flattenOptions<T>(option: ChipSelectOption<T>): ChipSelectOption<T>[] {
return [option, ...(option.children?.flatMap((c) => this.flattenOptions(c)) || [])];
}
constructor( constructor(
private folderService: FolderService, private folderService: FolderService,
@@ -105,10 +179,30 @@ export class VaultPopupListFiltersService {
private policyService: PolicyService, private policyService: PolicyService,
private stateProvider: StateProvider, private stateProvider: StateProvider,
private accountService: AccountService, private accountService: AccountService,
private viewCacheService: PopupViewCacheService,
) { ) {
this.filterForm.controls.organization.valueChanges this.filterForm.controls.organization.valueChanges
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe(this.validateOrganizationChange.bind(this)); .subscribe(this.validateOrganizationChange.bind(this));
const cachedFilters = this.viewCacheService.signal<CachedFilterState>({
key: "vault-filters",
initialValue: {},
deserializer: (v) => v,
});
this.deserializeFilters(cachedFilters());
// Save changes to cache
this.filterForm.valueChanges
.pipe(
debounceTime(300),
map(() => this.serializeFilters()),
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
)
.subscribe((state) => {
cachedFilters.set(state);
});
} }
/** Stored state for the visibility of the filters. */ /** Stored state for the visibility of the filters. */
@@ -130,14 +224,11 @@ export class VaultPopupListFiltersService {
return false; return false;
} }
if ( if (filters.collection && !cipher.collectionIds?.includes(filters.collection.id)) {
filters.collection !== null &&
!cipher.collectionIds.includes(filters.collection.id)
) {
return false; return false;
} }
if (filters.folder !== null && cipher.folderId !== filters.folder.id) { if (filters.folder && cipher.folderId !== filters.folder.id) {
return false; return false;
} }
@@ -147,7 +238,7 @@ export class VaultPopupListFiltersService {
if (cipher.organizationId !== null) { if (cipher.organizationId !== null) {
return false; return false;
} }
} else if (filters.organization !== null) { } else if (filters.organization) {
if (cipher.organizationId !== filters.organization.id) { if (cipher.organizationId !== filters.organization.id) {
return false; return false;
} }
@@ -199,7 +290,9 @@ export class VaultPopupListFiltersService {
*/ */
organizations$: Observable<ChipSelectOption<Organization>[]> = combineLatest([ organizations$: Observable<ChipSelectOption<Organization>[]> = combineLatest([
this.accountService.activeAccount$.pipe( this.accountService.activeAccount$.pipe(
switchMap((account) => this.organizationService.memberOrganizations$(account?.id)), switchMap((account) =>
account === null ? of([]) : this.organizationService.memberOrganizations$(account.id),
),
), ),
this.policyService.policyAppliesToActiveUser$(PolicyType.PersonalOwnership), this.policyService.policyAppliesToActiveUser$(PolicyType.PersonalOwnership),
]).pipe( ]).pipe(
@@ -285,7 +378,7 @@ export class VaultPopupListFiltersService {
map(([filters, folders, cipherViews]): [PopupListFilter, FolderView[], CipherView[]] => { map(([filters, folders, cipherViews]): [PopupListFilter, FolderView[], CipherView[]] => {
if (folders.length === 1 && folders[0].id === null) { if (folders.length === 1 && folders[0].id === null) {
// Do not display folder selections when only the "no folder" option is available. // Do not display folder selections when only the "no folder" option is available.
return [filters, [], cipherViews]; return [filters as PopupListFilter, [], cipherViews];
} }
// Sort folders by alphabetic name // Sort folders by alphabetic name
@@ -304,7 +397,7 @@ export class VaultPopupListFiltersService {
// Move the "no folder" option to the end of the list // Move the "no folder" option to the end of the list
arrangedFolders = [...folders.filter((f) => f.id !== null), updatedNoFolder]; arrangedFolders = [...folders.filter((f) => f.id !== null), updatedNoFolder];
} }
return [filters, arrangedFolders, cipherViews]; return [filters as PopupListFilter, arrangedFolders, cipherViews];
}), }),
map(([filters, folders, cipherViews]) => { map(([filters, folders, cipherViews]) => {
const organizationId = filters.organization?.id ?? null; const organizationId = filters.organization?.id ?? null;
@@ -410,7 +503,7 @@ export class VaultPopupListFiltersService {
// Remove "/" from beginning and end of the folder name // Remove "/" from beginning and end of the folder name
// then split the folder name by the delimiter // then split the folder name by the delimiter
const parts = f.name != null ? f.name.replace(/^\/+|\/+$/g, "").split(NESTING_DELIMITER) : []; const parts = f.name != null ? f.name.replace(/^\/+|\/+$/g, "").split(NESTING_DELIMITER) : [];
ServiceUtils.nestedTraverse(nodes, 0, parts, folderCopy, null, NESTING_DELIMITER); ServiceUtils.nestedTraverse(nodes, 0, parts, folderCopy, undefined, NESTING_DELIMITER);
}); });
return nodes; return nodes;
@@ -429,7 +522,7 @@ export class VaultPopupListFiltersService {
// When the organization filter changes and a collection is already selected, // When the organization filter changes and a collection is already selected,
// reset the collection filter if the collection does not belong to the new organization filter // reset the collection filter if the collection does not belong to the new organization filter
if (currentFilters.collection && currentFilters.collection.organizationId !== organization.id) { if (currentFilters.collection && currentFilters.collection.organizationId !== organization.id) {
this.filterForm.get("collection").setValue(null); this.filterForm.get("collection")?.setValue(null);
} }
// When the organization filter changes and a folder is already selected, // When the organization filter changes and a folder is already selected,
@@ -444,12 +537,12 @@ export class VaultPopupListFiltersService {
// Find any ciphers within the organization that belong to the current folder // Find any ciphers within the organization that belong to the current folder
const newOrgContainsFolder = orgCiphers.some( const newOrgContainsFolder = orgCiphers.some(
(oc) => oc.folderId === currentFilters.folder.id, (oc) => oc.folderId === currentFilters?.folder?.id,
); );
// If the new organization does not contain the current folder, reset the folder filter // If the new organization does not contain the current folder, reset the folder filter
if (!newOrgContainsFolder) { if (!newOrgContainsFolder) {
this.filterForm.get("folder").setValue(null); this.filterForm.get("folder")?.setValue(null);
} }
} }
} }