diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.html b/apps/desktop/src/app/tools/send-v2/send-v2.component.html new file mode 100644 index 00000000000..20cac15138a --- /dev/null +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.html @@ -0,0 +1,110 @@ +
+
+
+
+ +
+
+ + + +

{{ "noItemsInList" | i18n }}

+
+
+ +
+
+ + +
diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts b/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts index 8055bc07667..5798df0989d 100644 --- a/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts @@ -1,22 +1,364 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +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"; +import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; +import { DialogService, ToastService } from "@bitwarden/components"; + +import * as utils from "../../../utils"; +import { SearchBarService } from "../../layout/search/search-bar.service"; +import { AddEditComponent } from "../send/add-edit.component"; import { SendV2Component } from "./send-v2.component"; +// Mock the invokeMenu utility function +jest.mock("../../../utils", () => ({ + invokeMenu: jest.fn(), +})); + describe("SendV2Component", () => { let component: SendV2Component; let fixture: ComponentFixture; + let sendService: MockProxy; + let searchBarService: MockProxy; + let broadcasterService: MockProxy; + let accountService: MockProxy; + let policyService: MockProxy; beforeEach(async () => { + sendService = mock(); + searchBarService = mock(); + broadcasterService = mock(); + accountService = mock(); + policyService = mock(); + + // Mock sendViews$ observable + sendService.sendViews$ = of([]); + searchBarService.searchText$ = new BehaviorSubject(""); + + // Mock activeAccount$ observable for parent class ngOnInit + accountService.activeAccount$ = of({ id: "test-user-id" } as any); + policyService.policyAppliesToUser$ = jest.fn().mockReturnValue(of(false)); + await TestBed.configureTestingModule({ imports: [SendV2Component], + providers: [ + { provide: SendService, useValue: sendService }, + { provide: I18nService, useValue: mock() }, + { provide: PlatformUtilsService, useValue: mock() }, + { provide: EnvironmentService, useValue: mock() }, + { provide: BroadcasterService, useValue: broadcasterService }, + { provide: SearchService, useValue: mock() }, + { provide: PolicyService, useValue: policyService }, + { provide: SearchBarService, useValue: searchBarService }, + { provide: LogService, useValue: mock() }, + { provide: SendApiService, useValue: mock() }, + { provide: DialogService, useValue: mock() }, + { provide: ToastService, useValue: mock() }, + { provide: AccountService, useValue: accountService }, + ], }).compileComponents(); fixture = TestBed.createComponent(SendV2Component); component = fixture.componentInstance; - fixture.detectChanges(); }); it("creates component", () => { expect(component).toBeTruthy(); }); + + it("initializes with correct default action", () => { + expect(component.action).toBe(""); + }); + + it("subscribes to broadcaster service on init", async () => { + await component.ngOnInit(); + expect(broadcasterService.subscribe).toHaveBeenCalledWith( + "SendV2Component", + expect.any(Function), + ); + }); + + it("unsubscribes from broadcaster service on destroy", () => { + component.ngOnDestroy(); + expect(broadcasterService.unsubscribe).toHaveBeenCalledWith("SendV2Component"); + }); + + it("enables search bar on init", async () => { + await component.ngOnInit(); + expect(searchBarService.setEnabled).toHaveBeenCalledWith(true); + }); + + it("disables search bar on destroy", () => { + component.ngOnDestroy(); + expect(searchBarService.setEnabled).toHaveBeenCalledWith(false); + }); + + describe("addSend", () => { + it("sets action to Add", async () => { + await component.addSend(); + expect(component.action).toBe("add"); + }); + + it("calls resetAndLoad on addEditComponent when component exists", async () => { + const mockAddEdit = mock(); + component.addEditComponent = mockAddEdit; + + await component.addSend(); + + expect(mockAddEdit.resetAndLoad).toHaveBeenCalled(); + }); + + it("does not throw when addEditComponent is null", async () => { + component.addEditComponent = null; + await expect(component.addSend()).resolves.not.toThrow(); + }); + }); + + describe("cancel", () => { + it("resets action to None", () => { + component.action = "edit"; + component.sendId = "test-id"; + + component.cancel(new SendView()); + + expect(component.action).toBe(""); + expect(component.sendId).toBeNull(); + }); + }); + + describe("deletedSend", () => { + it("refreshes the list and resets action and sendId", async () => { + component.action = "edit"; + component.sendId = "test-id"; + jest.spyOn(component, "refresh").mockResolvedValue(); + + const mockSend = new SendView(); + await component.deletedSend(mockSend); + + expect(component.refresh).toHaveBeenCalled(); + expect(component.action).toBe(""); + expect(component.sendId).toBeNull(); + }); + }); + + describe("savedSend", () => { + it("refreshes the list and selects the saved send", async () => { + jest.spyOn(component, "refresh").mockResolvedValue(); + jest.spyOn(component, "selectSend").mockResolvedValue(); + + const mockSend = new SendView(); + mockSend.id = "saved-send-id"; + + await component.savedSend(mockSend); + + expect(component.refresh).toHaveBeenCalled(); + expect(component.selectSend).toHaveBeenCalledWith("saved-send-id"); + }); + }); + + describe("selectSend", () => { + it("sets action to Edit and updates sendId", async () => { + await component.selectSend("new-send-id"); + + expect(component.action).toBe("edit"); + expect(component.sendId).toBe("new-send-id"); + }); + + it("updates addEditComponent when it exists", async () => { + const mockAddEdit = mock(); + component.addEditComponent = mockAddEdit; + + await component.selectSend("test-send-id"); + + expect(mockAddEdit.sendId).toBe("test-send-id"); + expect(mockAddEdit.refresh).toHaveBeenCalled(); + }); + + it("does not reload if same send is already selected in edit mode", async () => { + const mockAddEdit = mock(); + component.addEditComponent = mockAddEdit; + component.sendId = "same-id"; + component.action = "edit"; + + await component.selectSend("same-id"); + + expect(mockAddEdit.refresh).not.toHaveBeenCalled(); + }); + + it("reloads if selecting different send", async () => { + const mockAddEdit = mock(); + component.addEditComponent = mockAddEdit; + component.sendId = "old-id"; + component.action = "edit"; + + await component.selectSend("new-id"); + + expect(mockAddEdit.refresh).toHaveBeenCalled(); + }); + }); + + describe("selectedSendType", () => { + it("returns the type of the currently selected send", () => { + const mockSend1 = new SendView(); + mockSend1.id = "send-1"; + mockSend1.type = SendType.Text; + + const mockSend2 = new SendView(); + mockSend2.id = "send-2"; + mockSend2.type = SendType.File; + + component.sends = [mockSend1, mockSend2]; + component.sendId = "send-2"; + + expect(component.selectedSendType).toBe(SendType.File); + }); + + it("returns undefined when no send is selected", () => { + component.sends = []; + component.sendId = "non-existent"; + + expect(component.selectedSendType).toBeUndefined(); + }); + + it("returns undefined when sendId is null", () => { + const mockSend = new SendView(); + mockSend.id = "send-1"; + mockSend.type = SendType.Text; + + component.sends = [mockSend]; + component.sendId = null; + + expect(component.selectedSendType).toBeUndefined(); + }); + }); + + describe("viewSendMenu", () => { + let mockSend: SendView; + + beforeEach(() => { + mockSend = new SendView(); + mockSend.id = "test-send"; + mockSend.name = "Test Send"; + jest.clearAllMocks(); + }); + + it("creates menu with copy link option", () => { + jest.spyOn(component, "copy").mockResolvedValue(); + + component.viewSendMenu(mockSend); + + expect(utils.invokeMenu).toHaveBeenCalled(); + const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0]; + expect(menuItems.length).toBeGreaterThanOrEqual(2); // At minimum: copy link + delete + }); + + it("includes remove password option when send has password and is not disabled", () => { + mockSend.password = "test-password"; + mockSend.disabled = false; + jest.spyOn(component, "removePassword").mockResolvedValue(true); + + component.viewSendMenu(mockSend); + + expect(utils.invokeMenu).toHaveBeenCalled(); + const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0]; + expect(menuItems.length).toBe(3); // copy link + remove password + delete + }); + + it("excludes remove password option when send has no password", () => { + mockSend.password = null; + mockSend.disabled = false; + + component.viewSendMenu(mockSend); + + expect(utils.invokeMenu).toHaveBeenCalled(); + const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0]; + expect(menuItems.length).toBe(2); // copy link + delete (no remove password) + }); + + it("excludes remove password option when send is disabled", () => { + mockSend.password = "test-password"; + mockSend.disabled = true; + + component.viewSendMenu(mockSend); + + expect(utils.invokeMenu).toHaveBeenCalled(); + const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0]; + expect(menuItems.length).toBe(2); // copy link + delete (no remove password) + }); + + it("always includes delete option", () => { + jest.spyOn(component, "delete").mockResolvedValue(true); + jest.spyOn(component, "deletedSend").mockResolvedValue(); + + component.viewSendMenu(mockSend); + + expect(utils.invokeMenu).toHaveBeenCalled(); + const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0]; + // Delete is always the last item in the menu + expect(menuItems.length).toBeGreaterThan(0); + expect(menuItems[menuItems.length - 1]).toHaveProperty("label"); + expect(menuItems[menuItems.length - 1]).toHaveProperty("click"); + }); + }); + + describe("search bar subscription", () => { + it("updates searchText when search bar text changes", () => { + const searchSubject = new BehaviorSubject("initial"); + searchBarService.searchText$ = searchSubject; + + // Create new component to trigger constructor subscription + fixture = TestBed.createComponent(SendV2Component); + component = fixture.componentInstance; + + searchSubject.next("new search text"); + + expect(component.searchText).toBe("new search text"); + }); + }); + + describe("load", () => { + it("sets loading states correctly", async () => { + jest.spyOn(component, "search").mockResolvedValue(); + jest.spyOn(component, "selectAll"); + + expect(component.loaded).toBeFalsy(); + + await component.load(); + + expect(component.loading).toBe(false); + expect(component.loaded).toBe(true); + }); + + it("calls selectAll when onSuccessfulLoad is not set", async () => { + jest.spyOn(component, "search").mockResolvedValue(); + jest.spyOn(component, "selectAll"); + component.onSuccessfulLoad = null; + + await component.load(); + + expect(component.selectAll).toHaveBeenCalled(); + }); + + it("calls onSuccessfulLoad when it is set", async () => { + jest.spyOn(component, "search").mockResolvedValue(); + const mockCallback = jest.fn().mockResolvedValue(undefined); + component.onSuccessfulLoad = mockCallback; + + await component.load(); + + expect(mockCallback).toHaveBeenCalled(); + }); + }); }); diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.ts b/apps/desktop/src/app/tools/send-v2/send-v2.component.ts index 4840cd4cce8..4afe02d9f98 100644 --- a/apps/desktop/src/app/tools/send-v2/send-v2.component.ts +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.ts @@ -1,9 +1,233 @@ -import { Component, ChangeDetectionStrategy } from "@angular/core"; +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { CommonModule } from "@angular/common"; +import { Component, OnInit, OnDestroy, ViewChild, NgZone, ChangeDetectorRef } from "@angular/core"; +import { FormsModule } from "@angular/forms"; +import { mergeMap } from "rxjs"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { SendComponent as BaseSendComponent } from "@bitwarden/angular/tools/send/send.component"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +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 { 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"; +import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; +import { DialogService, ToastService } from "@bitwarden/components"; + +import { invokeMenu, RendererMenuItem } from "../../../utils"; +import { SearchBarService } from "../../layout/search/search-bar.service"; +import { AddEditComponent } from "../send/add-edit.component"; + +const Action = Object.freeze({ + /** No action is currently active. */ + None: "", + /** The user is adding a new Send. */ + Add: "add", + /** The user is editing an existing Send. */ + Edit: "edit", +} as const); + +type Action = (typeof Action)[keyof typeof Action]; + +const BroadcasterSubscriptionId = "SendV2Component"; + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-send-v2", - imports: [], - template: "

