-
+
+
-
+
+ }
- {{ "send" | i18n }}
+ @if (!disableSend()) { +
-
-
-
-
-
-
{{ "noItemsInList" | i18n }}
-
- ;
let sendService: MockProxy;
- let searchBarService: MockProxy;
- let broadcasterService: MockProxy;
let accountService: MockProxy;
let policyService: MockProxy;
- let sendListFiltersService: SendListFiltersService;
+ let sendItemsService: MockProxy;
+ let sendListFiltersService: MockProxy;
let changeDetectorRef: MockProxy;
beforeEach(async () => {
sendService = mock();
- searchBarService = mock();
- broadcasterService = mock();
accountService = mock();
policyService = mock();
changeDetectorRef = mock();
- // Create real SendListFiltersService with mocked dependencies
- const formBuilder = new FormBuilder();
- const i18nService = mock();
- i18nService.t.mockImplementation((key: string) => key);
- sendListFiltersService = new SendListFiltersService(i18nService, formBuilder);
+ // Mock SendItemsService with all required observables
+ sendItemsService = mock();
+ sendItemsService.filteredAndSortedSends$ = of([]);
+ sendItemsService.loading$ = of(false);
+ sendItemsService.emptyList$ = of(false);
+ sendItemsService.noFilteredResults$ = of(false);
+ sendItemsService.latestSearchText$ = of("");
+
+ // Mock SendListFiltersService
+ sendListFiltersService = mock();
// Mock sendViews$ observable
sendService.sendViews$ = of([]);
- searchBarService.searchText$ = new BehaviorSubject("");
- // Mock activeAccount$ observable for parent class ngOnInit
+ // Mock activeAccount$ observable
accountService.activeAccount$ = of({ id: "test-user-id" } as any);
policyService.policyAppliesToUser$ = jest.fn().mockReturnValue(of(false));
@@ -72,21 +68,27 @@ describe("SendV2Component", () => {
await TestBed.configureTestingModule({
imports: [SendV2Component],
providers: [
+ provideNoopAnimations(),
{ provide: SendService, useValue: sendService },
{ provide: I18nService, useValue: mock() },
{ provide: PlatformUtilsService, useValue: mock() },
{ provide: EnvironmentService, useValue: mock() },
- { provide: BroadcasterService, useValue: broadcasterService },
{ provide: SearchService, useValue: mockSearchService },
{ 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 },
+ { provide: SendItemsService, useValue: sendItemsService },
{ provide: SendListFiltersService, useValue: sendListFiltersService },
{ provide: ChangeDetectorRef, useValue: changeDetectorRef },
+ {
+ provide: BillingAccountProfileStateService,
+ useValue: mock(),
+ },
+ { provide: MessagingService, useValue: mock() },
+ { provide: ConfigService, useValue: mock() },
],
}).compileComponents();
@@ -99,108 +101,70 @@ describe("SendV2Component", () => {
});
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);
+ expect(component.action()).toBe("");
});
describe("addSend", () => {
it("sets action to Add", async () => {
- await component.addSend();
- expect(component.action).toBe("add");
+ await component.addSend(SendType.Text);
+ expect(component.action()).toBe("add");
});
it("calls resetAndLoad on addEditComponent when component exists", async () => {
const mockAddEdit = mock();
- component.addEditComponent = mockAddEdit;
+ mockAddEdit.resetAndLoad.mockResolvedValue();
+ jest.spyOn(component as any, "addEditComponent").mockReturnValue(mockAddEdit);
- await component.addSend();
+ await component.addSend(SendType.Text);
expect(mockAddEdit.resetAndLoad).toHaveBeenCalled();
});
it("does not throw when addEditComponent is null", async () => {
- component.addEditComponent = null;
- await expect(component.addSend()).resolves.not.toThrow();
+ jest.spyOn(component as any, "addEditComponent").mockReturnValue(undefined);
+ await expect(component.addSend(SendType.Text)).resolves.not.toThrow();
});
});
- describe("cancel", () => {
+ describe("closeEditPanel", () => {
it("resets action to None", () => {
- component.action = "edit";
- component.sendId = "test-id";
+ component["action"].set("edit");
+ component["sendId"].set("test-id");
- component.cancel(new SendView());
+ component["closeEditPanel"]();
- 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();
+ 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();
+ it("selects the saved send", async () => {
+ jest.spyOn(component as any, "selectSend").mockResolvedValue();
const mockSend = new SendView();
mockSend.id = "saved-send-id";
- await component.savedSend(mockSend);
+ await component["savedSend"](mockSend);
- expect(component.refresh).toHaveBeenCalled();
- expect(component.selectSend).toHaveBeenCalledWith("saved-send-id");
+ expect(component["selectSend"]).toHaveBeenCalledWith("saved-send-id");
});
});
describe("selectSend", () => {
it("sets action to Edit and updates sendId", async () => {
- await component.selectSend("new-send-id");
+ await component["selectSend"]("new-send-id");
- expect(component.action).toBe("edit");
- expect(component.sendId).toBe("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;
+ mockAddEdit.refresh.mockResolvedValue();
+ jest.spyOn(component as any, "addEditComponent").mockReturnValue(mockAddEdit);
- await component.selectSend("test-send-id");
+ await component["selectSend"]("test-send-id");
expect(mockAddEdit.sendId).toBe("test-send-id");
expect(mockAddEdit.refresh).toHaveBeenCalled();
@@ -208,179 +172,37 @@ describe("SendV2Component", () => {
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";
+ jest.spyOn(component as any, "addEditComponent").mockReturnValue(mockAddEdit);
+ component["sendId"].set("same-id");
+ component["action"].set("edit");
- await component.selectSend("same-id");
+ 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";
+ mockAddEdit.refresh.mockResolvedValue();
+ jest.spyOn(component as any, "addEditComponent").mockReturnValue(mockAddEdit);
+ component["sendId"].set("old-id");
+ component["action"].set("edit");
- await component.selectSend("new-id");
+ 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", () => {
+ describe("onEditSend", () => {
+ it("selects the send for editing", async () => {
+ jest.spyOn(component as any, "selectSend").mockResolvedValue();
const mockSend = new SendView();
- mockSend.id = "send-1";
- mockSend.type = SendType.Text;
+ mockSend.id = "edit-send-id";
- component.sends = [mockSend];
- component.sendId = null;
+ await component["onEditSend"](mockSend);
- 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();
-
- expect(component.loaded).toBeFalsy();
-
- await component.load();
-
- expect(component.loading).toBe(false);
- expect(component.loaded).toBe(true);
- });
-
- it("sets up sendViews$ subscription", async () => {
- const mockSends = [new SendView(), new SendView()];
- sendService.sendViews$ = of(mockSends);
- jest.spyOn(component, "search").mockResolvedValue();
-
- await component.load();
-
- // Give observable time to emit
- await new Promise((resolve) => setTimeout(resolve, 10));
-
- expect(component.sends).toEqual(mockSends);
- });
-
- 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();
+ expect(component["selectSend"]).toHaveBeenCalledWith("edit-send-id");
});
});
});
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 eb0856b76af..1b2ccb30247 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,16 +1,23 @@
// 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 { takeUntilDestroyed } from "@angular/core/rxjs-interop";
-import { FormsModule } from "@angular/forms";
-import { mergeMap, Subscription } from "rxjs";
+import {
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ computed,
+ effect,
+ inject,
+ signal,
+ viewChild,
+} from "@angular/core";
+import { toSignal } from "@angular/core/rxjs-interop";
+import { combineLatest, map, switchMap } 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 { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
-import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
+import { getUserId } from "@bitwarden/common/auth/services/account.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";
@@ -18,13 +25,16 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
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 { SendListFiltersService } from "@bitwarden/send-ui";
+import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
+import { ButtonModule, DialogService, ToastService } from "@bitwarden/components";
+import {
+ NewSendDropdownV2Component,
+ SendItemsService,
+ SendListComponent,
+ SendListState,
+} from "@bitwarden/send-ui";
-import { invokeMenu, RendererMenuItem } from "../../../utils";
-import { SearchBarService } from "../../layout/search/search-bar.service";
+import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-premium-upgrade-prompt.service";
import { AddEditComponent } from "../send/add-edit.component";
const Action = Object.freeze({
@@ -38,224 +48,204 @@ const Action = Object.freeze({
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: [CommonModule, JslibModule, FormsModule, AddEditComponent],
+ imports: [
+ JslibModule,
+ ButtonModule,
+ AddEditComponent,
+ SendListComponent,
+ NewSendDropdownV2Component,
+ ],
+ providers: [
+ {
+ provide: PremiumUpgradePromptService,
+ useClass: DesktopPremiumUpgradePromptService,
+ },
+ ],
templateUrl: "./send-v2.component.html",
+ changeDetection: ChangeDetectionStrategy.OnPush,
})
-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;
+export class SendV2Component {
+ protected readonly addEditComponent = viewChild(AddEditComponent);
- // The ID of the currently selected Send item being viewed or edited
- sendId: string;
+ protected readonly sendId = signal(null);
+ protected readonly action = signal(Action.None);
+ private readonly selectedSendTypeOverride = signal(undefined);
- // Tracks the current UI state: viewing list (None), adding new Send (Add), or editing existing Send (Edit)
- action: Action = Action.None;
+ private sendItemsService = inject(SendItemsService);
+ private policyService = inject(PolicyService);
+ private accountService = inject(AccountService);
+ private i18nService = inject(I18nService);
+ private platformUtilsService = inject(PlatformUtilsService);
+ private environmentService = inject(EnvironmentService);
+ private logService = inject(LogService);
+ private sendApiService = inject(SendApiService);
+ private dialogService = inject(DialogService);
+ private toastService = inject(ToastService);
+ private cdr = inject(ChangeDetectorRef);
- // Subscription for sendViews$ cleanup
- private sendViewsSubscription: Subscription;
+ protected readonly filteredSends = toSignal(this.sendItemsService.filteredAndSortedSends$, {
+ initialValue: [],
+ });
- 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,
- private sendListFiltersService: SendListFiltersService,
- ) {
- super(
- sendService,
- i18nService,
- platformUtilsService,
- environmentService,
- ngZone,
- searchService,
- policyService,
- logService,
- sendApiService,
- dialogService,
- toastService,
- accountService,
- );
+ protected readonly loading = toSignal(this.sendItemsService.loading$, { initialValue: true });
- // Listen to search bar changes and update the Send list filter
- this.searchBarService.searchText$.pipe(takeUntilDestroyed()).subscribe((searchText) => {
- this.searchText = searchText;
- this.searchTextChanged();
- });
+ protected readonly currentSearchText = toSignal(this.sendItemsService.latestSearchText$, {
+ initialValue: "",
+ });
- // Listen to filter changes from sidebar navigation
- this.sendListFiltersService.filterForm.valueChanges
- .pipe(takeUntilDestroyed())
- .subscribe((filters) => {
- this.applySendTypeFilter(filters);
- });
- }
+ protected readonly disableSend = toSignal(
+ this.accountService.activeAccount$.pipe(
+ getUserId,
+ switchMap((userId) =>
+ this.policyService.policyAppliesToUser$(PolicyType.DisableSend, userId),
+ ),
+ ),
+ { initialValue: false },
+ );
- // 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();
-
- // Read current filter synchronously to avoid race condition on navigation
- const currentFilter = this.sendListFiltersService.filterForm.value;
- this.applySendTypeFilter(currentFilter);
-
- // 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;
+ protected readonly listState = toSignal(
+ combineLatest([
+ this.sendItemsService.emptyList$,
+ this.sendItemsService.noFilteredResults$,
+ ]).pipe(
+ map(([emptyList, noFilteredResults]): SendListState | null => {
+ if (emptyList) {
+ return SendListState.Empty;
}
- });
+ if (noFilteredResults) {
+ return SendListState.NoResults;
+ }
+ return null;
+ }),
+ ),
+ { initialValue: null },
+ );
+
+ constructor() {
+ // WORKAROUND: Force change detection when data updates
+ // This is needed because SendSearchComponent (shared lib) hasn't migrated to OnPush yet
+ // and doesn't trigger CD properly when search/add operations complete
+ // TODO: Remove this once SendSearchComponent migrates to OnPush (tracked in CL-764)
+ effect(() => {
+ this.filteredSends();
+ this.cdr.markForCheck();
});
- await this.load();
}
- // Apply send type filter to display: centralized logic for initial load and filter changes
- private applySendTypeFilter(filters: Partial<{ sendType: SendType | null }>): void {
- if (filters.sendType === null || filters.sendType === undefined) {
- this.selectAll();
- } else {
- this.selectType(filters.sendType);
+ protected async addSend(type: SendType): Promise {
+ this.action.set(Action.Add);
+ this.sendId.set(null);
+ this.selectedSendTypeOverride.set(type);
+
+ const component = this.addEditComponent();
+ if (component) {
+ await component.resetAndLoad();
}
}
- // Clean up subscriptions and disable search bar when component is destroyed
- ngOnDestroy() {
- this.sendViewsSubscription?.unsubscribe();
- this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
- this.searchBarService.setEnabled(false);
+ protected closeEditPanel(): void {
+ this.action.set(Action.None);
+ this.sendId.set(null);
+ this.selectedSendTypeOverride.set(undefined);
}
- // 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;
-
- // Recreate subscription on each load (required for sync refresh)
- // Manual cleanup in ngOnDestroy is intentional - load() is called multiple times
- this.sendViewsSubscription?.unsubscribe();
-
- this.sendViewsSubscription = 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();
- }
- this.loading = false;
- this.loaded = true;
+ protected async savedSend(send: SendView): Promise {
+ await this.selectSend(send.id);
}
- // 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) {
+ protected async selectSend(sendId: string): Promise {
+ 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();
+ this.action.set(Action.Edit);
+ this.sendId.set(sendId);
+ const component = this.addEditComponent();
+ if (component) {
+ component.sendId = sendId;
+ await component.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;
+ protected readonly selectedSendType = computed(() => {
+ const action = this.action();
+ const typeOverride = this.selectedSendTypeOverride();
+
+ if (action === Action.Add && typeOverride !== undefined) {
+ return typeOverride;
+ }
+
+ const sendId = this.sendId();
+ return this.filteredSends().find((s) => s.id === sendId)?.type;
+ });
+
+ protected async onEditSend(send: SendView): Promise {
+ await this.selectSend(send.id);
}
- // 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),
+ protected async onCopySend(send: SendView): Promise {
+ const env = await this.environmentService.getEnvironment();
+ const link = env.getSendUrl() + send.accessId + "/" + send.urlB64Key;
+ this.platformUtilsService.copyToClipboard(link);
+ this.toastService.showToast({
+ variant: "success",
+ title: null,
+ message: this.i18nService.t("valueCopied", this.i18nService.t("sendLink")),
});
- 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);
- }
- },
+ }
+
+ protected async onRemovePassword(send: SendView): Promise {
+ if (this.disableSend()) {
+ return;
+ }
+
+ const confirmed = await this.dialogService.openSimpleDialog({
+ title: { key: "removePassword" },
+ content: { key: "removePasswordConfirmation" },
+ type: "warning",
+ });
+
+ if (!confirmed) {
+ return;
+ }
+
+ try {
+ await this.sendApiService.removePassword(send.id);
+ this.toastService.showToast({
+ variant: "success",
+ title: null,
+ message: this.i18nService.t("removedPassword"),
});
+
+ if (this.sendId() === send.id) {
+ this.sendId.set(null);
+ await this.selectSend(send.id);
+ }
+ } catch (e) {
+ this.logService.error(e);
}
- menu.push({
- label: this.i18nService.t("delete"),
- click: async () => {
- await this.delete(send);
- await this.deletedSend(send);
- },
+ }
+
+ protected async onDeleteSend(send: SendView): Promise {
+ const confirmed = await this.dialogService.openSimpleDialog({
+ title: { key: "deleteSend" },
+ content: { key: "deleteSendConfirmation" },
+ type: "warning",
});
- invokeMenu(menu);
+ if (!confirmed) {
+ return;
+ }
+
+ await this.sendApiService.delete(send.id);
+
+ this.toastService.showToast({
+ variant: "success",
+ title: null,
+ message: this.i18nService.t("deletedSend"),
+ });
+
+ this.closeEditPanel();
}
}
diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json
index 9be96a62589..3ace5308d27 100644
--- a/apps/desktop/src/locales/en/messages.json
+++ b/apps/desktop/src/locales/en/messages.json
@@ -100,6 +100,10 @@
}
}
},
+ "new": {
+ "message": "New",
+ "description": "for adding new items"
+ },
"newUri": {
"message": "New URI"
},
@@ -2411,6 +2415,10 @@
"message": "Are you sure you want to delete this Send?",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
+ "copySendLink": {
+ "message": "Copy Send link",
+ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
+ },
"copySendLinkToClipboard": {
"message": "Copy Send link to clipboard",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
@@ -4031,10 +4039,16 @@
"message": "Send sensitive information safely",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
+ "sendsTitleNoSearchResults": {
+ "message": "No search results returned"
+ },
"sendsBodyNoItems": {
"message": "Share files and data securely with anyone, on any platform. Your information will remain end-to-end encrypted while limiting exposure.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
+ "sendsBodyNoSearchResults": {
+ "message": "Clear filters or try another search term"
+ },
"generatorNudgeTitle": {
"message": "Quickly create passwords"
},
diff --git a/apps/desktop/src/scss/migration.scss b/apps/desktop/src/scss/migration.scss
index e3078158283..ba70d4fa009 100644
--- a/apps/desktop/src/scss/migration.scss
+++ b/apps/desktop/src/scss/migration.scss
@@ -13,3 +13,17 @@ bit-layout {
padding: 0 0 0 0;
}
}
+/**
+ * Send list panel styling for send-v2 component
+ * Temporary during migration - width handled by tw-w-2/5
+ **/
+.vault > .send-items-panel {
+ order: 2;
+ min-width: 200px;
+ border-right: 1px solid;
+
+ @include themify($themes) {
+ background-color: themed("backgroundColor");
+ border-right-color: themed("borderColor");
+ }
+}
diff --git a/libs/tools/send/send-ui/src/index.ts b/libs/tools/send/send-ui/src/index.ts
index ac8b9383681..b125e76e000 100644
--- a/libs/tools/send/send-ui/src/index.ts
+++ b/libs/tools/send/send-ui/src/index.ts
@@ -1,5 +1,6 @@
export * from "./send-form";
export { NewSendDropdownComponent } from "./new-send-dropdown/new-send-dropdown.component";
+export { NewSendDropdownV2Component } from "./new-send-dropdown-v2/new-send-dropdown-v2.component";
export * from "./add-edit/send-add-edit-dialog.component";
export { SendListItemsContainerComponent } from "./send-list-items-container/send-list-items-container.component";
export { SendItemsService } from "./services/send-items.service";
@@ -7,3 +8,4 @@ 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";
export { SendTableComponent } from "./send-table/send-table.component";
+export { SendListComponent, SendListState } from "./send-list/send-list.component";
diff --git a/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.html b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.html
new file mode 100644
index 00000000000..7e447a13441
--- /dev/null
+++ b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.html
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.spec.ts b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.spec.ts
new file mode 100644
index 00000000000..8f8390a170c
--- /dev/null
+++ b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.spec.ts
@@ -0,0 +1,261 @@
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { mock, MockProxy } from "jest-mock-extended";
+import { BehaviorSubject, of } from "rxjs";
+
+import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
+import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
+import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
+
+import { NewSendDropdownV2Component } from "./new-send-dropdown-v2.component";
+
+describe("NewSendDropdownV2Component", () => {
+ let component: NewSendDropdownV2Component;
+ let fixture: ComponentFixture;
+ let billingService: MockProxy;
+ let accountService: MockProxy;
+ let premiumUpgradeService: MockProxy;
+
+ beforeEach(async () => {
+ billingService = mock();
+ accountService = mock();
+ premiumUpgradeService = mock();
+
+ // Default: user has premium
+ accountService.activeAccount$ = of({ id: "user-123" } as any);
+ billingService.hasPremiumFromAnySource$.mockReturnValue(of(true));
+
+ const i18nService = mock();
+ i18nService.t.mockImplementation((key: string) => key);
+
+ await TestBed.configureTestingModule({
+ imports: [NewSendDropdownV2Component],
+ providers: [
+ { provide: BillingAccountProfileStateService, useValue: billingService },
+ { provide: AccountService, useValue: accountService },
+ { provide: PremiumUpgradePromptService, useValue: premiumUpgradeService },
+ { provide: I18nService, useValue: i18nService },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(NewSendDropdownV2Component);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("should create", () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe("input signals", () => {
+ it("has correct default input values", () => {
+ expect(component.hideIcon()).toBe(false);
+ expect(component.buttonType()).toBe("primary");
+ });
+
+ it("accepts input signal values", () => {
+ fixture.componentRef.setInput("hideIcon", true);
+ fixture.componentRef.setInput("buttonType", "secondary");
+
+ expect(component.hideIcon()).toBe(true);
+ expect(component.buttonType()).toBe("secondary");
+ });
+ });
+
+ describe("premium status detection", () => {
+ it("hasNoPremium is false when user has premium", () => {
+ billingService.hasPremiumFromAnySource$.mockReturnValue(of(true));
+ accountService.activeAccount$ = of({ id: "user-123" } as any);
+
+ fixture = TestBed.createComponent(NewSendDropdownV2Component);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+
+ expect(component["hasNoPremium"]()).toBe(false);
+ });
+
+ it("hasNoPremium is true when user lacks premium", () => {
+ billingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
+ accountService.activeAccount$ = of({ id: "user-123" } as any);
+
+ fixture = TestBed.createComponent(NewSendDropdownV2Component);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+
+ expect(component["hasNoPremium"]()).toBe(true);
+ });
+
+ it("hasNoPremium defaults to true when no active account", () => {
+ accountService.activeAccount$ = of(null);
+
+ fixture = TestBed.createComponent(NewSendDropdownV2Component);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+
+ expect(component["hasNoPremium"]()).toBe(true);
+ });
+
+ it("hasNoPremium updates reactively when premium status changes", async () => {
+ const premiumSubject = new BehaviorSubject(false);
+ billingService.hasPremiumFromAnySource$.mockReturnValue(premiumSubject.asObservable());
+
+ fixture = TestBed.createComponent(NewSendDropdownV2Component);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+
+ expect(component["hasNoPremium"]()).toBe(true);
+
+ premiumSubject.next(true);
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ expect(component["hasNoPremium"]()).toBe(false);
+ });
+ });
+
+ describe("text send functionality", () => {
+ it("onTextSendClick emits SendType.Text", () => {
+ const emitSpy = jest.fn();
+ component.addSend.subscribe(emitSpy);
+
+ component["onTextSendClick"]();
+
+ expect(emitSpy).toHaveBeenCalledWith(SendType.Text);
+ });
+
+ it("allows text send without premium", () => {
+ billingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
+
+ fixture = TestBed.createComponent(NewSendDropdownV2Component);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+
+ const emitSpy = jest.fn();
+ component.addSend.subscribe(emitSpy);
+
+ component["onTextSendClick"]();
+
+ expect(emitSpy).toHaveBeenCalledWith(SendType.Text);
+ expect(premiumUpgradeService.promptForPremium).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("file send premium gating", () => {
+ it("onFileSendClick emits SendType.File when user has premium", async () => {
+ const emitSpy = jest.fn();
+ component.addSend.subscribe(emitSpy);
+
+ await component["onFileSendClick"]();
+
+ expect(emitSpy).toHaveBeenCalledWith(SendType.File);
+ expect(premiumUpgradeService.promptForPremium).not.toHaveBeenCalled();
+ });
+
+ it("onFileSendClick shows premium prompt without premium", async () => {
+ billingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
+ premiumUpgradeService.promptForPremium.mockResolvedValue();
+
+ fixture = TestBed.createComponent(NewSendDropdownV2Component);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+
+ const emitSpy = jest.fn();
+ component.addSend.subscribe(emitSpy);
+
+ await component["onFileSendClick"]();
+
+ expect(premiumUpgradeService.promptForPremium).toHaveBeenCalled();
+ expect(emitSpy).not.toHaveBeenCalled();
+ });
+
+ it("does not emit file send type when premium prompt is shown", async () => {
+ billingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
+
+ fixture = TestBed.createComponent(NewSendDropdownV2Component);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+
+ const emitSpy = jest.fn();
+ component.addSend.subscribe(emitSpy);
+
+ await component["onFileSendClick"]();
+
+ expect(emitSpy).not.toHaveBeenCalledWith(SendType.File);
+ });
+
+ it("allows file send after user gains premium", async () => {
+ const premiumSubject = new BehaviorSubject(false);
+ billingService.hasPremiumFromAnySource$.mockReturnValue(premiumSubject.asObservable());
+
+ fixture = TestBed.createComponent(NewSendDropdownV2Component);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+
+ // Initially no premium
+ let emitSpy = jest.fn();
+ component.addSend.subscribe(emitSpy);
+ await component["onFileSendClick"]();
+ expect(premiumUpgradeService.promptForPremium).toHaveBeenCalled();
+
+ // Gain premium
+ premiumSubject.next(true);
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ // Now should emit
+ emitSpy = jest.fn();
+ component.addSend.subscribe(emitSpy);
+ await component["onFileSendClick"]();
+ expect(emitSpy).toHaveBeenCalledWith(SendType.File);
+ });
+ });
+
+ describe("edge cases", () => {
+ it("handles null account without errors", () => {
+ accountService.activeAccount$ = of(null);
+
+ expect(() => {
+ fixture = TestBed.createComponent(NewSendDropdownV2Component);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ }).not.toThrow();
+
+ expect(component["hasNoPremium"]()).toBe(true);
+ });
+
+ it("handles rapid clicks without race conditions", async () => {
+ const emitSpy = jest.fn();
+ component.addSend.subscribe(emitSpy);
+
+ // Rapid text send clicks
+ component["onTextSendClick"]();
+ component["onTextSendClick"]();
+ component["onTextSendClick"]();
+
+ expect(emitSpy).toHaveBeenCalledTimes(3);
+
+ // Rapid file send clicks (with premium)
+ await Promise.all([
+ component["onFileSendClick"](),
+ component["onFileSendClick"](),
+ component["onFileSendClick"](),
+ ]);
+
+ expect(emitSpy).toHaveBeenCalledTimes(6); // 3 text + 3 file
+ });
+
+ it("cleans up subscriptions on destroy", () => {
+ const subscription = component["hasNoPremium"];
+
+ fixture.destroy();
+
+ // Signal should still exist but component cleanup handled by Angular
+ expect(() => subscription()).not.toThrow();
+ });
+ });
+});
diff --git a/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.ts b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.ts
new file mode 100644
index 00000000000..7e7c4a2005b
--- /dev/null
+++ b/libs/tools/send/send-ui/src/new-send-dropdown-v2/new-send-dropdown-v2.component.ts
@@ -0,0 +1,59 @@
+import { ChangeDetectionStrategy, Component, inject, input, output } from "@angular/core";
+import { toSignal } from "@angular/core/rxjs-interop";
+import { map, of, switchMap } from "rxjs";
+
+import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
+import { JslibModule } from "@bitwarden/angular/jslib.module";
+import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
+import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
+import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
+import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
+import { ButtonModule, ButtonType, MenuModule } from "@bitwarden/components";
+
+// Desktop-specific version of NewSendDropdownComponent.
+// Unlike the shared library version, this component emits events instead of using Angular Router,
+// which aligns with Desktop's modal-based architecture.
+@Component({
+ selector: "tools-new-send-dropdown-v2",
+ templateUrl: "new-send-dropdown-v2.component.html",
+ imports: [JslibModule, ButtonModule, MenuModule, PremiumBadgeComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class NewSendDropdownV2Component {
+ readonly hideIcon = input(false);
+ readonly buttonType = input("primary");
+
+ readonly addSend = output();
+
+ protected sendType = SendType;
+
+ private readonly billingAccountProfileStateService = inject(BillingAccountProfileStateService);
+ private readonly accountService = inject(AccountService);
+ private readonly premiumUpgradePromptService = inject(PremiumUpgradePromptService);
+
+ protected readonly hasNoPremium = toSignal(
+ this.accountService.activeAccount$.pipe(
+ switchMap((account) => {
+ if (!account) {
+ return of(true);
+ }
+ return this.billingAccountProfileStateService
+ .hasPremiumFromAnySource$(account.id)
+ .pipe(map((hasPremium) => !hasPremium));
+ }),
+ ),
+ { initialValue: true },
+ );
+
+ protected onTextSendClick(): void {
+ this.addSend.emit(SendType.Text);
+ }
+
+ protected async onFileSendClick(): Promise {
+ if (this.hasNoPremium()) {
+ await this.premiumUpgradePromptService.promptForPremium();
+ } else {
+ this.addSend.emit(SendType.File);
+ }
+ }
+}
diff --git a/libs/tools/send/send-ui/src/send-list/send-list.component.html b/libs/tools/send/send-ui/src/send-list/send-list.component.html
new file mode 100644
index 00000000000..acdd6fa8d4d
--- /dev/null
+++ b/libs/tools/send/send-ui/src/send-list/send-list.component.html
@@ -0,0 +1,31 @@
+@if (loading()) {
+
+} @else {
+ @if (showSearchBar()) {
+
+
+ }
+
+ @if (noSearchResults()) {
+
+
+ {{ "sendsTitleNoSearchResults" | i18n }}
+ {{ "sendsBodyNoSearchResults" | i18n }}
+
+ } @else if (listState() === sendListState.NoResults || listState() === sendListState.Empty) {
+
+
+
+ {{ "sendsTitleNoItems" | i18n }}
+ {{ "sendsBodyNoItems" | i18n }}
+
+
+ }
+}
diff --git a/libs/tools/send/send-ui/src/send-list/send-list.component.spec.ts b/libs/tools/send/send-ui/src/send-list/send-list.component.spec.ts
new file mode 100644
index 00000000000..03539b99afa
--- /dev/null
+++ b/libs/tools/send/send-ui/src/send-list/send-list.component.spec.ts
@@ -0,0 +1,89 @@
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { mock, MockProxy } from "jest-mock-extended";
+import { of } from "rxjs";
+
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+
+import { SendItemsService } from "../services/send-items.service";
+
+import { SendListComponent } from "./send-list.component";
+
+describe("SendListComponent", () => {
+ let component: SendListComponent;
+ let fixture: ComponentFixture;
+ let i18nService: MockProxy;
+ let sendItemsService: MockProxy;
+
+ beforeEach(async () => {
+ i18nService = mock();
+ i18nService.t.mockImplementation((key) => key);
+
+ // Mock SendItemsService for SendSearchComponent child component
+ sendItemsService = mock();
+ sendItemsService.latestSearchText$ = of("");
+
+ await TestBed.configureTestingModule({
+ imports: [SendListComponent],
+ providers: [
+ { provide: I18nService, useValue: i18nService },
+ { provide: SendItemsService, useValue: sendItemsService },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(SendListComponent);
+ component = fixture.componentInstance;
+ });
+
+ it("should create", () => {
+ expect(component).toBeTruthy();
+ });
+
+ it("should display empty state when listState is Empty", () => {
+ fixture.componentRef.setInput("sends", []);
+ fixture.componentRef.setInput("listState", "Empty");
+ fixture.detectChanges();
+
+ const compiled = fixture.nativeElement;
+ expect(compiled.textContent).toContain("sendsTitleNoItems");
+ });
+
+ it("should display no results state when listState is NoResults", () => {
+ fixture.componentRef.setInput("sends", []);
+ fixture.componentRef.setInput("listState", "NoResults");
+ fixture.detectChanges();
+
+ const compiled = fixture.nativeElement;
+ // Component shows same empty state for both Empty and NoResults states
+ expect(compiled.textContent).toContain("sendsTitleNoItems");
+ });
+
+ it("should emit editSend event when send is edited", () => {
+ const editSpy = jest.fn();
+ component.editSend.subscribe(editSpy);
+
+ const mockSend = { id: "test-id", name: "Test Send" } as any;
+ component["onEditSend"](mockSend);
+
+ expect(editSpy).toHaveBeenCalledWith(mockSend);
+ });
+
+ it("should emit copySend event when send link is copied", () => {
+ const copySpy = jest.fn();
+ component.copySend.subscribe(copySpy);
+
+ const mockSend = { id: "test-id", name: "Test Send" } as any;
+ component["onCopySend"](mockSend);
+
+ expect(copySpy).toHaveBeenCalledWith(mockSend);
+ });
+
+ it("should emit deleteSend event when send is deleted", () => {
+ const deleteSpy = jest.fn();
+ component.deleteSend.subscribe(deleteSpy);
+
+ const mockSend = { id: "test-id", name: "Test Send" } as any;
+ component["onDeleteSend"](mockSend);
+
+ expect(deleteSpy).toHaveBeenCalledWith(mockSend);
+ });
+});
diff --git a/libs/tools/send/send-ui/src/send-list/send-list.component.ts b/libs/tools/send/send-ui/src/send-list/send-list.component.ts
new file mode 100644
index 00000000000..d90f77913aa
--- /dev/null
+++ b/libs/tools/send/send-ui/src/send-list/send-list.component.ts
@@ -0,0 +1,105 @@
+import { CommonModule } from "@angular/common";
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ effect,
+ inject,
+ input,
+ output,
+} from "@angular/core";
+
+import { JslibModule } from "@bitwarden/angular/jslib.module";
+import { NoResults, NoSendsIcon } from "@bitwarden/assets/svg";
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
+import {
+ ButtonModule,
+ NoItemsModule,
+ SpinnerComponent,
+ TableDataSource,
+} from "@bitwarden/components";
+
+import { SendSearchComponent } from "../send-search/send-search.component";
+import { SendTableComponent } from "../send-table/send-table.component";
+
+/** A state of the Send list UI. */
+export const SendListState = Object.freeze({
+ /** No Sends exist at all (File or Text). */
+ Empty: "Empty",
+ /** Sends exist, but none match the current Side Nav Filter (File or Text). */
+ NoResults: "NoResults",
+} as const);
+
+/** A state of the Send list UI. */
+export type SendListState = (typeof SendListState)[keyof typeof SendListState];
+
+/**
+ * A container component for displaying the Send list with search, table, and empty states.
+ * Handles the presentation layer while delegating data management to services.
+ */
+@Component({
+ selector: "tools-send-list",
+ templateUrl: "./send-list.component.html",
+ imports: [
+ CommonModule,
+ JslibModule,
+ ButtonModule,
+ NoItemsModule,
+ SpinnerComponent,
+ SendSearchComponent,
+ SendTableComponent,
+ ],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class SendListComponent {
+ protected readonly noItemIcon = NoSendsIcon;
+ protected readonly noResultsIcon = NoResults;
+ protected readonly sendListState = SendListState;
+
+ private i18nService = inject(I18nService);
+
+ readonly sends = input.required();
+ readonly loading = input(false);
+ readonly disableSend = input(false);
+ readonly listState = input(null);
+ readonly searchText = input("");
+
+ protected readonly showSearchBar = computed(
+ () => this.sends().length > 0 || this.searchText().length > 0,
+ );
+
+ protected readonly noSearchResults = computed(
+ () => this.showSearchBar() && (this.sends().length === 0 || this.searchText().length > 0),
+ );
+
+ // Reusable data source instance - updated reactively when sends change
+ protected readonly dataSource = new TableDataSource();
+
+ constructor() {
+ effect(() => {
+ this.dataSource.data = this.sends();
+ });
+ }
+
+ readonly editSend = output();
+ readonly copySend = output();
+ readonly removePassword = output();
+ readonly deleteSend = output();
+
+ protected onEditSend(send: SendView): void {
+ this.editSend.emit(send);
+ }
+
+ protected onCopySend(send: SendView): void {
+ this.copySend.emit(send);
+ }
+
+ protected onRemovePassword(send: SendView): void {
+ this.removePassword.emit(send);
+ }
+
+ protected onDeleteSend(send: SendView): void {
+ this.deleteSend.emit(send);
+ }
+}
-
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 8657f3e375e..58c9ce8e0b4 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
@@ -2,16 +2,18 @@
// @ts-strict-ignore
import { ChangeDetectorRef } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
-import { FormBuilder } from "@angular/forms";
+import { provideNoopAnimations } from "@angular/platform-browser/animations";
import { mock, MockProxy } from "jest-mock-extended";
-import { BehaviorSubject, of } from "rxjs";
+import { 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 { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
+import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.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 { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.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";
@@ -19,49 +21,43 @@ import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.s
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 { SendListFiltersService } from "@bitwarden/send-ui";
+import { SendItemsService, SendListFiltersService } from "@bitwarden/send-ui";
-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
-
+
+
+ @if (action() == "add" || action() == "edit") {
+
+ }
+
+
+ @if (!action()) {
+
+ }
+
-
+
+
+