1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 15:53:27 +00:00

[PM-9854] - Send Search Component (#10278)

* send list items container

* update send list items container

* finalize send list container

* remove unecessary file

* undo change to config

* prefer use of takeUntilDestroyed

* add send items service

* and send list filters and service

* undo changes to jest config

* add specs for send list filters

* Revert "Merge branch 'PM-9853' into PM-9852"

This reverts commit 9f65ded13f, reversing
changes made to 63f95600e8.

* add send items service

* Revert "Revert "Merge branch 'PM-9853' into PM-9852""

This reverts commit 81e9860c25.

* finish send search

* fix formControlName

* add specs

* finalize send search

* layout and copy fixes

* cleanup

* Remove unneeded empty file

* Remove the erroneous addition of send-list-filters to vault-export tsconfig

* update tests

* hide send list filters for non-premium users

* fix and add specss

* Fix small typo

* Re-add missing tests

* Remove unused NgZone

* Rename selector for send-search

---------

Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>
Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com>
This commit is contained in:
Jordan Aasen
2024-08-07 05:34:03 -07:00
committed by GitHub
parent 5b47ca1011
commit af14c3fe6d
18 changed files with 514 additions and 53 deletions

View File

@@ -2,4 +2,7 @@ export * from "./icons";
export * from "./send-form";
export { NewSendDropdownComponent } from "./new-send-dropdown/new-send-dropdown.component";
export { SendListItemsContainerComponent } from "./send-list-items-container/send-list-items-container.component";
export { SendItemsService } from "./services/send-items.service";
export { SendSearchComponent } from "./send-search/send-search.component";
export { SendListFiltersComponent } from "./send-list-filters/send-list-filters.component";
export { SendListFiltersService } from "./services/send-list-filters.service";

View File

@@ -1,7 +1,7 @@
<div role="toolbar" [ariaLabel]="'filters' | i18n">
<form [formGroup]="filterForm" class="tw-flex tw-flex-wrap tw-gap-2 tw-mb-6 tw-mt-2">
<div *ngIf="canAccessPremium$ | async" role="toolbar" [ariaLabel]="'filters' | i18n">
<form [formGroup]="filterForm" class="tw-flex tw-flex-wrap tw-gap-2 tw-mt-2">
<bit-chip-select
formControlName="sendTypes"
formControlName="sendType"
placeholderIcon="bwi-list"
[placeholderText]="'type' | i18n"
[options]="sendTypes"

View File

@@ -0,0 +1,66 @@
import { CommonModule } from "@angular/common";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ChipSelectComponent } from "@bitwarden/components";
import { SendListFiltersService } from "../services/send-list-filters.service";
import { SendListFiltersComponent } from "./send-list-filters.component";
describe("SendListFiltersComponent", () => {
let component: SendListFiltersComponent;
let fixture: ComponentFixture<SendListFiltersComponent>;
let sendListFiltersService: SendListFiltersService;
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
beforeEach(async () => {
sendListFiltersService = new SendListFiltersService(mock(), new FormBuilder());
sendListFiltersService.resetFilterForm = jest.fn();
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
billingAccountProfileStateService.hasPremiumFromAnySource$ = of(true);
await TestBed.configureTestingModule({
imports: [
CommonModule,
JslibModule,
ChipSelectComponent,
ReactiveFormsModule,
SendListFiltersComponent,
],
providers: [
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: SendListFiltersService, useValue: sendListFiltersService },
{
provide: BillingAccountProfileStateService,
useValue: billingAccountProfileStateService,
},
],
}).compileComponents();
fixture = TestBed.createComponent(SendListFiltersComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
it("should initialize canAccessPremium$ from BillingAccountProfileStateService", () => {
let canAccessPremium: boolean | undefined;
component["canAccessPremium$"].subscribe((value) => (canAccessPremium = value));
expect(canAccessPremium).toBe(true);
});
it("should call resetFilterForm on ngOnDestroy", () => {
component.ngOnDestroy();
expect(sendListFiltersService.resetFilterForm).toHaveBeenCalled();
});
});

View File

@@ -1,8 +1,10 @@
import { CommonModule } from "@angular/common";
import { Component, OnDestroy } from "@angular/core";
import { ReactiveFormsModule } from "@angular/forms";
import { Observable } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ChipSelectComponent } from "@bitwarden/components";
import { SendListFiltersService } from "../services/send-list-filters.service";
@@ -16,8 +18,14 @@ import { SendListFiltersService } from "../services/send-list-filters.service";
export class SendListFiltersComponent implements OnDestroy {
protected filterForm = this.sendListFiltersService.filterForm;
protected sendTypes = this.sendListFiltersService.sendTypes;
protected canAccessPremium$: Observable<boolean>;
constructor(private sendListFiltersService: SendListFiltersService) {}
constructor(
private sendListFiltersService: SendListFiltersService,
billingAccountProfileStateService: BillingAccountProfileStateService,
) {
this.canAccessPremium$ = billingAccountProfileStateService.hasPremiumFromAnySource$;
}
ngOnDestroy(): void {
this.sendListFiltersService.resetFilterForm();

View File

@@ -1,7 +1,7 @@
<bit-section *ngIf="sends?.length > 0">
<bit-section-header>
<h2 class="tw-font-bold" bitTypography="h5">
{{ "allSends" | i18n }}
{{ headerText }}
</h2>
<span bitTypography="body1" slot="end">{{ sends.length }}</span>
</bit-section-header>

View File

@@ -48,6 +48,9 @@ export class SendListItemsContainerComponent {
@Input()
sends: SendView[] = [];
@Input()
headerText: string;
constructor(
protected dialogService: DialogService,
protected environmentService: EnvironmentService,

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,52 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormsModule } from "@angular/forms";
import { Subject, Subscription, debounceTime, filter } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SearchModule } from "@bitwarden/components";
import { SendItemsService } from "../services/send-items.service";
const SearchTextDebounceInterval = 200;
@Component({
imports: [CommonModule, SearchModule, JslibModule, FormsModule],
standalone: true,
selector: "tools-send-search",
templateUrl: "send-search.component.html",
})
export class SendSearchComponent {
searchText: string;
private searchText$ = new Subject<string>();
constructor(private sendListItemService: SendItemsService) {
this.subscribeToLatestSearchText();
this.subscribeToApplyFilter();
}
onSearchTextChanged() {
this.searchText$.next(this.searchText);
}
subscribeToLatestSearchText(): Subscription {
return this.sendListItemService.latestSearchText$
.pipe(
takeUntilDestroyed(),
filter((data) => !!data),
)
.subscribe((text) => {
this.searchText = text;
});
}
subscribeToApplyFilter(): Subscription {
return this.searchText$
.pipe(debounceTime(SearchTextDebounceInterval), takeUntilDestroyed())
.subscribe((data) => {
this.sendListItemService.applyFilter(data);
});
}
}

View File

@@ -0,0 +1,114 @@
import { TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { BehaviorSubject, first, Subject } from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { SendItemsService } from "./send-items.service";
import { SendListFiltersService } from "./send-list-filters.service";
describe("SendItemsService", () => {
let testBed: TestBed;
let service: SendItemsService;
const sendServiceMock = mock<SendService>();
const sendListFiltersServiceMock = mock<SendListFiltersService>();
const searchServiceMock = mock<SearchService>();
beforeEach(() => {
sendServiceMock.sendViews$ = new BehaviorSubject<SendView[]>([]);
sendListFiltersServiceMock.filters$ = new BehaviorSubject({
sendType: null,
});
sendListFiltersServiceMock.filterFunction$ = new BehaviorSubject((sends: SendView[]) => sends);
searchServiceMock.searchSends.mockImplementation((sends) => sends);
testBed = TestBed.configureTestingModule({
providers: [
{ provide: SendService, useValue: sendServiceMock },
{ provide: SendListFiltersService, useValue: sendListFiltersServiceMock },
{ provide: SearchService, useValue: searchServiceMock },
SendItemsService,
],
});
service = testBed.inject(SendItemsService);
});
afterEach(() => {
jest.clearAllMocks();
});
it("should be created", () => {
expect(service).toBeTruthy();
});
it("should update and sort filteredAndSortedSends$ when filterFunction$ changes", (done) => {
const unsortedSends = [
{ id: "2", name: "Send B", type: 2, disabled: false },
{ id: "1", name: "Send A", type: 1, disabled: false },
] as SendView[];
(sendServiceMock.sendViews$ as BehaviorSubject<SendView[]>).next([...unsortedSends]);
service.filteredAndSortedSends$.subscribe((filteredAndSortedSends) => {
expect(filteredAndSortedSends).toEqual([unsortedSends[1], unsortedSends[0]]);
done();
});
});
it("should update loading$ when sends are loading", (done) => {
const sendsLoading$ = new Subject<void>();
(service as any)._sendsLoading$ = sendsLoading$;
service.loading$.subscribe((loading) => {
expect(loading).toBe(true);
done();
});
sendsLoading$.next();
});
it("should update hasFilterApplied$ when a filter is applied", (done) => {
searchServiceMock.isSearchable.mockImplementation(async () => true);
service.hasFilterApplied$.subscribe((canSearch) => {
expect(canSearch).toBe(true);
done();
});
service.applyFilter("test");
});
it("should return true for emptyList$ when there are no sends", (done) => {
(sendServiceMock.sendViews$ as BehaviorSubject<SendView[]>).next([]);
service.emptyList$.subscribe((empty) => {
expect(empty).toBe(true);
done();
});
});
it("should return true for noFilteredResults$ when there are no filtered sends", (done) => {
searchServiceMock.searchSends.mockImplementation(() => []);
service.noFilteredResults$.pipe(first()).subscribe((noResults) => {
expect(noResults).toBe(true);
done();
});
(sendServiceMock.sendViews$ as BehaviorSubject<SendView[]>).next([]);
});
it("should call searchService.searchSends when applyFilter is called", (done) => {
const searchText = "Hello";
service.applyFilter(searchText);
const searchServiceSpy = jest.spyOn(searchServiceMock, "searchSends");
service.filteredAndSortedSends$.subscribe(() => {
expect(searchServiceSpy).toHaveBeenCalledWith([], searchText);
done();
});
});
});

View File

@@ -0,0 +1,102 @@
import { Injectable } from "@angular/core";
import {
BehaviorSubject,
combineLatest,
distinctUntilChanged,
from,
map,
Observable,
shareReplay,
startWith,
Subject,
switchMap,
tap,
} from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { SendListFiltersService } from "./send-list-filters.service";
/**
* Service for managing the various item lists on the new Vault tab in the browser popup.
*/
@Injectable({
providedIn: "root",
})
export class SendItemsService {
private _searchText$ = new BehaviorSubject<string>("");
/**
* Subject that emits whenever new sends are being processed/filtered.
* @private
*/
private _sendsLoading$ = new Subject<void>();
latestSearchText$: Observable<string> = this._searchText$.asObservable();
private _sendList$: Observable<SendView[]> = this.sendService.sendViews$;
/**
* Observable that emits the list of sends, filtered and sorted based on the current search text and filters.
* The list is sorted alphabetically by send name.
* @readonly
*/
filteredAndSortedSends$: Observable<SendView[]> = combineLatest([
this._sendList$,
this._searchText$,
this.sendListFiltersService.filterFunction$,
]).pipe(
tap(() => this._sendsLoading$.next()),
map(([sends, searchText, filterFunction]): [SendView[], string] => [
filterFunction(sends),
searchText,
]),
map(([sends, searchText]) => this.searchService.searchSends(sends, searchText)),
map((sends) => sends.sort((a, b) => a.name.localeCompare(b.name))),
shareReplay({ refCount: true, bufferSize: 1 }),
);
/**
* Observable that indicates whether the service is currently loading sends.
*/
loading$: Observable<boolean> = this._sendsLoading$
.pipe(map(() => true))
.pipe(startWith(true), distinctUntilChanged(), shareReplay({ refCount: false, bufferSize: 1 }));
/**
* Observable that indicates whether a filter is currently applied to the sends.
*/
hasFilterApplied$ = combineLatest([this._searchText$, this.sendListFiltersService.filters$]).pipe(
switchMap(([searchText, filters]) => {
return from(this.searchService.isSearchable(searchText)).pipe(
map(
(isSearchable) =>
isSearchable || Object.values(filters).some((filter) => filter !== null),
),
);
}),
);
/**
* Observable that indicates whether the user's vault is empty.
*/
emptyList$: Observable<boolean> = this._sendList$.pipe(map((sends) => !sends.length));
/**
* Observable that indicates whether there are no sends to show with the current filter.
*/
noFilteredResults$: Observable<boolean> = this.filteredAndSortedSends$.pipe(
map((sends) => !sends.length),
);
constructor(
private sendService: SendService,
private sendListFiltersService: SendListFiltersService,
private searchService: SearchService,
) {}
applyFilter(newSearchText: string) {
this._searchText$.next(newSearchText);
}
}

View File

@@ -5,7 +5,7 @@ import { BehaviorSubject, first } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { Send } from "@bitwarden/common/tools/send/models/domain/send";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendListFiltersService } from "./send-list-filters.service";
@@ -47,7 +47,7 @@ describe("SendListFiltersService", () => {
});
it("filters disabled sends", (done) => {
const sends = [{ disabled: true }, { disabled: false }, { disabled: true }] as Send[];
const sends = [{ disabled: true }, { disabled: false }, { disabled: true }] as SendView[];
service.filterFunction$.pipe(first()).subscribe((filterFunction) => {
expect(filterFunction(sends)).toEqual([sends[1]]);
done();
@@ -67,7 +67,7 @@ describe("SendListFiltersService", () => {
{ type: SendType.File },
{ type: SendType.Text },
{ type: SendType.File },
] as Send[];
] as SendView[];
service.filterFunction$.subscribe((filterFunction) => {
expect(filterFunction(sends)).toEqual([sends[1]]);
done();

View File

@@ -4,7 +4,7 @@ import { map, Observable, startWith } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { Send } from "@bitwarden/common/tools/send/models/domain/send";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { ITreeNodeObject, TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { ChipSelectOption } from "@bitwarden/components";
@@ -38,11 +38,11 @@ export class SendListFiltersService {
) {}
/**
* Observable whose value is a function that filters an array of `Send` objects based on the current filters
* Observable whose value is a function that filters an array of `SendView` objects based on the current filters
*/
filterFunction$: Observable<(send: Send[]) => Send[]> = this.filters$.pipe(
filterFunction$: Observable<(send: SendView[]) => SendView[]> = this.filters$.pipe(
map(
(filters) => (sends: Send[]) =>
(filters) => (sends: SendView[]) =>
sends.filter((send) => {
// do not show disabled sends
if (send.disabled) {
@@ -64,12 +64,12 @@ export class SendListFiltersService {
readonly sendTypes: ChipSelectOption<SendType>[] = [
{
value: SendType.File,
label: this.i18nService.t("file"),
label: this.i18nService.t("sendTypeFile"),
icon: "bwi-file",
},
{
value: SendType.Text,
label: this.i18nService.t("text"),
label: this.i18nService.t("sendTypeText"),
icon: "bwi-file-text",
},
];