Sends V2 Component

", - changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, JslibModule, FormsModule, AddEditComponent], + templateUrl: "./send-v2.component.html", }) -export class SendV2Component {} +export class SendV2Component extends BaseSendComponent implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @ViewChild(AddEditComponent) addEditComponent: AddEditComponent; + + // The ID of the currently selected Send item being viewed or edited + sendId: string; + + // Tracks the current UI state: viewing list (None), adding new Send (Add), or editing existing Send (Edit) + action: Action = Action.None; + + constructor( + sendService: SendService, + i18nService: I18nService, + platformUtilsService: PlatformUtilsService, + environmentService: EnvironmentService, + private broadcasterService: BroadcasterService, + ngZone: NgZone, + searchService: SearchService, + policyService: PolicyService, + private searchBarService: SearchBarService, + logService: LogService, + sendApiService: SendApiService, + dialogService: DialogService, + toastService: ToastService, + accountService: AccountService, + private cdr: ChangeDetectorRef, + ) { + super( + sendService, + i18nService, + platformUtilsService, + environmentService, + ngZone, + searchService, + policyService, + logService, + sendApiService, + dialogService, + toastService, + accountService, + ); + + // Listen to search bar changes and update the Send list filter + // eslint-disable-next-line rxjs-angular/prefer-takeuntil + this.searchBarService.searchText$.subscribe((searchText) => { + this.searchText = searchText; + this.searchTextChanged(); + setTimeout(() => this.cdr.detectChanges(), 250); + }); + } + + // Initialize the component: enable search bar, subscribe to sync events, and load Send items + async ngOnInit() { + this.searchBarService.setEnabled(true); + this.searchBarService.setPlaceholderText(this.i18nService.t("searchSends")); + + await super.ngOnInit(); + + // Listen for sync completion events to refresh the Send list + this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => { + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.ngZone.run(async () => { + switch (message.command) { + case "syncCompleted": + await this.load(); + break; + } + }); + }); + await this.load(); + } + + // Clean up subscriptions and disable search bar when component is destroyed + ngOnDestroy() { + this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); + this.searchBarService.setEnabled(false); + } + + // Load Send items from the service and display them in the list. + // Subscribes to sendViews$ observable to get updates when Sends change. + // Manually triggers change detection to ensure UI updates immediately. + // Note: The filter parameter is ignored in this implementation for desktop-specific behavior. + async load(filter: (send: SendView) => boolean = null) { + this.loading = true; + this.sendService.sendViews$ + .pipe( + mergeMap(async (sends) => { + this.sends = sends; + await this.search(null); + // Trigger change detection after data updates + this.cdr.detectChanges(); + }), + ) + // eslint-disable-next-line rxjs-angular/prefer-takeuntil + .subscribe(); + if (this.onSuccessfulLoad != null) { + await this.onSuccessfulLoad(); + } else { + // Default action + this.selectAll(); + } + this.loading = false; + this.loaded = true; + } + + // Open the add Send form to create a new Send item + async addSend() { + this.action = Action.Add; + if (this.addEditComponent != null) { + await this.addEditComponent.resetAndLoad(); + } + } + + // Close the add/edit form and return to the list view + cancel(s: SendView) { + this.action = Action.None; + this.sendId = null; + } + + // Handle when a Send is deleted: refresh the list and close the edit form + async deletedSend(s: SendView) { + await this.refresh(); + this.action = Action.None; + this.sendId = null; + } + + // Handle when a Send is saved: refresh the list and re-select the saved Send + async savedSend(s: SendView) { + await this.refresh(); + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.selectSend(s.id); + } + + // Select a Send from the list and open it in the edit form. + // If the same Send is already selected and in edit mode, do nothing to avoid unnecessary reloads. + async selectSend(sendId: string) { + if (sendId === this.sendId && this.action === Action.Edit) { + return; + } + this.action = Action.Edit; + this.sendId = sendId; + if (this.addEditComponent != null) { + this.addEditComponent.sendId = sendId; + await this.addEditComponent.refresh(); + } + } + + // Get the type (text or file) of the currently selected Send for the edit form + get selectedSendType() { + return this.sends.find((s) => s.id === this.sendId)?.type; + } + + // Show the right-click context menu for a Send with options to copy link, remove password, or delete + viewSendMenu(send: SendView) { + const menu: RendererMenuItem[] = []; + menu.push({ + label: this.i18nService.t("copyLink"), + click: () => this.copy(send), + }); + if (send.password && !send.disabled) { + menu.push({ + label: this.i18nService.t("removePassword"), + click: async () => { + await this.removePassword(send); + if (this.sendId === send.id) { + this.sendId = null; + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.selectSend(send.id); + } + }, + }); + } + menu.push({ + label: this.i18nService.t("delete"), + click: async () => { + await this.delete(send); + await this.deletedSend(send); + }, + }); + + invokeMenu(menu); + } +}