From 4ef9497bc51475fa26e69bb737fdf1eac4184eb5 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Tue, 28 May 2024 17:08:25 -0400 Subject: [PATCH] [PM-6824] Browser V2 Search (#9343) *browser v2 search - can search for terms and items filtered in their respective groups --- .../vault-v2-search.component.html | 8 ++ .../vault-search/vault-v2-search.component.ts | 35 +++++ .../components/vault/vault-v2.component.html | 3 + .../components/vault/vault-v2.component.ts | 6 + .../vault-popup-items.service.spec.ts | 134 +++++++++++++++++- .../services/vault-popup-items.service.ts | 29 +++- 6 files changed, 205 insertions(+), 10 deletions(-) create mode 100644 apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html create mode 100644 apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html new file mode 100644 index 0000000000..55674aa83e --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html @@ -0,0 +1,8 @@ +
+ + +
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 new file mode 100644 index 0000000000..321717285a --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts @@ -0,0 +1,35 @@ +import { CommonModule } from "@angular/common"; +import { Component, Output, EventEmitter } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormsModule } from "@angular/forms"; +import { Subject, debounceTime } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { SearchModule } from "@bitwarden/components"; + +const SearchTextDebounceInterval = 200; + +@Component({ + imports: [CommonModule, SearchModule, JslibModule, FormsModule], + standalone: true, + selector: "app-vault-v2-search", + templateUrl: "vault-v2-search.component.html", +}) +export class VaultV2SearchComponent { + searchText: string; + @Output() searchTextChanged = new EventEmitter(); + + private searchText$ = new Subject(); + + constructor() { + this.searchText$ + .pipe(debounceTime(SearchTextDebounceInterval), takeUntilDestroyed()) + .subscribe((data) => { + this.searchTextChanged.emit(data); + }); + } + + onSearchTextChanged() { + this.searchText$.next(this.searchText); + } +} diff --git a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html index df2b2c1a13..7d83d9f26c 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html @@ -24,6 +24,9 @@ + + +
{ 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.