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:
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<button bitButton [bitMenuTriggerFor]="itemOptions" [buttonType]="buttonType()" type="button">
|
||||
@if (!hideIcon()) {
|
||||
<i class="bwi bwi-plus tw-me-2" aria-hidden="true"></i>
|
||||
}
|
||||
{{ (hideIcon() ? "createSend" : "new") | i18n }}
|
||||
</button>
|
||||
<bit-menu #itemOptions>
|
||||
<button bitMenuItem type="button" (click)="onTextSendClick()">
|
||||
<i class="bwi bwi-file-text" slot="start" aria-hidden="true"></i>
|
||||
{{ "sendTypeText" | i18n }}
|
||||
</button>
|
||||
<button bitMenuItem type="button" (click)="onFileSendClick()">
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<i class="bwi bwi-file" slot="start" aria-hidden="true"></i>
|
||||
{{ "sendTypeFile" | i18n }}
|
||||
<app-premium-badge></app-premium-badge>
|
||||
</div>
|
||||
</button>
|
||||
</bit-menu>
|
||||
@@ -0,0 +1,261 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SendType } from "@bitwarden/common/tools/send/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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
@if (loading()) {
|
||||
<bit-spinner />
|
||||
} @else {
|
||||
@if (showSearchBar()) {
|
||||
<!-- Search Bar - hidden when no Sends exist -->
|
||||
<tools-send-search></tools-send-search>
|
||||
}
|
||||
<tools-send-table
|
||||
[dataSource]="dataSource"
|
||||
[disableSend]="disableSend()"
|
||||
(editSend)="onEditSend($event)"
|
||||
(copySend)="onCopySend($event)"
|
||||
(removePassword)="onRemovePassword($event)"
|
||||
(deleteSend)="onDeleteSend($event)"
|
||||
/>
|
||||
@if (noSearchResults()) {
|
||||
<!-- No Sends from Search results -->
|
||||
<bit-no-items [icon]="noItemIcon">
|
||||
<ng-container slot="title">{{ "sendsTitleNoSearchResults" | i18n }}</ng-container>
|
||||
<ng-container slot="description">{{ "sendsBodyNoSearchResults" | i18n }}</ng-container>
|
||||
</bit-no-items>
|
||||
} @else if (listState() === sendListState.NoResults || listState() === sendListState.Empty) {
|
||||
<!-- No Sends from Filter results ( File/Text ) -->
|
||||
<!-- No Sends exist at all -->
|
||||
<bit-no-items [icon]="noItemIcon">
|
||||
<ng-container slot="title">{{ "sendsTitleNoItems" | i18n }}</ng-container>
|
||||
<ng-container slot="description">{{ "sendsBodyNoItems" | i18n }}</ng-container>
|
||||
<ng-content select="[slot='empty-button']" slot="button" />
|
||||
</bit-no-items>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { SendItemsService } from "../services/send-items.service";
|
||||
|
||||
import { SendListComponent } from "./send-list.component";
|
||||
|
||||
describe("SendListComponent", () => {
|
||||
let component: SendListComponent;
|
||||
let fixture: ComponentFixture<SendListComponent>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let sendItemsService: MockProxy<SendItemsService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
i18nService = mock<I18nService>();
|
||||
i18nService.t.mockImplementation((key) => key);
|
||||
|
||||
// Mock SendItemsService for SendSearchComponent child component
|
||||
sendItemsService = mock<SendItemsService>();
|
||||
sendItemsService.latestSearchText$ = of("");
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SendListComponent],
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: i18nService },
|
||||
{ provide: SendItemsService, useValue: sendItemsService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SendListComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should display empty state when listState is Empty", () => {
|
||||
fixture.componentRef.setInput("sends", []);
|
||||
fixture.componentRef.setInput("listState", "Empty");
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.textContent).toContain("sendsTitleNoItems");
|
||||
});
|
||||
|
||||
it("should display no results state when listState is NoResults", () => {
|
||||
fixture.componentRef.setInput("sends", []);
|
||||
fixture.componentRef.setInput("listState", "NoResults");
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
// Component shows same empty state for both Empty and NoResults states
|
||||
expect(compiled.textContent).toContain("sendsTitleNoItems");
|
||||
});
|
||||
|
||||
it("should emit editSend event when send is edited", () => {
|
||||
const editSpy = jest.fn();
|
||||
component.editSend.subscribe(editSpy);
|
||||
|
||||
const mockSend = { id: "test-id", name: "Test Send" } as any;
|
||||
component["onEditSend"](mockSend);
|
||||
|
||||
expect(editSpy).toHaveBeenCalledWith(mockSend);
|
||||
});
|
||||
|
||||
it("should emit copySend event when send link is copied", () => {
|
||||
const copySpy = jest.fn();
|
||||
component.copySend.subscribe(copySpy);
|
||||
|
||||
const mockSend = { id: "test-id", name: "Test Send" } as any;
|
||||
component["onCopySend"](mockSend);
|
||||
|
||||
expect(copySpy).toHaveBeenCalledWith(mockSend);
|
||||
});
|
||||
|
||||
it("should emit deleteSend event when send is deleted", () => {
|
||||
const deleteSpy = jest.fn();
|
||||
component.deleteSend.subscribe(deleteSpy);
|
||||
|
||||
const mockSend = { id: "test-id", name: "Test Send" } as any;
|
||||
component["onDeleteSend"](mockSend);
|
||||
|
||||
expect(deleteSpy).toHaveBeenCalledWith(mockSend);
|
||||
});
|
||||
});
|
||||
94
libs/tools/send/send-ui/src/send-list/send-list.component.ts
Normal file
94
libs/tools/send/send-ui/src/send-list/send-list.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
114
libs/tools/send/send-ui/src/send-table/send-table.component.html
Normal file
114
libs/tools/send/send-ui/src/send-table/send-table.component.html
Normal 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>
|
||||
@@ -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 = {};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user