[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:
parent
c749447894
commit
4ef9497bc5
|
@ -0,0 +1,8 @@
|
|||
<div class="tw-mb-2">
|
||||
<bit-search
|
||||
[placeholder]="'search' | i18n"
|
||||
[(ngModel)]="searchText"
|
||||
(ngModelChange)="onSearchTextChanged()"
|
||||
>
|
||||
</bit-search>
|
||||
</div>
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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"], {});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Reference in New Issue