1
0
mirror of https://github.com/bitwarden/browser synced 2026-03-01 11:01:17 +00:00

Merge remote-tracking branch 'origin/main' into playwright

This commit is contained in:
Matt Gibson
2026-01-26 12:57:05 -08:00
1790 changed files with 150488 additions and 32025 deletions

View File

@@ -6,9 +6,9 @@ import { FormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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 { SendType } from "@bitwarden/common/tools/send/types/send-type";
import {
DIALOG_DATA,
DialogRef,
@@ -44,8 +44,10 @@ export const SendItemDialogResult = Object.freeze({
} as const);
/** A result of the Send add/edit dialog. */
export type SendItemDialogResult = (typeof SendItemDialogResult)[keyof typeof SendItemDialogResult];
export type SendItemDialogResult = {
result: (typeof SendItemDialogResult)[keyof typeof SendItemDialogResult];
send?: SendView;
};
/**
* Component for adding or editing a send item.
*/
@@ -93,7 +95,7 @@ export class SendAddEditDialogComponent {
*/
async onSendCreated(send: SendView) {
// FIXME Add dialogService.open send-created dialog
this.dialogRef.close(SendItemDialogResult.Saved);
this.dialogRef.close({ result: SendItemDialogResult.Saved, send });
return;
}
@@ -101,14 +103,14 @@ export class SendAddEditDialogComponent {
* Handles the event when the send is updated.
*/
async onSendUpdated(send: SendView) {
this.dialogRef.close(SendItemDialogResult.Saved);
this.dialogRef.close({ result: SendItemDialogResult.Saved });
}
/**
* Handles the event when the send is deleted.
*/
async onSendDeleted() {
this.dialogRef.close(SendItemDialogResult.Deleted);
this.dialogRef.close({ result: SendItemDialogResult.Deleted });
this.toastService.showToast({
variant: "success",
@@ -174,4 +176,19 @@ export class SendAddEditDialogComponent {
},
);
}
/**
* Opens the send add/edit dialog in a drawer
* @param dialogService Instance of the DialogService.
* @param params The parameters for the drawer.
* @returns The drawer result.
*/
static openDrawer(dialogService: DialogService, params: SendItemDialogParams) {
return dialogService.openDrawer<SendItemDialogResult, SendItemDialogParams>(
SendAddEditDialogComponent,
{
data: params,
},
);
}
}

View File

@@ -1,8 +1,11 @@
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";
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";

View File

@@ -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>

View File

@@ -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/types/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();
});
});
});

View File

@@ -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/types/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);
}
}
}

View File

@@ -1,5 +1,5 @@
<button bitButton [bitMenuTriggerFor]="itemOptions" [buttonType]="buttonType" type="button">
<i *ngIf="!hideIcon" class="bwi bwi-plus" aria-hidden="true"></i>
<i *ngIf="!hideIcon" class="bwi bwi-plus tw-me-2" aria-hidden="true"></i>
{{ (hideIcon ? "createSend" : "new") | i18n }}
</button>
<bit-menu #itemOptions>

View File

