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:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user