1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-09-23 03:22:50 +02: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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 514 additions and 53 deletions

View File

@ -4097,6 +4097,12 @@
"itemLocation": { "itemLocation": {
"message": "Item Location" "message": "Item Location"
}, },
"fileSends": {
"message": "File Sends"
},
"textSends": {
"message": "Text Sends"
},
"bitwardenNewLook": { "bitwardenNewLook": {
"message": "Bitwarden has a new look!" "message": "Bitwarden has a new look!"
}, },

View File

@ -8,12 +8,32 @@
</ng-container> </ng-container>
</popup-header> </popup-header>
<div *ngIf="sends.length === 0" class="tw-flex tw-flex-col tw-h-full tw-justify-center"> <div
*ngIf="listState === sendState.Empty"
class="tw-flex tw-flex-col tw-h-full tw-justify-center"
>
<bit-no-items [icon]="noItemIcon" class="tw-text-main"> <bit-no-items [icon]="noItemIcon" class="tw-text-main">
<ng-container slot="title">{{ "sendsNoItemsTitle" | i18n }}</ng-container> <ng-container slot="title">{{ "sendsNoItemsTitle" | i18n }}</ng-container>
<ng-container slot="description">{{ "sendsNoItemsMessage" | i18n }}</ng-container> <ng-container slot="description">{{ "sendsNoItemsMessage" | i18n }}</ng-container>
<tools-new-send-dropdown slot="button"></tools-new-send-dropdown> <tools-new-send-dropdown slot="button"></tools-new-send-dropdown>
</bit-no-items> </bit-no-items>
</div> </div>
<app-send-list-items-container [sends]="sends" />
<ng-container *ngIf="listState !== sendState.Empty">
<div
*ngIf="listState === sendState.NoResults"
class="tw-flex tw-flex-col tw-justify-center tw-h-auto tw-pt-12"
>
<bit-no-items [icon]="noResultsIcon">
<ng-container slot="title">{{ "noItemsMatchSearch" | i18n }}</ng-container>
<ng-container slot="description">{{ "clearFiltersOrTryAnother" | i18n }}</ng-container>
</bit-no-items>
</div>
<app-send-list-items-container [headerText]="title | i18n" [sends]="sends$ | async" />
</ng-container>
<div slot="above-scroll-area" class="tw-p-4" *ngIf="listState !== sendState.Empty">
<tools-send-search></tools-send-search>
<app-send-list-filters></app-send-list-filters>
</div>
</popup-page> </popup-page>

View File

