mirror of
https://github.com/bitwarden/browser
synced 2026-02-17 09:59:41 +00:00
Feature/pm 25865 migrate send list desktop migration (#18008)
This PR moves the Desktop Send list UI into a shared library component and updates the Desktop Send v2 component to use modern Angular patterns (Signals, OnPush, no manual subscriptions)
This commit is contained in:
@@ -1,110 +1,64 @@
|
||||
<div id="sends" class="vault">
|
||||
<div id="items" class="items">
|
||||
<div class="send-items-panel tw-w-2/5">
|
||||
<div class="content">
|
||||
<div class="list full-height" *ngIf="filteredSends && filteredSends.length">
|
||||
<button
|
||||
type="button"
|
||||
*ngFor="let s of filteredSends"
|
||||
appStopClick
|
||||
(click)="selectSend(s.id)"
|
||||
title="{{ 'viewItem' | i18n }}"
|
||||
(contextmenu)="viewSendMenu(s)"
|
||||
[ngClass]="{ active: s.id === sendId }"
|
||||
[attr.aria-pressed]="s.id === sendId"
|
||||
class="flex-list-item"
|
||||
>
|
||||
<span class="item-icon" aria-hidden="true">
|
||||
<i class="bwi bwi-fw bwi-lg" [ngClass]="s.type == 0 ? 'bwi-file-text' : 'bwi-file'"></i>
|
||||
</span>
|
||||
<span class="item-content">
|
||||
<span class="item-title">
|
||||
{{ s.name }}
|
||||
<span class="title-badges">
|
||||
<ng-container *ngIf="s.disabled">
|
||||
<i
|
||||
class="bwi bwi-exclamation-triangle"
|
||||
appStopProp
|
||||
title="{{ 'disabled' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "disabled" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="s.password">
|
||||
<i
|
||||
class="bwi bwi-key"
|
||||
appStopProp
|
||||
title="{{ 'password' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "password" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="s.maxAccessCountReached">
|
||||
<i
|
||||
class="bwi bwi-exclamation-triangle"
|
||||
appStopProp
|
||||
title="{{ 'maxAccessCountReached' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "maxAccessCountReached" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="s.expired">
|
||||
<i
|
||||
class="bwi bwi-clock"
|
||||
appStopProp
|
||||
title="{{ 'expired' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "expired" | i18n }}</span>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="s.pendingDelete">
|
||||
<i
|
||||
class="bwi bwi-trash"
|
||||
appStopProp
|
||||
title="{{ 'pendingDeletion' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "pendingDeletion" | i18n }}</span>
|
||||
</ng-container>
|
||||
</span>
|
||||
</span>
|
||||
<span class="item-details">{{ s.deletionDate | date }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<!-- Header with Send title and New button -->
|
||||
<div
|
||||
class="tw-flex tw-items-center tw-justify-between tw-px-4 tw-py-3 tw-border-b tw-border-secondary-300"
|
||||
>
|
||||
<h1 class="tw-text-base tw-font-semibold tw-m-0">{{ "send" | i18n }}</h1>
|
||||
@if (!disableSend()) {
|
||||
<tools-new-send-dropdown-v2
|
||||
[buttonType]="'primary'"
|
||||
(addSend)="addSend($event)"
|
||||
></tools-new-send-dropdown-v2>
|
||||
}
|
||||
</div>
|
||||
<div class="no-items" *ngIf="!filteredSends || !filteredSends.length">
|
||||
<i class="bwi bwi-spinner bwi-spin bwi-3x" *ngIf="!loaded" aria-hidden="true"></i>
|
||||
<ng-container *ngIf="loaded">
|
||||
<img class="no-items-image" aria-hidden="true" />
|
||||
<p>{{ "noItemsInList" | i18n }}</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<button
|
||||
type="button"
|
||||
(click)="addSend()"
|
||||
class="block primary"
|
||||
appA11yTitle="{{ 'addItem' | i18n }}"
|
||||
|
||||
<!-- Send List Component -->
|
||||
<div class="tw-my-4 tw-px-4">
|
||||
<tools-send-list
|
||||
[sends]="filteredSends()"
|
||||
[loading]="loading()"
|
||||
[disableSend]="disableSend()"
|
||||
[listState]="listState()"
|
||||
[searchText]="currentSearchText()"
|
||||
(editSend)="onEditSend($event)"
|
||||
(copySend)="onCopySend($event)"
|
||||
(deleteSend)="onDeleteSend($event)"
|
||||
(removePassword)="onRemovePassword($event)"
|
||||
>
|
||||
<i class="bwi bwi-plus bwi-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
<tools-new-send-dropdown-v2
|
||||
slot="empty-button"
|
||||
[hideIcon]="true"
|
||||
buttonType="primary"
|
||||
(addSend)="addSend($event)"
|
||||
/>
|
||||
</tools-send-list>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<app-send-add-edit
|
||||
id="addEdit"
|
||||
class="details"
|
||||
*ngIf="action == 'add' || action == 'edit'"
|
||||
[sendId]="sendId"
|
||||
[type]="selectedSendType"
|
||||
(onSavedSend)="savedSend($event)"
|
||||
(onCancelled)="cancel($event)"
|
||||
(onDeletedSend)="deletedSend($event)"
|
||||
></app-send-add-edit>
|
||||
<div class="logo" *ngIf="!action">
|
||||
<div class="content">
|
||||
<div class="inner-content">
|
||||
<img class="logo-image" alt="Bitwarden" aria-hidden="true" />
|
||||
|
||||
<!-- Edit/Add panel (right side) -->
|
||||
@if (action() == "add" || action() == "edit") {
|
||||
<app-send-add-edit
|
||||
id="addEdit"
|
||||
class="details"
|
||||
[sendId]="sendId()"
|
||||
[type]="selectedSendType()"
|
||||
(onSavedSend)="savedSend($event)"
|
||||
(onCancelled)="closeEditPanel()"
|
||||
(onDeletedSend)="closeEditPanel()"
|
||||
></app-send-add-edit>
|
||||
}
|
||||
|
||||
<!-- Bitwarden logo (shown when no send is selected) -->
|
||||
@if (!action()) {
|
||||
<div class="logo tw-w-1/2">
|
||||
<div class="content">
|
||||
<div class="inner-content">
|
||||
<img class="logo-image" alt="Bitwarden" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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<SendV2Component>;
|
||||
let sendService: MockProxy<SendService>;
|
||||
let searchBarService: MockProxy<SearchBarService>;
|
||||
let broadcasterService: MockProxy<BroadcasterService>;
|
||||
let accountService: MockProxy<AccountService>;
|
||||
let policyService: MockProxy<PolicyService>;
|
||||
let sendListFiltersService: SendListFiltersService;
|
||||
let sendItemsService: MockProxy<SendItemsService>;
|
||||
let sendListFiltersService: MockProxy<SendListFiltersService>;
|
||||
let changeDetectorRef: MockProxy<ChangeDetectorRef>;
|
||||
|
||||
beforeEach(async () => {
|
||||
sendService = mock<SendService>();
|
||||
searchBarService = mock<SearchBarService>();
|
||||
broadcasterService = mock<BroadcasterService>();
|
||||
accountService = mock<AccountService>();
|
||||
policyService = mock<PolicyService>();
|
||||
changeDetectorRef = mock<ChangeDetectorRef>();
|
||||
|
||||
// Create real SendListFiltersService with mocked dependencies
|
||||
const formBuilder = new FormBuilder();
|
||||
const i18nService = mock<I18nService>();
|
||||
i18nService.t.mockImplementation((key: string) => key);
|
||||
sendListFiltersService = new SendListFiltersService(i18nService, formBuilder);
|
||||
// Mock SendItemsService with all required observables
|
||||
sendItemsService = mock<SendItemsService>();
|
||||
sendItemsService.filteredAndSortedSends$ = of([]);
|
||||
sendItemsService.loading$ = of(false);
|
||||
sendItemsService.emptyList$ = of(false);
|
||||
sendItemsService.noFilteredResults$ = of(false);
|
||||
sendItemsService.latestSearchText$ = of("");
|
||||
|
||||
// Mock SendListFiltersService
|
||||
sendListFiltersService = mock<SendListFiltersService>();
|
||||
|
||||
// Mock sendViews$ observable
|
||||
sendService.sendViews$ = of([]);
|
||||
searchBarService.searchText$ = new BehaviorSubject<string>("");
|
||||
|
||||
// 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<I18nService>() },
|
||||
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
|
||||
{ provide: EnvironmentService, useValue: mock<EnvironmentService>() },
|
||||
{ provide: BroadcasterService, useValue: broadcasterService },
|
||||
{ provide: SearchService, useValue: mockSearchService },
|
||||
{ provide: PolicyService, useValue: policyService },
|
||||
{ provide: SearchBarService, useValue: searchBarService },
|
||||
{ provide: LogService, useValue: mock<LogService>() },
|
||||
{ provide: SendApiService, useValue: mock<SendApiService>() },
|
||||
{ provide: DialogService, useValue: mock<DialogService>() },
|
||||
{ provide: ToastService, useValue: mock<ToastService>() },
|
||||
{ provide: AccountService, useValue: accountService },
|
||||
{ provide: SendItemsService, useValue: sendItemsService },
|
||||
{ provide: SendListFiltersService, useValue: sendListFiltersService },
|
||||
{ provide: ChangeDetectorRef, useValue: changeDetectorRef },
|
||||
{
|
||||
provide: BillingAccountProfileStateService,
|
||||
useValue: mock<BillingAccountProfileStateService>(),
|
||||
},
|
||||
{ provide: MessagingService, useValue: mock<MessagingService>() },
|
||||
{ provide: ConfigService, useValue: mock<ConfigService>() },
|
||||
],
|
||||
}).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<AddEditComponent>();
|
||||
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<AddEditComponent>();
|
||||
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<AddEditComponent>();
|
||||
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<AddEditComponent>();
|
||||
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<string>("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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
protected readonly action = signal<Action>(Action.None);
|
||||
private readonly selectedSendTypeOverride = signal<SendType | undefined>(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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<button bitButton [bitMenuTriggerFor]="itemOptions" [buttonType]="buttonType()" type="button">
|
||||
@if (!hideIcon()) {
|
||||
<i class="bwi bwi-plus tw-me-2" aria-hidden="true"></i>
|
||||
}
|
||||
{{ (hideIcon() ? "createSend" : "new") | i18n }}
|
||||
</button>
|
||||
<bit-menu #itemOptions>
|
||||
<button bitMenuItem type="button" (click)="onTextSendClick()">
|
||||
<i class="bwi bwi-file-text" slot="start" aria-hidden="true"></i>
|
||||
{{ "sendTypeText" | i18n }}
|
||||
</button>
|
||||
<button bitMenuItem type="button" (click)="onFileSendClick()">
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<i class="bwi bwi-file" slot="start" aria-hidden="true"></i>
|
||||
{{ "sendTypeFile" | i18n }}
|
||||
<app-premium-badge></app-premium-badge>
|
||||
</div>
|
||||
</button>
|
||||
</bit-menu>
|
||||
@@ -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<NewSendDropdownV2Component>;
|
||||
let billingService: MockProxy<BillingAccountProfileStateService>;
|
||||
let accountService: MockProxy<AccountService>;
|
||||
let premiumUpgradeService: MockProxy<PremiumUpgradePromptService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
billingService = mock<BillingAccountProfileStateService>();
|
||||
accountService = mock<AccountService>();
|
||||
premiumUpgradeService = mock<PremiumUpgradePromptService>();
|
||||
|
||||
// Default: user has premium
|
||||
accountService.activeAccount$ = of({ id: "user-123" } as any);
|
||||
billingService.hasPremiumFromAnySource$.mockReturnValue(of(true));
|
||||
|
||||
const i18nService = mock<I18nService>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<boolean>(false);
|
||||
readonly buttonType = input<ButtonType>("primary");
|
||||
|
||||
readonly addSend = output<SendType>();
|
||||
|
||||
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<void> {
|
||||
if (this.hasNoPremium()) {
|
||||
await this.premiumUpgradePromptService.promptForPremium();
|
||||
} else {
|
||||
this.addSend.emit(SendType.File);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
@if (loading()) {
|
||||
<bit-spinner />
|
||||
} @else {
|
||||
@if (showSearchBar()) {
|
||||
<!-- Search Bar - hidden when no Sends exist -->
|
||||
<tools-send-search></tools-send-search>
|
||||
}
|
||||
<tools-send-table
|
||||
[dataSource]="dataSource"
|
||||
[disableSend]="disableSend()"
|
||||
(editSend)="onEditSend($event)"
|
||||
(copySend)="onCopySend($event)"
|
||||
(removePassword)="onRemovePassword($event)"
|
||||
(deleteSend)="onDeleteSend($event)"
|
||||
/>
|
||||
@if (noSearchResults()) {
|
||||
<!-- No Sends from Search results -->
|
||||
<bit-no-items [icon]="noItemIcon">
|
||||
<ng-container slot="title">{{ "sendsTitleNoSearchResults" | i18n }}</ng-container>
|
||||
<ng-container slot="description">{{ "sendsBodyNoSearchResults" | i18n }}</ng-container>
|
||||
</bit-no-items>
|
||||
} @else if (listState() === sendListState.NoResults || listState() === sendListState.Empty) {
|
||||
<!-- No Sends from Filter results ( File/Text ) -->
|
||||
<!-- No Sends exist at all -->
|
||||
<bit-no-items [icon]="noItemIcon">
|
||||
<ng-container slot="title">{{ "sendsTitleNoItems" | i18n }}</ng-container>
|
||||
<ng-container slot="description">{{ "sendsBodyNoItems" | i18n }}</ng-container>
|
||||
<ng-content select="[slot='empty-button']" slot="button" />
|
||||
</bit-no-items>
|
||||
}
|
||||
}
|
||||
@@ -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<SendListComponent>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let sendItemsService: MockProxy<SendItemsService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
i18nService = mock<I18nService>();
|
||||
i18nService.t.mockImplementation((key) => key);
|
||||
|
||||
// Mock SendItemsService for SendSearchComponent child component
|
||||
sendItemsService = mock<SendItemsService>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
105
libs/tools/send/send-ui/src/send-list/send-list.component.ts
Normal file
105
libs/tools/send/send-ui/src/send-list/send-list.component.ts
Normal file
@@ -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<SendView[]>();
|
||||
readonly loading = input<boolean>(false);
|
||||
readonly disableSend = input<boolean>(false);
|
||||
readonly listState = input<SendListState | null>(null);
|
||||
readonly searchText = input<string>("");
|
||||
|
||||
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<SendView>();
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.dataSource.data = this.sends();
|
||||
});
|
||||
}
|
||||
|
||||
readonly editSend = output<SendView>();
|
||||
readonly copySend = output<SendView>();
|
||||
readonly removePassword = output<SendView>();
|
||||
readonly deleteSend = output<SendView>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user