mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
[PM-27794] create send component desktop migration (#17786)
* wip * updated tests to work, and linter
This commit is contained in:
110
apps/desktop/src/app/tools/send-v2/send-v2.component.html
Normal file
110
apps/desktop/src/app/tools/send-v2/send-v2.component.html
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<div id="sends" class="vault">
|
||||||
|
<div id="items" class="items">
|
||||||
|
<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>
|
||||||
|
</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 }}"
|
||||||
|
>
|
||||||
|
<i class="bwi bwi-plus bwi-lg" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</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" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,22 +1,364 @@
|
|||||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||||
|
import { mock, MockProxy } from "jest-mock-extended";
|
||||||
|
import { BehaviorSubject, of } from "rxjs";
|
||||||
|
|
||||||
|
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||||
|
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
|
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
||||||
|
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||||
|
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||||
|
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||||
|
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
||||||
|
import { DialogService, ToastService } from "@bitwarden/components";
|
||||||
|
|
||||||
|
import * as utils from "../../../utils";
|
||||||
|
import { SearchBarService } from "../../layout/search/search-bar.service";
|
||||||
|
import { AddEditComponent } from "../send/add-edit.component";
|
||||||
|
|
||||||
import { SendV2Component } from "./send-v2.component";
|
import { SendV2Component } from "./send-v2.component";
|
||||||
|
|
||||||
|
// Mock the invokeMenu utility function
|
||||||
|
jest.mock("../../../utils", () => ({
|
||||||
|
invokeMenu: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("SendV2Component", () => {
|
describe("SendV2Component", () => {
|
||||||
let component: SendV2Component;
|
let component: SendV2Component;
|
||||||
let fixture: ComponentFixture<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>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
sendService = mock<SendService>();
|
||||||
|
searchBarService = mock<SearchBarService>();
|
||||||
|
broadcasterService = mock<BroadcasterService>();
|
||||||
|
accountService = mock<AccountService>();
|
||||||
|
policyService = mock<PolicyService>();
|
||||||
|
|
||||||
|
// Mock sendViews$ observable
|
||||||
|
sendService.sendViews$ = of([]);
|
||||||
|
searchBarService.searchText$ = new BehaviorSubject<string>("");
|
||||||
|
|
||||||
|
// Mock activeAccount$ observable for parent class ngOnInit
|
||||||
|
accountService.activeAccount$ = of({ id: "test-user-id" } as any);
|
||||||
|
policyService.policyAppliesToUser$ = jest.fn().mockReturnValue(of(false));
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [SendV2Component],
|
imports: [SendV2Component],
|
||||||
|
providers: [
|
||||||
|
{ 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: mock<SearchService>() },
|
||||||
|
{ 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 },
|
||||||
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
fixture = TestBed.createComponent(SendV2Component);
|
fixture = TestBed.createComponent(SendV2Component);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates component", () => {
|
it("creates component", () => {
|
||||||
expect(component).toBeTruthy();
|
expect(component).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("initializes with correct default action", () => {
|
||||||
|
expect(component.action).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subscribes to broadcaster service on init", async () => {
|
||||||
|
await component.ngOnInit();
|
||||||
|
expect(broadcasterService.subscribe).toHaveBeenCalledWith(
|
||||||
|
"SendV2Component",
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unsubscribes from broadcaster service on destroy", () => {
|
||||||
|
component.ngOnDestroy();
|
||||||
|
expect(broadcasterService.unsubscribe).toHaveBeenCalledWith("SendV2Component");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables search bar on init", async () => {
|
||||||
|
await component.ngOnInit();
|
||||||
|
expect(searchBarService.setEnabled).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables search bar on destroy", () => {
|
||||||
|
component.ngOnDestroy();
|
||||||
|
expect(searchBarService.setEnabled).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addSend", () => {
|
||||||
|
it("sets action to Add", async () => {
|
||||||
|
await component.addSend();
|
||||||
|
expect(component.action).toBe("add");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls resetAndLoad on addEditComponent when component exists", async () => {
|
||||||
|
const mockAddEdit = mock<AddEditComponent>();
|
||||||
|
component.addEditComponent = mockAddEdit;
|
||||||
|
|
||||||
|
await component.addSend();
|
||||||
|
|
||||||
|
expect(mockAddEdit.resetAndLoad).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw when addEditComponent is null", async () => {
|
||||||
|
component.addEditComponent = null;
|
||||||
|
await expect(component.addSend()).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cancel", () => {
|
||||||
|
it("resets action to None", () => {
|
||||||
|
component.action = "edit";
|
||||||
|
component.sendId = "test-id";
|
||||||
|
|
||||||
|
component.cancel(new SendView());
|
||||||
|
|
||||||
|
expect(component.action).toBe("");
|
||||||
|
expect(component.sendId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deletedSend", () => {
|
||||||
|
it("refreshes the list and resets action and sendId", async () => {
|
||||||
|
component.action = "edit";
|
||||||
|
component.sendId = "test-id";
|
||||||
|
jest.spyOn(component, "refresh").mockResolvedValue();
|
||||||
|
|
||||||
|
const mockSend = new SendView();
|
||||||
|
await component.deletedSend(mockSend);
|
||||||
|
|
||||||
|
expect(component.refresh).toHaveBeenCalled();
|
||||||
|
expect(component.action).toBe("");
|
||||||
|
expect(component.sendId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("savedSend", () => {
|
||||||
|
it("refreshes the list and selects the saved send", async () => {
|
||||||
|
jest.spyOn(component, "refresh").mockResolvedValue();
|
||||||
|
jest.spyOn(component, "selectSend").mockResolvedValue();
|
||||||
|
|
||||||
|
const mockSend = new SendView();
|
||||||
|
mockSend.id = "saved-send-id";
|
||||||
|
|
||||||
|
await component.savedSend(mockSend);
|
||||||
|
|
||||||
|
expect(component.refresh).toHaveBeenCalled();
|
||||||
|
expect(component.selectSend).toHaveBeenCalledWith("saved-send-id");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("selectSend", () => {
|
||||||
|
it("sets action to Edit and updates sendId", async () => {
|
||||||
|
await component.selectSend("new-send-id");
|
||||||
|
|
||||||
|
expect(component.action).toBe("edit");
|
||||||
|
expect(component.sendId).toBe("new-send-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates addEditComponent when it exists", async () => {
|
||||||
|
const mockAddEdit = mock<AddEditComponent>();
|
||||||
|
component.addEditComponent = mockAddEdit;
|
||||||
|
|
||||||
|
await component.selectSend("test-send-id");
|
||||||
|
|
||||||
|
expect(mockAddEdit.sendId).toBe("test-send-id");
|
||||||
|
expect(mockAddEdit.refresh).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reload if same send is already selected in edit mode", async () => {
|
||||||
|
const mockAddEdit = mock<AddEditComponent>();
|
||||||
|
component.addEditComponent = mockAddEdit;
|
||||||
|
component.sendId = "same-id";
|
||||||
|
component.action = "edit";
|
||||||
|
|
||||||
|
await component.selectSend("same-id");
|
||||||
|
|
||||||
|
expect(mockAddEdit.refresh).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reloads if selecting different send", async () => {
|
||||||
|
const mockAddEdit = mock<AddEditComponent>();
|
||||||
|
component.addEditComponent = mockAddEdit;
|
||||||
|
component.sendId = "old-id";
|
||||||
|
component.action = "edit";
|
||||||
|
|
||||||
|
await component.selectSend("new-id");
|
||||||
|
|
||||||
|
expect(mockAddEdit.refresh).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("selectedSendType", () => {
|
||||||
|
it("returns the type of the currently selected send", () => {
|
||||||
|
const mockSend1 = new SendView();
|
||||||
|
mockSend1.id = "send-1";
|
||||||
|
mockSend1.type = SendType.Text;
|
||||||
|
|
||||||
|
const mockSend2 = new SendView();
|
||||||
|
mockSend2.id = "send-2";
|
||||||
|
mockSend2.type = SendType.File;
|
||||||
|
|
||||||
|
component.sends = [mockSend1, mockSend2];
|
||||||
|
component.sendId = "send-2";
|
||||||
|
|
||||||
|
expect(component.selectedSendType).toBe(SendType.File);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when no send is selected", () => {
|
||||||
|
component.sends = [];
|
||||||
|
component.sendId = "non-existent";
|
||||||
|
|
||||||
|
expect(component.selectedSendType).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when sendId is null", () => {
|
||||||
|
const mockSend = new SendView();
|
||||||
|
mockSend.id = "send-1";
|
||||||
|
mockSend.type = SendType.Text;
|
||||||
|
|
||||||
|
component.sends = [mockSend];
|
||||||
|
component.sendId = null;
|
||||||
|
|
||||||
|
expect(component.selectedSendType).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("viewSendMenu", () => {
|
||||||
|
let mockSend: SendView;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockSend = new SendView();
|
||||||
|
mockSend.id = "test-send";
|
||||||
|
mockSend.name = "Test Send";
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates menu with copy link option", () => {
|
||||||
|
jest.spyOn(component, "copy").mockResolvedValue();
|
||||||
|
|
||||||
|
component.viewSendMenu(mockSend);
|
||||||
|
|
||||||
|
expect(utils.invokeMenu).toHaveBeenCalled();
|
||||||
|
const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0];
|
||||||
|
expect(menuItems.length).toBeGreaterThanOrEqual(2); // At minimum: copy link + delete
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes remove password option when send has password and is not disabled", () => {
|
||||||
|
mockSend.password = "test-password";
|
||||||
|
mockSend.disabled = false;
|
||||||
|
jest.spyOn(component, "removePassword").mockResolvedValue(true);
|
||||||
|
|
||||||
|
component.viewSendMenu(mockSend);
|
||||||
|
|
||||||
|
expect(utils.invokeMenu).toHaveBeenCalled();
|
||||||
|
const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0];
|
||||||
|
expect(menuItems.length).toBe(3); // copy link + remove password + delete
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes remove password option when send has no password", () => {
|
||||||
|
mockSend.password = null;
|
||||||
|
mockSend.disabled = false;
|
||||||
|
|
||||||
|
component.viewSendMenu(mockSend);
|
||||||
|
|
||||||
|
expect(utils.invokeMenu).toHaveBeenCalled();
|
||||||
|
const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0];
|
||||||
|
expect(menuItems.length).toBe(2); // copy link + delete (no remove password)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes remove password option when send is disabled", () => {
|
||||||
|
mockSend.password = "test-password";
|
||||||
|
mockSend.disabled = true;
|
||||||
|
|
||||||
|
component.viewSendMenu(mockSend);
|
||||||
|
|
||||||
|
expect(utils.invokeMenu).toHaveBeenCalled();
|
||||||
|
const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0];
|
||||||
|
expect(menuItems.length).toBe(2); // copy link + delete (no remove password)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always includes delete option", () => {
|
||||||
|
jest.spyOn(component, "delete").mockResolvedValue(true);
|
||||||
|
jest.spyOn(component, "deletedSend").mockResolvedValue();
|
||||||
|
|
||||||
|
component.viewSendMenu(mockSend);
|
||||||
|
|
||||||
|
expect(utils.invokeMenu).toHaveBeenCalled();
|
||||||
|
const menuItems = (utils.invokeMenu as jest.Mock).mock.calls[0][0];
|
||||||
|
// Delete is always the last item in the menu
|
||||||
|
expect(menuItems.length).toBeGreaterThan(0);
|
||||||
|
expect(menuItems[menuItems.length - 1]).toHaveProperty("label");
|
||||||
|
expect(menuItems[menuItems.length - 1]).toHaveProperty("click");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("search bar subscription", () => {
|
||||||
|
it("updates searchText when search bar text changes", () => {
|
||||||
|
const searchSubject = new BehaviorSubject<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();
|
||||||
|
jest.spyOn(component, "selectAll");
|
||||||
|
|
||||||
|
expect(component.loaded).toBeFalsy();
|
||||||
|
|
||||||
|
await component.load();
|
||||||
|
|
||||||
|
expect(component.loading).toBe(false);
|
||||||
|
expect(component.loaded).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls selectAll when onSuccessfulLoad is not set", async () => {
|
||||||
|
jest.spyOn(component, "search").mockResolvedValue();
|
||||||
|
jest.spyOn(component, "selectAll");
|
||||||
|
component.onSuccessfulLoad = null;
|
||||||
|
|
||||||
|
await component.load();
|
||||||
|
|
||||||
|
expect(component.selectAll).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onSuccessfulLoad when it is set", async () => {
|
||||||
|
jest.spyOn(component, "search").mockResolvedValue();
|
||||||
|
const mockCallback = jest.fn().mockResolvedValue(undefined);
|
||||||
|
component.onSuccessfulLoad = mockCallback;
|
||||||
|
|
||||||
|
await component.load();
|
||||||
|
|
||||||
|
expect(mockCallback).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,233 @@
|
|||||||
import { Component, ChangeDetectionStrategy } from "@angular/core";
|
// FIXME: Update this file to be type safe and remove this and next line
|
||||||
|
// @ts-strict-ignore
|
||||||
|
import { CommonModule } from "@angular/common";
|
||||||
|
import { Component, OnInit, OnDestroy, ViewChild, NgZone, ChangeDetectorRef } from "@angular/core";
|
||||||
|
import { FormsModule } from "@angular/forms";
|
||||||
|
import { mergeMap } from "rxjs";
|
||||||
|
|
||||||
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
|
import { SendComponent as BaseSendComponent } from "@bitwarden/angular/tools/send/send.component";
|
||||||
|
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||||
|
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
|
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||||
|
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||||
|
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||||
|
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
||||||
|
import { DialogService, ToastService } from "@bitwarden/components";
|
||||||
|
|
||||||
|
import { invokeMenu, RendererMenuItem } from "../../../utils";
|
||||||
|
import { SearchBarService } from "../../layout/search/search-bar.service";
|
||||||
|
import { AddEditComponent } from "../send/add-edit.component";
|
||||||
|
|
||||||
|
const Action = Object.freeze({
|
||||||
|
/** No action is currently active. */
|
||||||
|
None: "",
|
||||||
|
/** The user is adding a new Send. */
|
||||||
|
Add: "add",
|
||||||
|
/** The user is editing an existing Send. */
|
||||||
|
Edit: "edit",
|
||||||
|
} as const);
|
||||||
|
|
||||||
|
type Action = (typeof Action)[keyof typeof Action];
|
||||||
|
|
||||||
|
const BroadcasterSubscriptionId = "SendV2Component";
|
||||||
|
|
||||||
|
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||||
|
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-send-v2",
|
selector: "app-send-v2",
|
||||||
imports: [],
|
imports: [CommonModule, JslibModule, FormsModule, AddEditComponent],
|
||||||
template: "<p>Sends V2 Component</p>",
|
templateUrl: "./send-v2.component.html",
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
})
|
})
|
||||||
export class SendV2Component {}
|
export class SendV2Component extends BaseSendComponent implements OnInit, OnDestroy {
|
||||||
|
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||||
|
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||||
|
@ViewChild(AddEditComponent) addEditComponent: AddEditComponent;
|
||||||
|
|
||||||
|
// The ID of the currently selected Send item being viewed or edited
|
||||||
|
sendId: string;
|
||||||
|
|
||||||
|
// Tracks the current UI state: viewing list (None), adding new Send (Add), or editing existing Send (Edit)
|
||||||
|
action: Action = Action.None;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
sendService: SendService,
|
||||||
|
i18nService: I18nService,
|
||||||
|
platformUtilsService: PlatformUtilsService,
|
||||||
|
environmentService: EnvironmentService,
|
||||||
|
private broadcasterService: BroadcasterService,
|
||||||
|
ngZone: NgZone,
|
||||||
|
searchService: SearchService,
|
||||||
|
policyService: PolicyService,
|
||||||
|
private searchBarService: SearchBarService,
|
||||||
|
logService: LogService,
|
||||||
|
sendApiService: SendApiService,
|
||||||
|
dialogService: DialogService,
|
||||||
|
toastService: ToastService,
|
||||||
|
accountService: AccountService,
|
||||||
|
private cdr: ChangeDetectorRef,
|
||||||
|
) {
|
||||||
|
super(
|
||||||
|
sendService,
|
||||||
|
i18nService,
|
||||||
|
platformUtilsService,
|
||||||
|
environmentService,
|
||||||
|
ngZone,
|
||||||
|
searchService,
|
||||||
|
policyService,
|
||||||
|
logService,
|
||||||
|
sendApiService,
|
||||||
|
dialogService,
|
||||||
|
toastService,
|
||||||
|
accountService,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Listen to search bar changes and update the Send list filter
|
||||||
|
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||||
|
this.searchBarService.searchText$.subscribe((searchText) => {
|
||||||
|
this.searchText = searchText;
|
||||||
|
this.searchTextChanged();
|
||||||
|
setTimeout(() => this.cdr.detectChanges(), 250);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the component: enable search bar, subscribe to sync events, and load Send items
|
||||||
|
async ngOnInit() {
|
||||||
|
this.searchBarService.setEnabled(true);
|
||||||
|
this.searchBarService.setPlaceholderText(this.i18nService.t("searchSends"));
|
||||||
|
|
||||||
|
await super.ngOnInit();
|
||||||
|
|
||||||
|
// Listen for sync completion events to refresh the Send list
|
||||||
|
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
|
||||||
|
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
|
this.ngZone.run(async () => {
|
||||||
|
switch (message.command) {
|
||||||
|
case "syncCompleted":
|
||||||
|
await this.load();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up subscriptions and disable search bar when component is destroyed
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
|
||||||
|
this.searchBarService.setEnabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load Send items from the service and display them in the list.
|
||||||
|
// Subscribes to sendViews$ observable to get updates when Sends change.
|
||||||
|
// Manually triggers change detection to ensure UI updates immediately.
|
||||||
|
// Note: The filter parameter is ignored in this implementation for desktop-specific behavior.
|
||||||
|
async load(filter: (send: SendView) => boolean = null) {
|
||||||
|
this.loading = true;
|
||||||
|
this.sendService.sendViews$
|
||||||
|
.pipe(
|
||||||
|
mergeMap(async (sends) => {
|
||||||
|
this.sends = sends;
|
||||||
|
await this.search(null);
|
||||||
|
// Trigger change detection after data updates
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||||
|
.subscribe();
|
||||||
|
if (this.onSuccessfulLoad != null) {
|
||||||
|
await this.onSuccessfulLoad();
|
||||||
|
} else {
|
||||||
|
// Default action
|
||||||
|
this.selectAll();
|
||||||
|
}
|
||||||
|
this.loading = false;
|
||||||
|
this.loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the add Send form to create a new Send item
|
||||||
|
async addSend() {
|
||||||
|
this.action = Action.Add;
|
||||||
|
if (this.addEditComponent != null) {
|
||||||
|
await this.addEditComponent.resetAndLoad();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the add/edit form and return to the list view
|
||||||
|
cancel(s: SendView) {
|
||||||
|
this.action = Action.None;
|
||||||
|
this.sendId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle when a Send is deleted: refresh the list and close the edit form
|
||||||
|
async deletedSend(s: SendView) {
|
||||||
|
await this.refresh();
|
||||||
|
this.action = Action.None;
|
||||||
|
this.sendId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle when a Send is saved: refresh the list and re-select the saved Send
|
||||||
|
async savedSend(s: SendView) {
|
||||||
|
await this.refresh();
|
||||||
|
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
|
this.selectSend(s.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select a Send from the list and open it in the edit form.
|
||||||
|
// If the same Send is already selected and in edit mode, do nothing to avoid unnecessary reloads.
|
||||||
|
async selectSend(sendId: string) {
|
||||||
|
if (sendId === this.sendId && this.action === Action.Edit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.action = Action.Edit;
|
||||||
|
this.sendId = sendId;
|
||||||
|
if (this.addEditComponent != null) {
|
||||||
|
this.addEditComponent.sendId = sendId;
|
||||||
|
await this.addEditComponent.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the type (text or file) of the currently selected Send for the edit form
|
||||||
|
get selectedSendType() {
|
||||||
|
return this.sends.find((s) => s.id === this.sendId)?.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the right-click context menu for a Send with options to copy link, remove password, or delete
|
||||||
|
viewSendMenu(send: SendView) {
|
||||||
|
const menu: RendererMenuItem[] = [];
|
||||||
|
menu.push({
|
||||||
|
label: this.i18nService.t("copyLink"),
|
||||||
|
click: () => this.copy(send),
|
||||||
|
});
|
||||||
|
if (send.password && !send.disabled) {
|
||||||
|
menu.push({
|
||||||
|
label: this.i18nService.t("removePassword"),
|
||||||
|
click: async () => {
|
||||||
|
await this.removePassword(send);
|
||||||
|
if (this.sendId === send.id) {
|
||||||
|
this.sendId = null;
|
||||||
|
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
|
this.selectSend(send.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
menu.push({
|
||||||
|
label: this.i18nService.t("delete"),
|
||||||
|
click: async () => {
|
||||||
|
await this.delete(send);
|
||||||
|
await this.deletedSend(send);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
invokeMenu(menu);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user