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