@ -1,11 +1,12 @@
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ComponentFixture, TestBed } from "@angular/core/testing";
import { RouterLink } from "@angular/router"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { RouterTestingModule } from "@angular/router/testing"; import { RouterTestingModule } from "@angular/router/testing";
import { mock } from "jest-mock-extended"; import { MockProxy, mock } from "jest-mock-extended";
import { Observable, of } from "rxjs"; import { of, BehaviorSubject } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service";
@ -15,6 +16,7 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
@ -22,7 +24,10 @@ import { ButtonModule, NoItemsModule } from "@bitwarden/components";
import { import {
NewSendDropdownComponent, NewSendDropdownComponent,
SendListItemsContainerComponent, SendListItemsContainerComponent,
SendItemsService,
SendSearchComponent,
SendListFiltersComponent, SendListFiltersComponent,
SendListFiltersService,
} from "@bitwarden/send-ui"; } from "@bitwarden/send-ui";
import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component"; import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component";
@ -30,31 +35,49 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
import { SendV2Component } from "./send-v2.component"; import { SendV2Component, SendState } from "./send-v2.component";
describe("SendV2Component", () => { describe("SendV2Component", () => {
let component: SendV2Component; let component: SendV2Component;
let fixture: ComponentFixture<SendV2Component>; let fixture: ComponentFixture<SendV2Component>;
let sendViews$: Observable<SendView[]>; let sendItemsService: MockProxy<SendItemsService>;
let sendListFiltersService: SendListFiltersService;
let sendListFiltersServiceFilters$: BehaviorSubject<{ sendType: SendType | null }>;
let sendItemsServiceEmptyList$: BehaviorSubject<boolean>;
let sendItemsServiceNoFilteredResults$: BehaviorSubject<boolean>;
beforeEach(async () => { beforeEach(async () => {
sendViews$ = of([ sendListFiltersServiceFilters$ = new BehaviorSubject({ sendType: null });
{ id: "1", name: "Send A" }, sendItemsServiceEmptyList$ = new BehaviorSubject(false);
{ id: "2", name: "Send B" }, sendItemsServiceNoFilteredResults$ = new BehaviorSubject(false);
] as SendView[]);
sendItemsService = mock<SendItemsService>({
filteredAndSortedSends$: of([
{ id: "1", name: "Send A" },
{ id: "2", name: "Send B" },
] as SendView[]),
latestSearchText$: of(""),
});
sendListFiltersService = new SendListFiltersService(mock(), new FormBuilder());
sendListFiltersService.filters$ = sendListFiltersServiceFilters$;
sendItemsService.emptyList$ = sendItemsServiceEmptyList$;
sendItemsService.noFilteredResults$ = sendItemsServiceNoFilteredResults$;
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [ imports: [
CommonModule, CommonModule,
RouterTestingModule, RouterTestingModule,
JslibModule, JslibModule,
NoItemsModule, ReactiveFormsModule,
ButtonModule, ButtonModule,
NoItemsModule, NoItemsModule,
RouterLink,
NewSendDropdownComponent, NewSendDropdownComponent,
SendListItemsContainerComponent, SendListItemsContainerComponent,
SendListFiltersComponent, SendListFiltersComponent,
SendSearchComponent,
SendV2Component,
PopupPageComponent, PopupPageComponent,
PopupHeaderComponent, PopupHeaderComponent,
PopOutComponent, PopOutComponent,
@ -66,21 +89,24 @@ describe("SendV2Component", () => {
{ provide: AvatarService, useValue: mock<AvatarService>() }, { provide: AvatarService, useValue: mock<AvatarService>() },
{ {
provide: BillingAccountProfileStateService, provide: BillingAccountProfileStateService,
useValue: mock<BillingAccountProfileStateService>(), useValue: { hasPremiumFromAnySource$: of(false) },
}, },
{ provide: ConfigService, useValue: mock<ConfigService>() }, { provide: ConfigService, useValue: mock<ConfigService>() },
{ provide: EnvironmentService, useValue: mock<EnvironmentService>() }, { provide: EnvironmentService, useValue: mock<EnvironmentService>() },
{ provide: LogService, useValue: mock<LogService>() }, { provide: LogService, useValue: mock<LogService>() },
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() }, { provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
{ provide: SendApiService, useValue: mock<SendApiService>() }, { provide: SendApiService, useValue: mock<SendApiService>() },
{ provide: SendService, useValue: { sendViews$ } }, { provide: SendItemsService, useValue: mock<SendItemsService>() },
{ provide: SearchService, useValue: mock<SearchService>() },
{ provide: SendService, useValue: { sendViews$: new BehaviorSubject<SendView[]>([]) } },
{ provide: SendItemsService, useValue: sendItemsService },
{ provide: I18nService, useValue: { t: (key: string) => key } }, { provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: SendListFiltersService, useValue: sendListFiltersService },
], ],
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(SendV2Component); fixture = TestBed.createComponent(SendV2Component);
component = fixture.componentInstance; component = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
}); });
@ -88,14 +114,21 @@ describe("SendV2Component", () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
it("should sort sends by name on initialization", async () => { it("should update the title based on the current filter", () => {
const sortedSends = [ sendListFiltersServiceFilters$.next({ sendType: SendType.File });
{ id: "1", name: "Send A" }, fixture.detectChanges();
{ id: "2", name: "Send B" }, expect(component["title"]).toBe("fileSends");
] as SendView[]; });
await component.ngOnInit(); it("should set listState to Empty when emptyList$ emits true", () => {
sendItemsServiceEmptyList$.next(true);
fixture.detectChanges();
expect(component["listState"]).toBe(SendState.Empty);
});
expect(component.sends).toEqual(sortedSends); it("should set listState to NoResults when noFilteredResults$ emits true", () => {
sendItemsServiceNoFilteredResults$.next(true);
fixture.detectChanges();
expect(component["listState"]).toBe(SendState.NoResults);
}); });
}); });

View File

@ -1,18 +1,20 @@
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core"; import { Component, OnDestroy, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { RouterLink } from "@angular/router"; import { RouterLink } from "@angular/router";
import { mergeMap, Subject, takeUntil } from "rxjs"; import { combineLatest } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { ButtonModule, Icons, NoItemsModule } from "@bitwarden/components";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { ButtonModule, NoItemsModule } from "@bitwarden/components";
import { import {
NoSendsIcon, NoSendsIcon,
NewSendDropdownComponent, NewSendDropdownComponent,
SendListItemsContainerComponent, SendListItemsContainerComponent,
SendItemsService,
SendSearchComponent,
SendListFiltersComponent, SendListFiltersComponent,
SendListFiltersService,
} from "@bitwarden/send-ui"; } from "@bitwarden/send-ui";
import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component"; import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component";
@ -20,6 +22,11 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
export enum SendState {
Empty,
NoResults,
}
@Component({ @Component({
templateUrl: "send-v2.component.html", templateUrl: "send-v2.component.html",
standalone: true, standalone: true,
@ -36,29 +43,56 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
NewSendDropdownComponent, NewSendDropdownComponent,
SendListItemsContainerComponent, SendListItemsContainerComponent,
SendListFiltersComponent, SendListFiltersComponent,
SendSearchComponent,
], ],
}) })
export class SendV2Component implements OnInit, OnDestroy { export class SendV2Component implements OnInit, OnDestroy {
sendType = SendType; sendType = SendType;
private destroy$ = new Subject<void>(); sendState = SendState;
sends: SendView[] = []; protected listState: SendState | null = null;
protected sends$ = this.sendItemsService.filteredAndSortedSends$;
protected title: string = "allSends";
protected noItemIcon = NoSendsIcon; protected noItemIcon = NoSendsIcon;
constructor(protected sendService: SendService) {} protected noResultsIcon = Icons.NoResults;
async ngOnInit() { constructor(
this.sendService.sendViews$ protected sendItemsService: SendItemsService,
.pipe( protected sendListFiltersService: SendListFiltersService,
mergeMap(async (sends) => { ) {
this.sends = sends.sort((a, b) => a.name.localeCompare(b.name)); combineLatest([
}), this.sendItemsService.emptyList$,
takeUntil(this.destroy$), this.sendItemsService.noFilteredResults$,
) this.sendListFiltersService.filters$,
.subscribe(); ])
.pipe(takeUntilDestroyed())
.subscribe(([emptyList, noFilteredResults, currentFilter]) => {
if (currentFilter?.sendType !== null) {
this.title = `${this.sendType[currentFilter.sendType].toLowerCase()}Sends`;
} else {
this.title = "allSends";
}
if (emptyList) {
this.listState = SendState.Empty;
return;
}
if (noFilteredResults) {
this.listState = SendState.NoResults;
return;
}
this.listState = null;
});
} }
ngOnInit(): void {}
ngOnDestroy(): void {} ngOnDestroy(): void {}
} }

View File

@ -3043,5 +3043,11 @@
}, },
"data": { "data": {
"message": "Data" "message": "Data"
},
"fileSends": {
"message": "File Sends"
},
"textSends": {
"message": "Text Sends"
} }
} }

