[PM-6824] Browser V2 Search (#9343)

*browser v2 search - can search for terms and items filtered in their respective groups
This commit is contained in:
Jason Ng 2024-05-28 17:08:25 -04:00 committed by GitHub
parent c749447894
commit 4ef9497bc5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 205 additions and 10 deletions

View File

@ -0,0 +1,8 @@
<div class="tw-mb-2">
<bit-search
[placeholder]="'search' | i18n"
[(ngModel)]="searchText"
(ngModelChange)="onSearchTextChanged()"
>
</bit-search>
</div>

View File

@ -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<string>();
private searchText$ = new Subject<string>();
constructor() {
this.searchText$
.pipe(debounceTime(SearchTextDebounceInterval), takeUntilDestroyed())
.subscribe((data) => {
this.searchTextChanged.emit(data);
});
}
onSearchTextChanged() {
this.searchText$.next(this.searchText);
}
}

View File

@ -24,6 +24,9 @@
<ng-container *ngIf="!(showEmptyState$ | async)">
<!-- TODO: Filter/search Section in PM-6824 and PM-6826.-->
<app-vault-v2-search (searchTextChanged)="handleSearchTextChange($event)">
</app-vault-v2-search>
<div
*ngIf="showNoResultsState$ | async"
class="tw-flex tw-flex-col tw-h-full tw-justify-center"

View File

@ -11,6 +11,7 @@ import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-he
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
import { VaultPopupItemsService } from "../../services/vault-popup-items.service";
import { AutofillVaultListItemsComponent, VaultListItemsContainerComponent } from "../vault-v2";
import { VaultV2SearchComponent } from "../vault-v2/vault-search/vault-v2-search.component";
@Component({
selector: "app-vault",
@ -28,6 +29,7 @@ import { AutofillVaultListItemsComponent, VaultListItemsContainerComponent } fro
VaultListItemsContainerComponent,
ButtonModule,
RouterLink,
VaultV2SearchComponent,
],
})
export class VaultV2Component implements OnInit, OnDestroy {
@ -48,6 +50,10 @@ export class VaultV2Component implements OnInit, OnDestroy {
ngOnDestroy(): void {}
handleSearchTextChange(searchText: string) {
this.vaultPopupItemsService.applyFilter(searchText);
}
addCipher() {
// TODO: Add currently filtered organization to query params if available
void this.router.navigate(["/add-cipher"], {});

View File

@ -1,6 +1,7 @@
import { mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { CipherId } 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";
@ -19,6 +20,7 @@ describe("VaultPopupItemsService", () => {
const cipherServiceMock = mock<CipherService>();
const vaultSettingsServiceMock = mock<VaultSettingsService>();
const searchService = mock<SearchService>();
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

View File

@ -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<void>();
private searchText$ = new BehaviorSubject<string>("");
/**
* 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<CipherView[]> = combineLatest([
this._cipherList$,
this._filteredCipherList$,
this._otherAutoFillTypes$,
this._currentAutofillTab$,
]).pipe(
@ -96,7 +106,7 @@ export class VaultPopupItemsService {
*/
favoriteCiphers$: Observable<CipherView[]> = 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<CipherView[]> = 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<boolean> = of(false);
hasFilterApplied$: Observable<boolean> = 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<boolean> = of(false);
noFilteredResults$: Observable<boolean> = 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.