diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts index fb5ac4b3391..cda055176e8 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts @@ -77,7 +77,7 @@ describe("VaultHeaderV2Component", () => { { provide: LogService, useValue: mock() }, { provide: VaultPopupItemsService, - useValue: mock({ latestSearchText$: new BehaviorSubject("") }), + useValue: mock({ searchText$: new BehaviorSubject("") }), }, { provide: SyncService, diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts index d9b310da231..32f5611f436 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts @@ -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(); @@ -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), diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts index 6d7b7b57d23..4d7957930ab 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts @@ -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; + let viewCacheService: { + signal: jest.Mock; + mockSignal: WritableSignal; + }; let ciphersSubject: BehaviorSubject>; let localDataSubject: BehaviorSubject>; @@ -125,6 +131,12 @@ describe("VaultPopupItemsService", () => { activeUserLastSync$ = new BehaviorSubject(new Date()); syncServiceMock.activeUserLastSync$.mockReturnValue(activeUserLastSync$); + const testSearchSignal = createMockSignal(""); + 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 { } return Object.fromEntries(ciphers.map((c) => [c.id, c])); } + +function createMockSignal(initialValue: T): WritableSignal { + const s = signal(initialValue); + s.set = (value: T) => s.update(() => value); + return s; +} diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts index 0b3e7eba492..62e5f53fe77 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -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(""); + private cachedSearchText = inject(PopupViewCacheService).signal({ + 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(); - latestSearchText$: Observable = 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 = 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), @@ -127,13 +134,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 = combineLatest([ this._activeCipherList$, - this._searchText$, + this.searchText$, this.vaultPopupListFiltersService.filterFunction$, ]).pipe( map(([ciphers, searchText, filterFunction]): [CipherView[], string] => [ @@ -142,7 +149,9 @@ export class VaultPopupItemsService { ]), switchMap( ([ciphers, searchText]) => - this.searchService.searchCiphers(searchText, null, ciphers) as Promise, + this.searchService.searchCiphers(searchText, undefined, ciphers) as Promise< + PopupCipherView[] + >, ), shareReplay({ refCount: true, bufferSize: 1 }), ); @@ -159,7 +168,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); @@ -211,7 +220,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]) => { @@ -244,7 +253,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; }), ); @@ -288,7 +297,7 @@ export class VaultPopupItemsService { ) {} applyFilter(newSearchText: string) { - this._searchText$.next(newSearchText); + this.cachedSearchText.set(newSearchText); } /** diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts index ec823d5738f..99a27c54bcc 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts @@ -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([]); + let _memberOrganizations$ = new BehaviorSubject([]); const memberOrganizations$ = (userId: UserId) => _memberOrganizations$; const organizations$ = new BehaviorSubject([]); - const folderViews$ = new BehaviorSubject([]); + let folderViews$ = new BehaviorSubject([]); const cipherViews$ = new BehaviorSubject({}); - const decryptedCollections$ = new BehaviorSubject([]); + let decryptedCollections$ = new BehaviorSubject([]); const policyAppliesToActiveUser$ = new BehaviorSubject(false); + let viewCacheService: { + signal: jest.Mock; + mockSignal: WritableSignal; + }; const collectionService = { decryptedCollections$, @@ -61,12 +72,19 @@ describe("VaultPopupListFiltersService", () => { const update = jest.fn().mockResolvedValue(undefined); beforeEach(() => { - _memberOrganizations$.next([]); - decryptedCollections$.next([]); + _memberOrganizations$ = new BehaviorSubject([]); // Fresh instance per test + folderViews$ = new BehaviorSubject([]); // Fresh instance per test + decryptedCollections$ = new BehaviorSubject([]); // Fresh instance per test policyAppliesToActiveUser$.next(false); policyService.policyAppliesToActiveUser$.mockClear(); const accountService = mockAccountServiceWith("userId" as UserId); + const mockCachedSignal = createMockSignal({}); + + 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(initialValue: T): WritableSignal { + 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 } { + const seededMemberOrganizations$ = new BehaviorSubject(organizations); + const seededCollections$ = new BehaviorSubject(collections); + const seededFolderViews$ = new BehaviorSubject(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(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 }; +} diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts index b1451318499..0231044b7f6 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts @@ -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(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; + 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(option: ChipSelectOption): ChipSelectOption[] { + 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({ + 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[]> = 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( @@ -284,7 +377,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 @@ -303,7 +396,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; @@ -407,7 +500,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; @@ -426,7 +519,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, @@ -441,12 +534,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); } } }