1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-21 11:53:34 +00:00

[PM-19079] [PM-28168] add edit desktop migration (#18294)

The goal of this PR is to migrate the Desktop Send-v2 component from the AddEditComponent to now use to shared SendAddEditDialogComponent from @bitwarden/send-ui library

---------

Co-authored-by: William Martin <contact@willmartian.com>
Co-authored-by: Hinton <hinton@users.noreply.github.com>
This commit is contained in:
Isaac Ivins
2026-01-14 12:09:38 -05:00
committed by GitHub
parent 7fef972f1d
commit 968260c9f5
7 changed files with 246 additions and 256 deletions

View File

@@ -1,21 +1,19 @@
<div class="tw-my-4 tw-px-4">
<bit-header [title]="resolvedTitle()" [icon]="icon()">
<ng-container slot="breadcrumbs">
<ng-content select="[slot=breadcrumbs]" />
</ng-container>
<bit-header [title]="resolvedTitle()" [icon]="icon()">
<ng-container slot="breadcrumbs">
<ng-content select="[slot=breadcrumbs]" />
</ng-container>
<ng-content />
<ng-content />
<ng-container slot="title-suffix">
<ng-content select="[slot=title-suffix]" />
</ng-container>
<ng-container slot="title-suffix">
<ng-content select="[slot=title-suffix]" />
</ng-container>
<ng-container slot="secondary">
<ng-content select="[slot=secondary]" />
</ng-container>
<ng-container slot="secondary">
<ng-content select="[slot=secondary]" />
</ng-container>
<ng-container slot="tabs">
<ng-content select="[slot=tabs]" />
</ng-container>
</bit-header>
</div>
<ng-container slot="tabs">
<ng-content select="[slot=tabs]" />
</ng-container>
</bit-header>

View File

@@ -1,55 +1,25 @@
<div id="sends" class="vault">
<div class="send-items-panel tw-w-2/5">
<!-- Header with Send title and New button -->
<app-header>
@if (!disableSend()) {
<tools-new-send-dropdown-v2 buttonType="primary" (addSend)="addSend($event)" />
}
</app-header>
<div class="tw-my-4 tw-px-4">
<!-- Send List Component -->
<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)"
>
<tools-new-send-dropdown-v2
slot="empty-button"
[hideIcon]="true"
buttonType="primary"
(addSend)="addSend($event)"
/>
</tools-send-list>
</div>
</div>
<!-- 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>
<!-- Header with Send title and New button -->
<app-header>
@if (!disableSend()) {
<tools-new-send-dropdown-v2 buttonType="primary" (addSend)="addSend($event)" />
}
<!-- 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>
</app-header>
<!-- Send List Component -->
<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)"
>
<tools-new-send-dropdown-v2
slot="empty-button"
[hideIcon]="true"
buttonType="primary"
(addSend)="addSend($event)"
/>
</tools-send-list>

View File

