From af14c3fe6d9c1e0299bda6c956106b0cec32265d Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Wed, 7 Aug 2024 05:34:03 -0700 Subject: [PATCH] [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 9f65ded13f1dfd38c80da6e0ce4f1d0c48eb8b59, reversing changes made to 63f95600e847afa28ef0e200543c3dd90d5ac233. * add send items service * Revert "Revert "Merge branch 'PM-9853' into PM-9852"" This reverts commit 81e9860c254904700c28985b07b40b36923ca312. * 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 Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> --- apps/browser/src/_locales/en/messages.json | 6 + .../popup/send-v2/send-v2.component.html | 24 +++- .../popup/send-v2/send-v2.component.spec.ts | 75 ++++++++---- .../tools/popup/send-v2/send-v2.component.ts | 66 +++++++--- apps/desktop/src/locales/en/messages.json | 6 + apps/web/src/locales/en/messages.json | 6 + libs/tools/send/send-ui/src/index.ts | 3 + .../send-list-filters.component.html | 6 +- .../send-list-filters.component.spec.ts | 66 ++++++++++ .../send-list-filters.component.ts | 10 +- .../send-list-items-container.component.html | 2 +- .../send-list-items-container.component.ts | 3 + .../send-search/send-search.component.html | 8 ++ .../src/send-search/send-search.component.ts | 52 ++++++++ .../src/services/send-items.service.spec.ts | 114 ++++++++++++++++++ .../src/services/send-items.service.ts | 102 ++++++++++++++++ .../send-list-filters.service.spec.ts | 6 +- .../src/services/send-list-filters.service.ts | 12 +- 18 files changed, 514 insertions(+), 53 deletions(-) create mode 100644 libs/tools/send/send-ui/src/send-list-filters/send-list-filters.component.spec.ts create mode 100644 libs/tools/send/send-ui/src/send-search/send-search.component.html create mode 100644 libs/tools/send/send-ui/src/send-search/send-search.component.ts create mode 100644 libs/tools/send/send-ui/src/services/send-items.service.spec.ts create mode 100644 libs/tools/send/send-ui/src/services/send-items.service.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 03009cdfce..db1f960b9b 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -4097,6 +4097,12 @@ "itemLocation": { "message": "Item Location" }, + "fileSends": { + "message": "File Sends" + }, + "textSends": { + "message": "Text Sends" + }, "bitwardenNewLook": { "message": "Bitwarden has a new look!" }, diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.html b/apps/browser/src/tools/popup/send-v2/send-v2.component.html index 52f7c3ed8f..a8dd3e24f2 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.html +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.html @@ -8,12 +8,32 @@ -
+
{{ "sendsNoItemsTitle" | i18n }} {{ "sendsNoItemsMessage" | i18n }}
- + + +
+ + {{ "noItemsMatchSearch" | i18n }} + {{ "clearFiltersOrTryAnother" | i18n }} + +
+ +
+ +
+ + +
diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts index d7a302b790..53a0441eec 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts @@ -1,11 +1,12 @@ import { CommonModule } from "@angular/common"; 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 { mock } from "jest-mock-extended"; -import { Observable, of } from "rxjs"; +import { MockProxy, mock } from "jest-mock-extended"; +import { of, BehaviorSubject } from "rxjs"; 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 { AuthService } from "@bitwarden/common/auth/abstractions/auth.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 { LogService } from "@bitwarden/common/platform/abstractions/log.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 { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; @@ -22,7 +24,10 @@ import { ButtonModule, NoItemsModule } from "@bitwarden/components"; import { NewSendDropdownComponent, SendListItemsContainerComponent, + SendItemsService, + SendSearchComponent, SendListFiltersComponent, + SendListFiltersService, } from "@bitwarden/send-ui"; 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 { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; -import { SendV2Component } from "./send-v2.component"; +import { SendV2Component, SendState } from "./send-v2.component"; describe("SendV2Component", () => { let component: SendV2Component; let fixture: ComponentFixture; - let sendViews$: Observable; + let sendItemsService: MockProxy; + let sendListFiltersService: SendListFiltersService; + let sendListFiltersServiceFilters$: BehaviorSubject<{ sendType: SendType | null }>; + let sendItemsServiceEmptyList$: BehaviorSubject; + let sendItemsServiceNoFilteredResults$: BehaviorSubject; beforeEach(async () => { - sendViews$ = of([ - { id: "1", name: "Send A" }, - { id: "2", name: "Send B" }, - ] as SendView[]); + sendListFiltersServiceFilters$ = new BehaviorSubject({ sendType: null }); + sendItemsServiceEmptyList$ = new BehaviorSubject(false); + sendItemsServiceNoFilteredResults$ = new BehaviorSubject(false); + + sendItemsService = mock({ + 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({ imports: [ CommonModule, RouterTestingModule, JslibModule, - NoItemsModule, + ReactiveFormsModule, ButtonModule, NoItemsModule, - RouterLink, NewSendDropdownComponent, SendListItemsContainerComponent, SendListFiltersComponent, + SendSearchComponent, + SendV2Component, PopupPageComponent, PopupHeaderComponent, PopOutComponent, @@ -66,21 +89,24 @@ describe("SendV2Component", () => { { provide: AvatarService, useValue: mock() }, { provide: BillingAccountProfileStateService, - useValue: mock(), + useValue: { hasPremiumFromAnySource$: of(false) }, }, { provide: ConfigService, useValue: mock() }, { provide: EnvironmentService, useValue: mock() }, { provide: LogService, useValue: mock() }, { provide: PlatformUtilsService, useValue: mock() }, { provide: SendApiService, useValue: mock() }, - { provide: SendService, useValue: { sendViews$ } }, + { provide: SendItemsService, useValue: mock() }, + { provide: SearchService, useValue: mock() }, + { provide: SendService, useValue: { sendViews$: new BehaviorSubject([]) } }, + { provide: SendItemsService, useValue: sendItemsService }, { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: SendListFiltersService, useValue: sendListFiltersService }, ], }).compileComponents(); fixture = TestBed.createComponent(SendV2Component); component = fixture.componentInstance; - fixture.detectChanges(); }); @@ -88,14 +114,21 @@ describe("SendV2Component", () => { expect(component).toBeTruthy(); }); - it("should sort sends by name on initialization", async () => { - const sortedSends = [ - { id: "1", name: "Send A" }, - { id: "2", name: "Send B" }, - ] as SendView[]; + it("should update the title based on the current filter", () => { + sendListFiltersServiceFilters$.next({ sendType: SendType.File }); + fixture.detectChanges(); + expect(component["title"]).toBe("fileSends"); + }); - 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); }); }); diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts index 6ee5f832be..5c1ec89fde 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts @@ -1,18 +1,20 @@ import { CommonModule } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { RouterLink } from "@angular/router"; -import { mergeMap, Subject, takeUntil } from "rxjs"; +import { combineLatest } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; -import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; -import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; -import { ButtonModule, NoItemsModule } from "@bitwarden/components"; +import { ButtonModule, Icons, NoItemsModule } from "@bitwarden/components"; import { NoSendsIcon, NewSendDropdownComponent, SendListItemsContainerComponent, + SendItemsService, + SendSearchComponent, SendListFiltersComponent, + SendListFiltersService, } from "@bitwarden/send-ui"; 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 { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; +export enum SendState { + Empty, + NoResults, +} + @Component({ templateUrl: "send-v2.component.html", standalone: true, @@ -36,29 +43,56 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co NewSendDropdownComponent, SendListItemsContainerComponent, SendListFiltersComponent, + SendSearchComponent, ], }) export class SendV2Component implements OnInit, OnDestroy { sendType = SendType; - private destroy$ = new Subject(); + sendState = SendState; - sends: SendView[] = []; + protected listState: SendState | null = null; + + protected sends$ = this.sendItemsService.filteredAndSortedSends$; + + protected title: string = "allSends"; protected noItemIcon = NoSendsIcon; - constructor(protected sendService: SendService) {} + protected noResultsIcon = Icons.NoResults; - async ngOnInit() { - this.sendService.sendViews$ - .pipe( - mergeMap(async (sends) => { - this.sends = sends.sort((a, b) => a.name.localeCompare(b.name)); - }), - takeUntil(this.destroy$), - ) - .subscribe(); + constructor( + protected sendItemsService: SendItemsService, + protected sendListFiltersService: SendListFiltersService, + ) { + combineLatest([ + this.sendItemsService.emptyList$, + this.sendItemsService.noFilteredResults$, + this.sendListFiltersService.filters$, + ]) + .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 {} } diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 5cfc860d60..14bd61a092 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -3043,5 +3043,11 @@ }, "data": { "message": "Data" + }, + "fileSends": { + "message": "File Sends" + }, + "textSends": { + "message": "Text Sends" } } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 96a50b5405..be48d1b301 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -8795,6 +8795,12 @@ "purchasedSeatsRemoved": { "message": "purchased seats removed" }, + "fileSends": { + "message": "File Sends" + }, + "textSends": { + "message": "Text Sends" + }, "includesXMembers": { "message": "for $COUNT$ member", "placeholders": { diff --git a/libs/tools/send/send-ui/src/index.ts b/libs/tools/send/send-ui/src/index.ts index 02326ac222..d208709c36 100644 --- a/libs/tools/send/send-ui/src/index.ts +++ b/libs/tools/send/send-ui/src/index.ts @@ -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"; diff --git a/libs/tools/send/send-ui/src/send-list-filters/send-list-filters.component.html b/libs/tools/send/send-ui/src/send-list-filters/send-list-filters.component.html index e74e2f0562..17f1233d70 100644 --- a/libs/tools/send/send-ui/src/send-list-filters/send-list-filters.component.html +++ b/libs/tools/send/send-ui/src/send-list-filters/send-list-filters.component.html @@ -1,7 +1,7 @@ -
-
+
+ { + let component: SendListFiltersComponent; + let fixture: ComponentFixture; + let sendListFiltersService: SendListFiltersService; + let billingAccountProfileStateService: MockProxy; + + beforeEach(async () => { + sendListFiltersService = new SendListFiltersService(mock(), new FormBuilder()); + sendListFiltersService.resetFilterForm = jest.fn(); + billingAccountProfileStateService = mock(); + + 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(); + }); +}); diff --git a/libs/tools/send/send-ui/src/send-list-filters/send-list-filters.component.ts b/libs/tools/send/send-ui/src/send-list-filters/send-list-filters.component.ts index ccdaa29324..b313ced742 100644 --- a/libs/tools/send/send-ui/src/send-list-filters/send-list-filters.component.ts +++ b/libs/tools/send/send-ui/src/send-list-filters/send-list-filters.component.ts @@ -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; - constructor(private sendListFiltersService: SendListFiltersService) {} + constructor( + private sendListFiltersService: SendListFiltersService, + billingAccountProfileStateService: BillingAccountProfileStateService, + ) { + this.canAccessPremium$ = billingAccountProfileStateService.hasPremiumFromAnySource$; + } ngOnDestroy(): void { this.sendListFiltersService.resetFilterForm(); diff --git a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html index 6a4c6a308e..586e62cb61 100644 --- a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html +++ b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html @@ -1,7 +1,7 @@

- {{ "allSends" | i18n }} + {{ headerText }}

{{ sends.length }}
diff --git a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts index ef7232e97a..9551fe07ee 100644 --- a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts +++ b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts @@ -48,6 +48,9 @@ export class SendListItemsContainerComponent { @Input() sends: SendView[] = []; + @Input() + headerText: string; + constructor( protected dialogService: DialogService, protected environmentService: EnvironmentService, diff --git a/libs/tools/send/send-ui/src/send-search/send-search.component.html b/libs/tools/send/send-ui/src/send-search/send-search.component.html new file mode 100644 index 0000000000..55674aa83e --- /dev/null +++ b/libs/tools/send/send-ui/src/send-search/send-search.component.html @@ -0,0 +1,8 @@ +
+ + +
diff --git a/libs/tools/send/send-ui/src/send-search/send-search.component.ts b/libs/tools/send/send-ui/src/send-search/send-search.component.ts new file mode 100644 index 0000000000..cd947490dd --- /dev/null +++ b/libs/tools/send/send-ui/src/send-search/send-search.component.ts @@ -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(); + + 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); + }); + } +} diff --git a/libs/tools/send/send-ui/src/services/send-items.service.spec.ts b/libs/tools/send/send-ui/src/services/send-items.service.spec.ts new file mode 100644 index 0000000000..92a48df40a --- /dev/null +++ b/libs/tools/send/send-ui/src/services/send-items.service.spec.ts @@ -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(); + const sendListFiltersServiceMock = mock(); + const searchServiceMock = mock(); + + beforeEach(() => { + sendServiceMock.sendViews$ = new BehaviorSubject([]); + 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).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(); + (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).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).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(); + }); + }); +}); diff --git a/libs/tools/send/send-ui/src/services/send-items.service.ts b/libs/tools/send/send-ui/src/services/send-items.service.ts new file mode 100644 index 0000000000..107749b1e6 --- /dev/null +++ b/libs/tools/send/send-ui/src/services/send-items.service.ts @@ -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(""); + + /** + * Subject that emits whenever new sends are being processed/filtered. + * @private + */ + private _sendsLoading$ = new Subject(); + + latestSearchText$: Observable = this._searchText$.asObservable(); + private _sendList$: Observable = 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 = 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 = 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 = this._sendList$.pipe(map((sends) => !sends.length)); + + /** + * Observable that indicates whether there are no sends to show with the current filter. + */ + noFilteredResults$: Observable = this.filteredAndSortedSends$.pipe( + map((sends) => !sends.length), + ); + + constructor( + private sendService: SendService, + private sendListFiltersService: SendListFiltersService, + private searchService: SearchService, + ) {} + + applyFilter(newSearchText: string) { + this._searchText$.next(newSearchText); + } +} diff --git a/libs/tools/send/send-ui/src/services/send-list-filters.service.spec.ts b/libs/tools/send/send-ui/src/services/send-list-filters.service.spec.ts index 023d0e3214..f41dab18e6 100644 --- a/libs/tools/send/send-ui/src/services/send-list-filters.service.spec.ts +++ b/libs/tools/send/send-ui/src/services/send-list-filters.service.spec.ts @@ -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(); diff --git a/libs/tools/send/send-ui/src/services/send-list-filters.service.ts b/libs/tools/send/send-ui/src/services/send-list-filters.service.ts index 0d2763b880..5b2c29329b 100644 --- a/libs/tools/send/send-ui/src/services/send-list-filters.service.ts +++ b/libs/tools/send/send-ui/src/services/send-list-filters.service.ts @@ -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[] = [ { 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", }, ];