1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 07:43:35 +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: VaultPopupItemsService,
useValue: mock<VaultPopupItemsService>({ latestSearchText$: new BehaviorSubject("") }),
useValue: mock<VaultPopupItemsService>({ searchText$: new BehaviorSubject("") }),
},
{
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 { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
@@ -20,7 +18,7 @@ const SearchTextDebounceInterval = 200;
templateUrl: "vault-v2-search.component.html",
})
export class VaultV2SearchComponent {
searchText: string;
searchText: string = "";
private searchText$ = new Subject<string>();
@@ -30,11 +28,11 @@ export class VaultV2SearchComponent {
}
onSearchTextChanged() {
this.searchText$.next(this.searchText);
this.vaultPopupItemsService.applyFilter(this.searchText);
}
subscribeToLatestSearchText(): Subscription {
return this.vaultPopupItemsService.latestSearchText$
return this.vaultPopupItemsService.searchText$
.pipe(
takeUntilDestroyed(),
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 { 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 { 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 { VaultPopupItemsService } from "./vault-popup-items.service";
@@ -35,6 +37,10 @@ describe("VaultPopupItemsService", () => {
let mockOrg: Organization;
let mockCollections: CollectionView[];
let activeUserLastSync$: BehaviorSubject<Date>;
let viewCacheService: {
signal: jest.Mock;
mockSignal: WritableSignal<string | null>;
};
let ciphersSubject: BehaviorSubject<Record<CipherId, CipherData>>;
let localDataSubject: BehaviorSubject<Record<CipherId, LocalData>>;
@@ -125,6 +131,12 @@ describe("VaultPopupItemsService", () => {
activeUserLastSync$ = new BehaviorSubject(new Date());
syncServiceMock.activeUserLastSync$.mockReturnValue(activeUserLastSync$);
const testSearchSignal = createMockSignal<string | null>("");
viewCacheService = {
mockSignal: testSearchSignal,
signal: jest.fn((options) => testSearchSignal),
};
testBed = TestBed.configureTestingModule({
providers: [
{ provide: CipherService, useValue: cipherServiceMock },
@@ -141,6 +153,7 @@ describe("VaultPopupItemsService", () => {
provide: InlineMenuFieldQualificationService,
useValue: inlineMenuFieldQualificationServiceMock,
},
{ provide: PopupViewCacheService, useValue: viewCacheService },
],
});
@@ -455,15 +468,32 @@ describe("VaultPopupItemsService", () => {
describe("applyFilter", () => {
it("should call search Service with the new search term", (done) => {
const searchText = "Hello";
service.applyFilter(searchText);
const searchServiceSpy = jest.spyOn(searchService, "searchCiphers");
service.applyFilter(searchText);
service.favoriteCiphers$.subscribe(() => {
expect(searchServiceSpy).toHaveBeenCalledWith(searchText, null, expect.anything());
expect(searchServiceSpy).toHaveBeenCalledWith(searchText, undefined, expect.anything());
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
@@ -518,3 +548,9 @@ function cipherFactory(count: number): Record<CipherId, CipherView> {
}
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
// @ts-strict-ignore
import { Injectable, NgZone } from "@angular/core";
import { inject, Injectable, NgZone } from "@angular/core";
import { toObservable } from "@angular/core/rxjs-interop";
import {
BehaviorSubject,
combineLatest,
concatMap,
distinctUntilChanged,
@@ -27,13 +25,14 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
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 { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
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 { PopupCipherView } from "../views/popup-cipher.view";
@@ -47,7 +46,12 @@ import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-fi
providedIn: "root",
})
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.
@@ -55,10 +59,13 @@ export class VaultPopupItemsService {
*/
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(
switchMap((account) => this.organizationService.organizations$(account?.id)),
private organizations$ = this.activeUserId$.pipe(
switchMap((userId) => this.organizationService.organizations$(userId)),
);
/**
* 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(
map((a) => a?.id),
filter((userId) => userId != null),
filter((userId): userId is UserId => userId != null),
switchMap((userId) =>
merge(this.cipherService.ciphers$(userId), this.cipherService.localData$(userId)).pipe(
runInsideAngular(this.ngZone),
@@ -131,13 +138,13 @@ export class VaultPopupItemsService {
* Observable that indicates whether there is search text present that is searchable.
* @private
*/
private _hasSearchText$ = this._searchText$.pipe(
private _hasSearchText = this.searchText$.pipe(
switchMap((searchText) => this.searchService.isSearchable(searchText)),
);
private _filteredCipherList$: Observable<PopupCipherView[]> = combineLatest([
this._activeCipherList$,
this._searchText$,
this.searchText$,
this.vaultPopupListFiltersService.filterFunction$,
]).pipe(
map(([ciphers, searchText, filterFunction]): [CipherView[], string] => [
@@ -146,7 +153,9 @@ export class VaultPopupItemsService {
]),
switchMap(
([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 }),
);
@@ -163,7 +172,7 @@ export class VaultPopupItemsService {
this.vaultPopupAutofillService.currentAutofillTab$,
]).pipe(
switchMap(([ciphers, otherTypes, tab]) => {
if (!tab) {
if (!tab || !tab.url) {
return of([]);
}
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.
*/
hasFilterApplied$ = combineLatest([
this._hasSearchText$,
this._hasSearchText,
this.vaultPopupListFiltersService.filters$,
]).pipe(
map(([hasSearchText, filters]) => {
@@ -248,7 +257,7 @@ export class VaultPopupItemsService {
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;
}),
);
@@ -292,7 +301,7 @@ export class VaultPopupItemsService {
) {}
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 { 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 { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
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 { 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", () => {
let service: VaultPopupListFiltersService;
const _memberOrganizations$ = new BehaviorSubject<Organization[]>([]);
let _memberOrganizations$ = new BehaviorSubject<Organization[]>([]);
const memberOrganizations$ = (userId: UserId) => _memberOrganizations$;
const organizations$ = new BehaviorSubject<Organization[]>([]);
const folderViews$ = new BehaviorSubject([]);
let folderViews$ = new BehaviorSubject([]);
const cipherViews$ = new BehaviorSubject({});
const decryptedCollections$ = new BehaviorSubject<CollectionView[]>([]);
let decryptedCollections$ = new BehaviorSubject<CollectionView[]>([]);
const policyAppliesToActiveUser$ = new BehaviorSubject<boolean>(false);
let viewCacheService: {
signal: jest.Mock;
mockSignal: WritableSignal<CachedFilterState>;
};
const collectionService = {
decryptedCollections$,
@@ -61,12 +72,19 @@ describe("VaultPopupListFiltersService", () => {
const update = jest.fn().mockResolvedValue(undefined);
beforeEach(() => {
_memberOrganizations$.next([]);
decryptedCollections$.next([]);
_memberOrganizations$ = new BehaviorSubject<Organization[]>([]); // Fresh instance per test
folderViews$ = new BehaviorSubject([]); // Fresh instance per test
decryptedCollections$ = new BehaviorSubject<CollectionView[]>([]); // Fresh instance per test
policyAppliesToActiveUser$.next(false);
policyService.policyAppliesToActiveUser$.mockClear();
const accountService = mockAccountServiceWith("userId" as UserId);
const mockCachedSignal = createMockSignal<CachedFilterState>({});
viewCacheService = {
mockSignal: mockCachedSignal,
signal: jest.fn(() => mockCachedSignal),
};
collectionService.getAllNested = () => Promise.resolve([]);
TestBed.configureTestingModule({
@@ -104,6 +122,10 @@ describe("VaultPopupListFiltersService", () => {
provide: AccountService,
useValue: accountService,
},
{
provide: PopupViewCacheService,
useValue: viewCacheService,
},
],
});
@@ -440,7 +462,7 @@ describe("VaultPopupListFiltersService", () => {
});
it("filters by collection", (done) => {
const collection = { id: "1234" } as Collection;
const collection = { id: "1234" } as CollectionView;
service.filterFunction$.subscribe((filterFunction) => {
expect(filterFunction(ciphers)).toEqual([ciphers[1]]);
@@ -505,4 +527,194 @@ describe("VaultPopupListFiltersService", () => {
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 { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder } from "@angular/forms";
import {
combineLatest,
debounceTime,
distinctUntilChanged,
filter,
map,
Observable,
of,
shareReplay,
startWith,
switchMap,
take,
tap,
} 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 { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -29,6 +31,7 @@ import {
StateProvider,
VAULT_SETTINGS_DISK,
} from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
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";
@@ -38,14 +41,26 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
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", {
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 */
export type PopupListFilter = {
organization: Organization | null;
collection: Collection | null;
collection: CollectionView | null;
folder: FolderView | null;
cipherType: CipherType | null;
};
@@ -76,8 +91,9 @@ export class VaultPopupListFiltersService {
* Observable for `filterForm` value
*/
filters$ = this.filterForm.valueChanges.pipe(
startWith(INITIAL_FILTERS),
) as Observable<PopupListFilter>;
startWith(this.filterForm.value),
shareReplay({ bufferSize: 1, refCount: true }),
);
/** Emits the number of applied filters. */
numberOfAppliedFilters$ = this.filters$.pipe(
@@ -93,7 +109,65 @@ export class VaultPopupListFiltersService {
*/
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(
private folderService: FolderService,
@@ -105,10 +179,30 @@ export class VaultPopupListFiltersService {
private policyService: PolicyService,
private stateProvider: StateProvider,
private accountService: AccountService,
private viewCacheService: PopupViewCacheService,
) {
this.filterForm.controls.organization.valueChanges
.pipe(takeUntilDestroyed())
.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. */
@@ -130,14 +224,11 @@ export class VaultPopupListFiltersService {
return false;
}
if (
filters.collection !== null &&
!cipher.collectionIds.includes(filters.collection.id)
) {
if (filters.collection && !cipher.collectionIds?.includes(filters.collection.id)) {
return false;
}
if (filters.folder !== null && cipher.folderId !== filters.folder.id) {
if (filters.folder && cipher.folderId !== filters.folder.id) {
return false;
}
@@ -147,7 +238,7 @@ export class VaultPopupListFiltersService {
if (cipher.organizationId !== null) {
return false;
}
} else if (filters.organization !== null) {
} else if (filters.organization) {
if (cipher.organizationId !== filters.organization.id) {
return false;
}
@@ -199,7 +290,9 @@ export class VaultPopupListFiltersService {
*/
organizations$: Observable<ChipSelectOption<Organization>[]> = combineLatest([
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),
]).pipe(
@@ -285,7 +378,7 @@ export class VaultPopupListFiltersService {
map(([filters, folders, cipherViews]): [PopupListFilter, FolderView[], CipherView[]] => {
if (folders.length === 1 && folders[0].id === null) {
// 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
@@ -304,7 +397,7 @@ export class VaultPopupListFiltersService {
// Move the "no folder" option to the end of the list
arrangedFolders = [...folders.filter((f) => f.id !== null), updatedNoFolder];
}
return [filters, arrangedFolders, cipherViews];
return [filters as PopupListFilter, arrangedFolders, cipherViews];
}),
map(([filters, folders, cipherViews]) => {
const organizationId = filters.organization?.id ?? null;
@@ -410,7 +503,7 @@ export class VaultPopupListFiltersService {
// Remove "/" from beginning and end of the folder name
// then split the folder name by the 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;
@@ -429,7 +522,7 @@ export class VaultPopupListFiltersService {
// 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
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,
@@ -444,12 +537,12 @@ export class VaultPopupListFiltersService {
// Find any ciphers within the organization that belong to the current folder
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 (!newOrgContainsFolder) {
this.filterForm.get("folder").setValue(null);
this.filterForm.get("folder")?.setValue(null);
}
}
}