@@ -20,11 +20,16 @@ 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 { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { SendItemsService, SendListFiltersService } from "@bitwarden/send-ui";
import { AddEditComponent } from "../send/add-edit.component";
import {
SendItemsService,
SendListFiltersService,
DefaultSendFormConfigService,
SendAddEditDialogComponent,
SendFormConfig,
} from "@bitwarden/send-ui";
import { SendV2Component } from "./send-v2.component";
@@ -37,12 +42,34 @@ describe("SendV2Component", () => {
let sendItemsService: MockProxy<SendItemsService>;
let sendListFiltersService: MockProxy<SendListFiltersService>;
let changeDetectorRef: MockProxy<ChangeDetectorRef>;
let sendFormConfigService: MockProxy<DefaultSendFormConfigService>;
let dialogService: MockProxy<DialogService>;
let environmentService: MockProxy<EnvironmentService>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let sendApiService: MockProxy<SendApiService>;
let toastService: MockProxy<ToastService>;
let i18nService: MockProxy<I18nService>;
beforeEach(async () => {
sendService = mock<SendService>();
accountService = mock<AccountService>();
policyService = mock<PolicyService>();
changeDetectorRef = mock<ChangeDetectorRef>();
sendFormConfigService = mock<DefaultSendFormConfigService>();
dialogService = mock<DialogService>();
environmentService = mock<EnvironmentService>();
platformUtilsService = mock<PlatformUtilsService>();
sendApiService = mock<SendApiService>();
toastService = mock<ToastService>();
i18nService = mock<I18nService>();
// Setup environmentService mock
environmentService.getEnvironment.mockResolvedValue({
getSendUrl: () => "https://send.bitwarden.com/#/",
} as any);
// Setup i18nService mock
i18nService.t.mockImplementation((key: string) => key);
// Mock SendItemsService with all required observables
sendItemsService = mock<SendItemsService>();
@@ -71,15 +98,16 @@ describe("SendV2Component", () => {
providers: [
provideNoopAnimations(),
{ provide: SendService, useValue: sendService },
{ provide: I18nService, useValue: mock<I18nService>() },
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
{ provide: EnvironmentService, useValue: mock<EnvironmentService>() },
{ provide: I18nService, useValue: i18nService },
{ provide: PlatformUtilsService, useValue: platformUtilsService },
{ provide: EnvironmentService, useValue: environmentService },
{ provide: SearchService, useValue: mockSearchService },
{ provide: PolicyService, useValue: policyService },
{ provide: LogService, useValue: mock<LogService>() },
{ provide: SendApiService, useValue: mock<SendApiService>() },
{ provide: DialogService, useValue: mock<DialogService>() },
{ provide: ToastService, useValue: mock<ToastService>() },
{ provide: SendApiService, useValue: sendApiService },
{ provide: DialogService, useValue: dialogService },
{ provide: DefaultSendFormConfigService, useValue: sendFormConfigService },
{ provide: ToastService, useValue: toastService },
{ provide: AccountService, useValue: accountService },
{ provide: SendItemsService, useValue: sendItemsService },
{ provide: SendListFiltersService, useValue: sendListFiltersService },
@@ -97,7 +125,16 @@ describe("SendV2Component", () => {
},
},
],
}).compileComponents();
})
.overrideComponent(SendV2Component, {
set: {
providers: [
{ provide: DefaultSendFormConfigService, useValue: sendFormConfigService },
{ provide: PremiumUpgradePromptService, useValue: mock<PremiumUpgradePromptService>() },
],
},
})
.compileComponents();
fixture = TestBed.createComponent(SendV2Component);
component = fixture.componentInstance;
@@ -107,103 +144,83 @@ describe("SendV2Component", () => {
expect(component).toBeTruthy();
});
it("initializes with correct default action", () => {
expect(component.action()).toBe("");
});
describe("addSend", () => {
it("sets action to Add", async () => {
await component.addSend(SendType.Text);
expect(component.action()).toBe("add");
beforeEach(() => {
jest.clearAllMocks();
});
it("calls resetAndLoad on addEditComponent when component exists", async () => {
const mockAddEdit = mock<AddEditComponent>();
mockAddEdit.resetAndLoad.mockResolvedValue();
jest.spyOn(component as any, "addEditComponent").mockReturnValue(mockAddEdit);
it("opens dialog with correct config for Text send", async () => {
const mockConfig = { mode: "add", sendType: SendType.Text } as SendFormConfig;
const mockDialogRef = { closed: of(true) };
await component.addSend(SendType.Text);
sendFormConfigService.buildConfig.mockResolvedValue(mockConfig);
const openDrawerSpy = jest
.spyOn(SendAddEditDialogComponent, "openDrawer")
.mockReturnValue(mockDialogRef as any);
expect(mockAddEdit.resetAndLoad).toHaveBeenCalled();
await component["addSend"](SendType.Text);
expect(sendFormConfigService.buildConfig).toHaveBeenCalledWith(
"add",
undefined,
SendType.Text,
);
expect(openDrawerSpy).toHaveBeenCalled();
expect(openDrawerSpy.mock.calls[0][1]).toEqual({
formConfig: mockConfig,
});
});
it("does not throw when addEditComponent is null", async () => {
jest.spyOn(component as any, "addEditComponent").mockReturnValue(undefined);
await expect(component.addSend(SendType.Text)).resolves.not.toThrow();
});
});
it("opens dialog with correct config for File send", async () => {
const mockConfig = { mode: "add", sendType: SendType.File } as SendFormConfig;
const mockDialogRef = { closed: of(true) };
describe("closeEditPanel", () => {
it("resets action to None", () => {
component["action"].set("edit");
component["sendId"].set("test-id");
sendFormConfigService.buildConfig.mockResolvedValue(mockConfig);
const openDrawerSpy = jest
.spyOn(SendAddEditDialogComponent, "openDrawer")
.mockReturnValue(mockDialogRef as any);
component["closeEditPanel"]();
await component["addSend"](SendType.File);
expect(component["action"]()).toBe("");
expect(component["sendId"]()).toBeNull();
});
});
describe("savedSend", () => {
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);
expect(component["selectSend"]).toHaveBeenCalledWith("saved-send-id");
expect(sendFormConfigService.buildConfig).toHaveBeenCalledWith(
"add",
undefined,
SendType.File,
);
expect(openDrawerSpy).toHaveBeenCalled();
expect(openDrawerSpy.mock.calls[0][1]).toEqual({
formConfig: mockConfig,
});
});
});
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");
beforeEach(() => {
jest.clearAllMocks();
});
it("updates addEditComponent when it exists", async () => {
const mockAddEdit = mock<AddEditComponent>();
mockAddEdit.refresh.mockResolvedValue();
jest.spyOn(component as any, "addEditComponent").mockReturnValue(mockAddEdit);
it("opens dialog with correct config for editing send", async () => {
const mockConfig = { mode: "edit", sendId: "test-send-id" } as SendFormConfig;
const mockDialogRef = { closed: of(true) };
sendFormConfigService.buildConfig.mockResolvedValue(mockConfig);
const openDrawerSpy = jest
.spyOn(SendAddEditDialogComponent, "openDrawer")
.mockReturnValue(mockDialogRef as any);
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>();
jest.spyOn(component as any, "addEditComponent").mockReturnValue(mockAddEdit);
component["sendId"].set("same-id");
component["action"].set("edit");
await component["selectSend"]("same-id");
expect(mockAddEdit.refresh).not.toHaveBeenCalled();
});
it("reloads if selecting different send", async () => {
const mockAddEdit = mock<AddEditComponent>();
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");
expect(mockAddEdit.refresh).toHaveBeenCalled();
expect(sendFormConfigService.buildConfig).toHaveBeenCalledWith("edit", "test-send-id");
expect(openDrawerSpy).toHaveBeenCalled();
expect(openDrawerSpy.mock.calls[0][1]).toEqual({
formConfig: mockConfig,
});
});
});
describe("onEditSend", () => {
it("selects the send for editing", async () => {
jest.spyOn(component as any, "selectSend").mockResolvedValue();
jest.spyOn(component as any, "selectSend").mockResolvedValue(undefined);
const mockSend = new SendView();
mockSend.id = "edit-send-id";
@@ -212,4 +229,25 @@ describe("SendV2Component", () => {
expect(component["selectSend"]).toHaveBeenCalledWith("edit-send-id");
});
});
describe("onCopySend", () => {
it("copies send link to clipboard and shows success toast", async () => {
const mockSend = {
accessId: "test-access-id",
urlB64Key: "test-key",
} as SendView;
await component["onCopySend"](mockSend);
expect(environmentService.getEnvironment).toHaveBeenCalled();
expect(platformUtilsService.copyToClipboard).toHaveBeenCalledWith(
"https://send.bitwarden.com/#/test-access-id/test-key",
);
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "success",
title: null,
message: expect.any(String),
});
});
});
});

View File

@@ -4,14 +4,11 @@ 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 { combineLatest, map, switchMap, lastValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -25,6 +22,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { SendId } from "@bitwarden/common/types/guid";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { ButtonModule, DialogService, ToastService } from "@bitwarden/components";
import {
@@ -32,34 +30,24 @@ import {
SendItemsService,
SendListComponent,
SendListState,
SendAddEditDialogComponent,
DefaultSendFormConfigService,
} from "@bitwarden/send-ui";
import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-premium-upgrade-prompt.service";
import { DesktopHeaderComponent } from "../../layout/header";
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];
@Component({
selector: "app-send-v2",
imports: [
JslibModule,
ButtonModule,
AddEditComponent,
SendListComponent,
NewSendDropdownV2Component,
DesktopHeaderComponent,
],
providers: [
DefaultSendFormConfigService,
{
provide: PremiumUpgradePromptService,
useClass: DesktopPremiumUpgradePromptService,
@@ -69,22 +57,17 @@ type Action = (typeof Action)[keyof typeof Action];
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SendV2Component {
protected readonly addEditComponent = viewChild(AddEditComponent);
protected readonly sendId = signal<string | null>(null);
protected readonly action = signal<Action>(Action.None);
private readonly selectedSendTypeOverride = signal<SendType | undefined>(undefined);
private sendFormConfigService = inject(DefaultSendFormConfigService);
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 logService = inject(LogService);
private cdr = inject(ChangeDetectorRef);
protected readonly filteredSends = toSignal(this.sendItemsService.filteredAndSortedSends$, {
@@ -137,53 +120,27 @@ export class SendV2Component {
}
protected async addSend(type: SendType): Promise<void> {
this.action.set(Action.Add);
this.sendId.set(null);
this.selectedSendTypeOverride.set(type);
const formConfig = await this.sendFormConfigService.buildConfig("add", undefined, type);
const component = this.addEditComponent();
if (component) {
await component.resetAndLoad();
}
const dialogRef = SendAddEditDialogComponent.openDrawer(this.dialogService, {
formConfig,
});
await lastValueFrom(dialogRef.closed);
}
protected closeEditPanel(): void {
this.action.set(Action.None);
this.sendId.set(null);
this.selectedSendTypeOverride.set(undefined);
protected async selectSend(sendId: SendId): Promise<void> {
const formConfig = await this.sendFormConfigService.buildConfig("edit", sendId);
const dialogRef = SendAddEditDialogComponent.openDrawer(this.dialogService, {
formConfig,
});
await lastValueFrom(dialogRef.closed);
}
protected async savedSend(send: SendView): Promise<void> {
await this.selectSend(send.id);
}
protected async selectSend(sendId: string): Promise<void> {
if (sendId === this.sendId() && this.action() === Action.Edit) {
return;
}
this.action.set(Action.Edit);
this.sendId.set(sendId);
const component = this.addEditComponent();
if (component) {
component.sendId = sendId;
await component.refresh();
}
}
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);
await this.selectSend(send.id as SendId);
}
protected async onCopySend(send: SendView): Promise<void> {
@@ -219,11 +176,6 @@ export class SendV2Component {
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);
}
@@ -247,7 +199,5 @@ export class SendV2Component {
title: null,
message: this.i18nService.t("deletedSend"),
});
this.closeEditPanel();
}
}

View File

@@ -100,6 +100,70 @@
}
}
},
"deletionDateDescV2": {
"message": "The Send will be permanently deleted on this date.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"fileToShare": {
"message": "File to share"
},
"hideTextByDefault": {
"message": "Hide text by default"
},
"hideYourEmail": {
"message": "Hide your email address from viewers."
},
"limitSendViews": {
"message": "Limit views"
},
"limitSendViewsCount": {
"message": "$ACCESSCOUNT$ views left",
"description": "Displayed under the limit views field on Send",
"placeholders": {
"accessCount": {
"content": "$1",
"example": "2"
}
}
},
"limitSendViewsHint": {
"message": "No one can view this Send after the limit is reached.",
"description": "Displayed under the limit views field on Send"
},
"privateNote": {
"message": "Private note"
},
"sendDetails": {
"message": "Send details",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"sendPasswordDescV3": {
"message": "Add an optional password for recipients to access this Send.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"sendTypeTextToShare": {
"message": "Text to share"
},
"newItemHeaderTextSend": {
"message": "New Text Send",
"description": "Header for new text send"
},
"newItemHeaderFileSend": {
"message": "New File Send",
"description": "Header for new file send"
},
"editItemHeaderTextSend": {
"message": "Edit Text Send",
"description": "Header for edit text send"
},
"editItemHeaderFileSend": {
"message": "Edit File Send",
"description": "Header for edit file send"
},
"deleteSendPermanentConfirmation": {
"message": "Are you sure you want to permanently delete this Send?",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"new": {
"message": "New",
"description": "for adding new items"

View File

@@ -1,29 +0,0 @@
/**
* Desktop UI Migration
*
* These are temporary styles during the desktop ui migration.
**/
/**
* This removes any padding applied by the bit-layout to content.
* This should be revisited once the table is migrated, and again once drawers are migrated.
**/
bit-layout {
#main-content {
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");
}
}

View File

@@ -15,6 +15,5 @@
@import "left-nav.scss";
@import "loading.scss";
@import "plugins.scss";
@import "migration.scss";
@import "../../../../libs/angular/src/scss/icons.scss";
@import "../../../../libs/components/src/multi-select/scss/bw.theme";