+
+
+
{
const cipherServiceMock = mock();
const vaultSettingsServiceMock = mock();
+ const searchService = mock();
beforeEach(() => {
allCiphers = cipherFactory(10);
@@ -34,6 +36,7 @@ describe("VaultPopupItemsService", () => {
cipherList[3].favorite = true;
cipherServiceMock.cipherViews$ = new BehaviorSubject(allCiphers).asObservable();
+ searchService.searchCiphers.mockImplementation(async () => cipherList);
cipherServiceMock.filterCiphersForUrl.mockImplementation(async () => autoFillCiphers);
vaultSettingsServiceMock.showCardsCurrentTab$ = new BehaviorSubject(false).asObservable();
vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(false).asObservable();
@@ -41,11 +44,19 @@ describe("VaultPopupItemsService", () => {
jest
.spyOn(BrowserApi, "getTabFromCurrentWindow")
.mockResolvedValue({ url: "https://example.com" } as chrome.tabs.Tab);
- service = new VaultPopupItemsService(cipherServiceMock, vaultSettingsServiceMock);
+ service = new VaultPopupItemsService(
+ cipherServiceMock,
+ vaultSettingsServiceMock,
+ searchService,
+ );
});
it("should be created", () => {
- service = new VaultPopupItemsService(cipherServiceMock, vaultSettingsServiceMock);
+ service = new VaultPopupItemsService(
+ cipherServiceMock,
+ vaultSettingsServiceMock,
+ searchService,
+ );
expect(service).toBeTruthy();
});
@@ -73,7 +84,11 @@ describe("VaultPopupItemsService", () => {
vaultSettingsServiceMock.showIdentitiesCurrentTab$ = new BehaviorSubject(true).asObservable();
jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(currentTab);
- service = new VaultPopupItemsService(cipherServiceMock, vaultSettingsServiceMock);
+ service = new VaultPopupItemsService(
+ cipherServiceMock,
+ vaultSettingsServiceMock,
+ searchService,
+ );
service.autoFillCiphers$.subscribe((ciphers) => {
expect(cipherServiceMock.filterCiphersForUrl.mock.calls.length).toBe(1);
@@ -99,7 +114,11 @@ describe("VaultPopupItemsService", () => {
Object.values(allCiphers),
);
- service = new VaultPopupItemsService(cipherServiceMock, vaultSettingsServiceMock);
+ service = new VaultPopupItemsService(
+ cipherServiceMock,
+ vaultSettingsServiceMock,
+ searchService,
+ );
service.autoFillCiphers$.subscribe((ciphers) => {
expect(ciphers.length).toBe(10);
@@ -114,6 +133,24 @@ describe("VaultPopupItemsService", () => {
done();
});
});
+
+ it("should filter autoFillCiphers$ down to search term", (done) => {
+ const cipherList = Object.values(allCiphers);
+ const searchText = "Login";
+
+ searchService.searchCiphers.mockImplementation(async () => {
+ return cipherList.filter((cipher) => {
+ return cipher.name.includes(searchText);
+ });
+ });
+
+ // there is only 1 Login returned for filteredCiphers. but two results expected because of other autofill types
+ service.autoFillCiphers$.subscribe((ciphers) => {
+ expect(ciphers[0].name.includes(searchText)).toBe(true);
+ expect(ciphers.length).toBe(2);
+ done();
+ });
+ });
});
describe("favoriteCiphers$", () => {
@@ -131,6 +168,24 @@ describe("VaultPopupItemsService", () => {
done();
});
});
+
+ it("should filter favoriteCiphers$ down to search term", (done) => {
+ const cipherList = Object.values(allCiphers);
+ const searchText = "Card 2";
+
+ searchService.searchCiphers.mockImplementation(async () => {
+ return cipherList.filter((cipher) => {
+ return cipher.name === searchText;
+ });
+ });
+
+ service.favoriteCiphers$.subscribe((ciphers) => {
+ // There are 2 favorite items but only one Card 2
+ expect(ciphers[0].name).toBe(searchText);
+ expect(ciphers.length).toBe(1);
+ done();
+ });
+ });
});
describe("remainingCiphers$", () => {
@@ -148,12 +203,33 @@ describe("VaultPopupItemsService", () => {
done();
});
});
+
+ it("should filter remainingCiphers$ down to search term", (done) => {
+ const cipherList = Object.values(allCiphers);
+ const searchText = "Login";
+
+ searchService.searchCiphers.mockImplementation(async () => {
+ return cipherList.filter((cipher) => {
+ return cipher.name.includes(searchText);
+ });
+ });
+
+ service.remainingCiphers$.subscribe((ciphers) => {
+ // There are 6 remaining ciphers but only 2 with "Login" in the name
+ expect(ciphers.length).toBe(2);
+ done();
+ });
+ });
});
describe("emptyVault$", () => {
it("should return true if there are no ciphers", (done) => {
cipherServiceMock.cipherViews$ = new BehaviorSubject({}).asObservable();
- service = new VaultPopupItemsService(cipherServiceMock, vaultSettingsServiceMock);
+ service = new VaultPopupItemsService(
+ cipherServiceMock,
+ vaultSettingsServiceMock,
+ searchService,
+ );
service.emptyVault$.subscribe((empty) => {
expect(empty).toBe(true);
done();
@@ -192,6 +268,54 @@ describe("VaultPopupItemsService", () => {
});
});
});
+
+ describe("noFilteredResults$", () => {
+ it("should return false when filteredResults has values", (done) => {
+ service.noFilteredResults$.subscribe((noResults) => {
+ expect(noResults).toBe(false);
+ done();
+ });
+ });
+
+ it("should return true when there are zero filteredResults", (done) => {
+ searchService.searchCiphers.mockImplementation(async () => []);
+ service.noFilteredResults$.subscribe((noResults) => {
+ expect(noResults).toBe(true);
+ done();
+ });
+ });
+ });
+
+ describe("hasFilterApplied$", () => {
+ it("should return true if the search term provided is searchable", (done) => {
+ searchService.isSearchable.mockImplementation(async () => true);
+ service.hasFilterApplied$.subscribe((canSearch) => {
+ expect(canSearch).toBe(true);
+ done();
+ });
+ });
+
+ it("should return false if the search term provided is not searchable", (done) => {
+ searchService.isSearchable.mockImplementation(async () => false);
+ service.hasFilterApplied$.subscribe((canSearch) => {
+ expect(canSearch).toBe(false);
+ done();
+ });
+ });
+ });
+
+ 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.favoriteCiphers$.subscribe(() => {
+ expect(searchServiceSpy).toHaveBeenCalledWith(searchText, null, expect.anything());
+ done();
+ });
+ });
+ });
});
// A function to generate a list of ciphers of different types
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 52de117e6b..9a66ada08c 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,5 +1,6 @@
import { Injectable } from "@angular/core";
import {
+ BehaviorSubject,
combineLatest,
map,
Observable,
@@ -10,6 +11,7 @@ import {
switchMap,
} from "rxjs";
+import { SearchService } from "@bitwarden/common/abstractions/search.service";
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";
@@ -26,6 +28,7 @@ import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
})
export class VaultPopupItemsService {
private _refreshCurrentTab$ = new Subject();
+ private searchText$ = new BehaviorSubject("");
/**
* Observable that contains the list of other cipher types that should be shown
@@ -69,6 +72,13 @@ export class VaultPopupItemsService {
shareReplay({ refCount: false, bufferSize: 1 }),
);
+ private _filteredCipherList$ = combineLatest([this._cipherList$, this.searchText$]).pipe(
+ switchMap(([ciphers, searchText]) =>
+ this.searchService.searchCiphers(searchText, null, ciphers),
+ ),
+ shareReplay({ refCount: true, bufferSize: 1 }),
+ );
+
/**
* List of ciphers that can be used for autofill on the current tab. Includes cards and/or identities
* if enabled in the vault settings. Ciphers are sorted by type, then by last used date, then by name.
@@ -76,7 +86,7 @@ export class VaultPopupItemsService {
* See {@link refreshCurrentTab} to trigger re-evaluation of the current tab.
*/
autoFillCiphers$: Observable = combineLatest([
- this._cipherList$,
+ this._filteredCipherList$,
this._otherAutoFillTypes$,
this._currentAutofillTab$,
]).pipe(
@@ -96,7 +106,7 @@ export class VaultPopupItemsService {
*/
favoriteCiphers$: Observable = combineLatest([
this.autoFillCiphers$,
- this._cipherList$,
+ this._filteredCipherList$,
]).pipe(
map(([autoFillCiphers, ciphers]) =>
ciphers.filter((cipher) => cipher.favorite && !autoFillCiphers.includes(cipher)),
@@ -114,7 +124,7 @@ export class VaultPopupItemsService {
remainingCiphers$: Observable = combineLatest([
this.autoFillCiphers$,
this.favoriteCiphers$,
- this._cipherList$,
+ this._filteredCipherList$,
]).pipe(
map(([autoFillCiphers, favoriteCiphers, ciphers]) =>
ciphers.filter(
@@ -129,7 +139,9 @@ export class VaultPopupItemsService {
* Observable that indicates whether a filter is currently applied to the ciphers.
* @todo Implement filter/search functionality in PM-6824 and PM-6826.
*/
- hasFilterApplied$: Observable = of(false);
+ hasFilterApplied$: Observable = this.searchText$.pipe(
+ switchMap((text) => this.searchService.isSearchable(text)),
+ );
/**
* Observable that indicates whether autofill is allowed in the current context.
@@ -146,11 +158,14 @@ export class VaultPopupItemsService {
* Observable that indicates whether there are no ciphers to show with the current filter.
* @todo Implement filter/search functionality in PM-6824 and PM-6826.
*/
- noFilteredResults$: Observable = of(false);
+ noFilteredResults$: Observable = this._filteredCipherList$.pipe(
+ map((ciphers) => !ciphers.length),
+ );
constructor(
private cipherService: CipherService,
private vaultSettingsService: VaultSettingsService,
+ private searchService: SearchService,
) {}
/**
@@ -160,6 +175,10 @@ export class VaultPopupItemsService {
this._refreshCurrentTab$.next(null);
}
+ applyFilter(newSearchText: string) {
+ this.searchText$.next(newSearchText);
+ }
+
/**
* Sort function for ciphers to be used in the autofill section of the Vault tab.
* Sorts by type, then by last used date, and finally by name.