@@ -7,7 +7,7 @@ import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/pre
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 { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { ButtonModule, ButtonType, MenuModule } from "@bitwarden/components";

View File

@@ -1,5 +1,5 @@
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { Send } from "@bitwarden/common/tools/send/models/domain/send";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { SendId } from "@bitwarden/common/types/guid";
/**

View File

@@ -0,0 +1,67 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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 { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { DialogService, ToastService } from "@bitwarden/components";
import { CredentialGeneratorService } from "@bitwarden/generator-core";
import { SendFormContainer } from "../../send-form-container";
import { SendOptionsComponent } from "./send-options.component";
describe("SendOptionsComponent", () => {
let component: SendOptionsComponent;
let fixture: ComponentFixture<SendOptionsComponent>;
const mockSendFormContainer = mock<SendFormContainer>();
const mockAccountService = mock<AccountService>();
beforeAll(() => {
mockAccountService.activeAccount$ = of({ id: "myTestAccount" } as Account);
});
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SendOptionsComponent],
declarations: [],
providers: [
{ provide: SendFormContainer, useValue: mockSendFormContainer },
{ provide: DialogService, useValue: mock<DialogService>() },
{ provide: SendApiService, useValue: mock<SendApiService>() },
{ provide: PolicyService, useValue: mock<PolicyService>() },
{ provide: I18nService, useValue: mock<I18nService>() },
{ provide: ToastService, useValue: mock<ToastService>() },
{ provide: CredentialGeneratorService, useValue: mock<CredentialGeneratorService>() },
{ provide: AccountService, useValue: mockAccountService },
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
],
}).compileComponents();
fixture = TestBed.createComponent(SendOptionsComponent);
component = fixture.componentInstance;
component.config = { areSendsAllowed: true, mode: "add", sendType: SendType.Text };
fixture.detectChanges();
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should create", () => {
expect(component).toBeTruthy();
});
it("should emit a null password when password textbox is empty", async () => {
const newSend = {} as SendView;
mockSendFormContainer.patchSend.mockImplementation((updateFn) => updateFn(newSend));
component.sendOptionsForm.patchValue({ password: "testing" });
expect(newSend.password).toBe("testing");
component.sendOptionsForm.patchValue({ password: "" });
expect(newSend.password).toBe(null);
});
});

View File

@@ -4,7 +4,7 @@ import { CommonModule } from "@angular/common";
import { Component, Input, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { BehaviorSubject, firstValueFrom, map, switchMap } from "rxjs";
import { BehaviorSubject, firstValueFrom, map, switchMap, tap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -12,6 +12,7 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { pin } from "@bitwarden/common/tools/rx";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
@@ -112,18 +113,27 @@ export class SendOptionsComponent implements OnInit {
this.disableHideEmail = disableHideEmail;
});
this.sendOptionsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => {
this.sendFormContainer.patchSend((send) => {
Object.assign(send, {
maxAccessCount: value.maxAccessCount,
accessCount: value.accessCount,
password: value.password,
hideEmail: value.hideEmail,
notes: value.notes,
this.sendOptionsForm.valueChanges
.pipe(
tap((value) => {
if (Utils.isNullOrWhitespace(value.password)) {
value.password = null;
}
}),
takeUntilDestroyed(),
)
.subscribe((value) => {
this.sendFormContainer.patchSend((send) => {
Object.assign(send, {
maxAccessCount: value.maxAccessCount,
accessCount: value.accessCount,
password: value.password,
hideEmail: value.hideEmail,
notes: value.notes,
});
return send;
});
return send;
});
});
}
generatePassword = async () => {

View File

@@ -9,8 +9,8 @@ import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import {
SectionComponent,
SectionHeaderComponent,

View File

@@ -1,7 +1,9 @@
<bit-section [formGroup]="sendFileDetailsForm">
<div *ngIf="config().mode === 'edit'">
<div bitTypography="body2" class="tw-text-muted">{{ "file" | i18n }}</div>
<div data-testid="file-name">{{ originalSendView().file.fileName }}</div>
<div class="tw-text-wrap tw-break-all" data-testid="file-name">
{{ originalSendView().file.fileName }}
</div>
<div data-testid="file-size" class="tw-text-muted">{{ originalSendView().file.sizeName }}</div>
</div>
<bit-form-field *ngIf="config().mode !== 'edit'">

View File

@@ -4,9 +4,9 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, Validators, ReactiveFormsModule, FormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendFileView } from "@bitwarden/common/tools/send/models/view/send-file.view";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import {
ButtonModule,
FormFieldModule,

View File

@@ -18,9 +18,8 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import {
AsyncActionsModule,
BitSubmitDirective,
@@ -227,10 +226,6 @@ export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, Send
return;
}
if (Utils.isNullOrWhitespace(this.updatedSendView.password)) {
this.updatedSendView.password = null;
}
this.toastService.showToast({
variant: "success",
title: null,

View File

@@ -7,8 +7,8 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { SendId } from "@bitwarden/common/types/guid";
import {

View File

@@ -8,6 +8,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
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 { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { ChipSelectComponent } from "@bitwarden/components";
@@ -31,9 +32,11 @@ describe("SendListFiltersComponent", () => {
accountService.activeAccount$ = of({
id: userId,
email: "test@email.com",
emailVerified: true,
name: "Test User",
...mockAccountInfoWith({
email: "test@email.com",
name: "Test User",
emailVerified: true,
}),
});
billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true));

View File

@@ -10,9 +10,9 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
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 { SendType } from "@bitwarden/common/tools/send/types/send-type";
import {
BadgeModule,
ButtonModule,

View 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>
}
}

View File

@@ -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);
});
});

View File

@@ -0,0 +1,94 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, computed, effect, input, output } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { NoResults, NoSendsIcon } from "@bitwarden/assets/svg";
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;
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,
);
// 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);
}
}

View File

@@ -0,0 +1,114 @@
<div class="tw-@container/send-table">
<bit-table [dataSource]="dataSource()">
<ng-container header>
<tr>
<th bitCell bitSortable="name" default>{{ "name" | i18n }}</th>
<th bitCell bitSortable="deletionDate" class="@lg/send-table:tw-table-cell tw-hidden">
{{ "deletionDate" | i18n }}
</th>
<th bitCell>{{ "options" | i18n }}</th>
</tr>
</ng-container>
<ng-template body let-rows$>
<tr bitRow *ngFor="let s of rows$ | async">
<td bitCell (click)="onEditSend(s)" class="tw-cursor-pointer">
<div class="tw-flex tw-gap-2 tw-items-center">
<span aria-hidden="true">
@if (s.type == sendType.File) {
<i class="bwi bwi-fw bwi-lg bwi-file"></i>
}
@if (s.type == sendType.Text) {
<i class="bwi bwi-fw bwi-lg bwi-file-text"></i>
}
</span>
<button type="button" bitLink>
{{ s.name }}
</button>
@if (s.disabled) {
<i
class="bwi bwi-exclamation-triangle"
appStopProp
title="{{ 'disabled' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "disabled" | i18n }}</span>
}
@if (s.authType !== authType.None) {
@let titleKey =
s.authType === authType.Email ? "emailProtected" : "passwordProtected";
<i
class="bwi bwi-lock"
appStopProp
title="{{ titleKey | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ titleKey | i18n }}</span>
}
@if (s.maxAccessCountReached) {
<i
class="bwi bwi-exclamation-triangle"
appStopProp
title="{{ 'maxAccessCountReached' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "maxAccessCountReached" | i18n }}</span>
}
@if (s.expired) {
<i
class="bwi bwi-clock"
appStopProp
title="{{ 'expired' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "expired" | i18n }}</span>
}
@if (s.pendingDelete) {
<i
class="bwi bwi-trash"
appStopProp
title="{{ 'pendingDeletion' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "pendingDeletion" | i18n }}</span>
}
</div>
</td>
<td
bitCell
(click)="onEditSend(s)"
class="tw-text-muted tw-cursor-pointer @lg/send-table:tw-table-cell tw-hidden"
>
<small bitTypography="body2" appStopProp>
{{ s.deletionDate | date: "medium" }}
</small>
</td>
<td bitCell class="tw-w-0 tw-text-right">
<button
type="button"
[bitMenuTriggerFor]="sendOptions"
bitIconButton="bwi-ellipsis-v"
label="{{ 'options' | i18n }}"
></button>
<bit-menu #sendOptions>
<button type="button" bitMenuItem (click)="onCopy(s)">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copySendLink" | i18n }}
</button>
@if (s.password && !disableSend()) {
<button type="button" bitMenuItem (click)="onRemovePassword(s)">
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
{{ "removePassword" | i18n }}
</button>
}
<button type="button" bitMenuItem (click)="onDelete(s)">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</span>
</button>
</bit-menu>
</td>
</tr>
</ng-template>
</bit-table>
</div>

View File

@@ -0,0 +1,111 @@
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { TableDataSource, I18nMockService } from "@bitwarden/components";
import { SendTableComponent } from "./send-table.component";
function createMockSend(id: number, overrides: Partial<SendView> = {}): SendView {
const send = new SendView();
send.id = `send-${id}`;
send.name = "My Send";
send.type = SendType.Text;
send.authType = AuthType.None;
send.deletionDate = new Date("2030-01-01T12:00:00Z");
send.password = null as any;
Object.assign(send, overrides);
return send;
}
const dataSource = new TableDataSource<SendView>();
dataSource.data = [
createMockSend(0, {
name: "Project Documentation",
type: SendType.Text,
}),
createMockSend(1, {
name: "Meeting Notes",
type: SendType.File,
}),
createMockSend(2, {
name: "Password Protected Send",
type: SendType.Text,
authType: AuthType.Password,
password: "123",
}),
createMockSend(3, {
name: "Email Protected Send",
type: SendType.Text,
authType: AuthType.Email,
emails: ["ckent@dailyplanet.com"],
}),
createMockSend(4, {
name: "Disabled Send",
type: SendType.Text,
disabled: true,
}),
createMockSend(5, {
name: "Expired Send",
type: SendType.File,
expirationDate: new Date("2025-12-01T00:00:00Z"),
}),
createMockSend(6, {
name: "Max Access Reached",
type: SendType.Text,
authType: AuthType.Password,
maxAccessCount: 5,
accessCount: 5,
password: "123",
}),
];
export default {
title: "Tools/Sends/Send Table",
component: SendTableComponent,
decorators: [
moduleMetadata({
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
name: "Name",
deletionDate: "Deletion Date",
options: "Options",
disabled: "Disabled",
passwordProtected: "Password protected",
emailProtected: "Email protected",
maxAccessCountReached: "Max access count reached",
expired: "Expired",
pendingDeletion: "Pending deletion",
copySendLink: "Copy Send link",
removePassword: "Remove password",
delete: "Delete",
loading: "Loading",
});
},
},
],
}),
],
args: {
dataSource,
disableSend: false,
},
argTypes: {
editSend: { action: "editSend" },
copySend: { action: "copySend" },
removePassword: { action: "removePassword" },
deleteSend: { action: "deleteSend" },
},
} as Meta<SendTableComponent>;
type Story = StoryObj<SendTableComponent>;
export const Default: Story = {};

View File

@@ -0,0 +1,94 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, input, output } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { AuthType } from "@bitwarden/common/tools/send/types/auth-type";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import {
BadgeModule,
ButtonModule,
IconButtonModule,
LinkModule,
MenuModule,
TableDataSource,
TableModule,
TypographyModule,
} from "@bitwarden/components";
/**
* A table component for displaying Send items with sorting, status indicators, and action menus. Handles the presentation of sends in a tabular format with options
* for editing, copying links, removing passwords, and deleting.
*/
@Component({
selector: "tools-send-table",
templateUrl: "./send-table.component.html",
imports: [
CommonModule,
JslibModule,
TableModule,
ButtonModule,
LinkModule,
IconButtonModule,
MenuModule,
BadgeModule,
TypographyModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SendTableComponent {
protected readonly sendType = SendType;
protected readonly authType = AuthType;
/**
* The data source containing the Send items to display in the table.
*/
readonly dataSource = input<TableDataSource<SendView>>();
/**
* Whether Send functionality is disabled by policy.
* When true, the "Remove Password" option is hidden from the action menu.
*/
readonly disableSend = input(false);
/**
* Emitted when a user clicks on a Send item to edit it.
* The clicked SendView is passed as the event payload.
*/
readonly editSend = output<SendView>();
/**
* Emitted when a user clicks the "Copy Send Link" action.
* The SendView is passed as the event payload for generating and copying the link.
*/
readonly copySend = output<SendView>();
/**
* Emitted when a user clicks the "Remove Password" action.
* The SendView is passed as the event payload for password removal.
* This action is only available if the Send has a password and Send is not disabled.
*/
readonly removePassword = output<SendView>();
/**
* Emitted when a user clicks the "Delete" action.
* The SendView is passed as the event payload for deletion.
*/
readonly deleteSend = output<SendView>();
protected onEditSend(send: SendView): void {
this.editSend.emit(send);
}
protected onCopy(send: SendView): void {
this.copySend.emit(send);
}
protected onRemovePassword(send: SendView): void {
this.removePassword.emit(send);
}
protected onDelete(send: SendView): void {
this.deleteSend.emit(send);
}
}

View File

@@ -4,8 +4,8 @@ import { BehaviorSubject } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { SendListFiltersService } from "./send-list-filters.service";

View File

@@ -5,8 +5,8 @@ import { FormBuilder } from "@angular/forms";
import { map, Observable, startWith } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { ITreeNodeObject, TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { ChipSelectOption } from "@bitwarden/components";