View File

@ -8795,6 +8795,12 @@
"purchasedSeatsRemoved": { "purchasedSeatsRemoved": {
"message": "purchased seats removed" "message": "purchased seats removed"
}, },
"fileSends": {
"message": "File Sends"
},
"textSends": {
"message": "Text Sends"
},
"includesXMembers": { "includesXMembers": {
"message": "for $COUNT$ member", "message": "for $COUNT$ member",
"placeholders": { "placeholders": {

View File

@ -2,4 +2,7 @@ export * from "./icons";
export * from "./send-form"; export * from "./send-form";
export { NewSendDropdownComponent } from "./new-send-dropdown/new-send-dropdown.component"; export { NewSendDropdownComponent } from "./new-send-dropdown/new-send-dropdown.component";
export { SendListItemsContainerComponent } from "./send-list-items-container/send-list-items-container.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 { 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"> <div *ngIf="canAccessPremium$ | async" role="toolbar" [ariaLabel]="'filters' | i18n">
<form [formGroup]="filterForm" class="tw-flex tw-flex-wrap tw-gap-2 tw-mb-6 tw-mt-2"> <form [formGroup]="filterForm" class="tw-flex tw-flex-wrap tw-gap-2 tw-mt-2">
<bit-chip-select <bit-chip-select
formControlName="sendTypes" formControlName="sendType"
placeholderIcon="bwi-list" placeholderIcon="bwi-list"
[placeholderText]="'type' | i18n" [placeholderText]="'type' | i18n"
[options]="sendTypes" [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 { CommonModule } from "@angular/common";
import { Component, OnDestroy } from "@angular/core"; import { Component, OnDestroy } from "@angular/core";
import { ReactiveFormsModule } from "@angular/forms"; import { ReactiveFormsModule } from "@angular/forms";
import { Observable } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ChipSelectComponent } from "@bitwarden/components"; import { ChipSelectComponent } from "@bitwarden/components";
import { SendListFiltersService } from "../services/send-list-filters.service"; 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 { export class SendListFiltersComponent implements OnDestroy {
protected filterForm = this.sendListFiltersService.filterForm; protected filterForm = this.sendListFiltersService.filterForm;
protected sendTypes = this.sendListFiltersService.sendTypes; 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 { ngOnDestroy(): void {
this.sendListFiltersService.resetFilterForm(); this.sendListFiltersService.resetFilterForm();

View File

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

View File

@ -48,6 +48,9 @@ export class SendListItemsContainerComponent {
@Input() @Input()
sends: SendView[] = []; sends: SendView[] = [];
@Input()
headerText: string;
constructor( constructor(
protected dialogService: DialogService, protected dialogService: DialogService,
protected environmentService: EnvironmentService, 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 { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; 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"; import { SendListFiltersService } from "./send-list-filters.service";
@ -47,7 +47,7 @@ describe("SendListFiltersService", () => {
}); });
it("filters disabled sends", (done) => { 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) => { service.filterFunction$.pipe(first()).subscribe((filterFunction) => {
expect(filterFunction(sends)).toEqual([sends[1]]); expect(filterFunction(sends)).toEqual([sends[1]]);
done(); done();
@ -67,7 +67,7 @@ describe("SendListFiltersService", () => {
{ type: SendType.File }, { type: SendType.File },
{ type: SendType.Text }, { type: SendType.Text },
{ type: SendType.File }, { type: SendType.File },
] as Send[]; ] as SendView[];
service.filterFunction$.subscribe((filterFunction) => { service.filterFunction$.subscribe((filterFunction) => {
expect(filterFunction(sends)).toEqual([sends[1]]); expect(filterFunction(sends)).toEqual([sends[1]]);
done(); done();

View File

@ -4,7 +4,7 @@ import { map, Observable, startWith } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; 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 { ITreeNodeObject, TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { ChipSelectOption } from "@bitwarden/components"; 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( map(
(filters) => (sends: Send[]) => (filters) => (sends: SendView[]) =>
sends.filter((send) => { sends.filter((send) => {
// do not show disabled sends // do not show disabled sends
if (send.disabled) { if (send.disabled) {
@ -64,12 +64,12 @@ export class SendListFiltersService {
readonly sendTypes: ChipSelectOption<SendType>[] = [ readonly sendTypes: ChipSelectOption<SendType>[] = [
{ {
value: SendType.File, value: SendType.File,
label: this.i18nService.t("file"), label: this.i18nService.t("sendTypeFile"),
icon: "bwi-file", icon: "bwi-file",
}, },
{ {
value: SendType.Text, value: SendType.Text,
label: this.i18nService.t("text"), label: this.i18nService.t("sendTypeText"),
icon: "bwi-file-text", icon: "bwi-file-text",
}, },
]; ];