mirror of
https://github.com/bitwarden/browser
synced 2026-02-28 02:23:25 +00:00
Merge branch 'main' into desktop/pm-18769/migrate-vault-filters
This commit is contained in:
@@ -31,7 +31,11 @@ interface Animal {
|
||||
<button class="tw-mr-2" bitButton type="button" (click)="openDialogNonDismissable()">
|
||||
Open Non-Dismissable Dialog
|
||||
</button>
|
||||
<button bitButton type="button" (click)="openDrawer()">Open Drawer</button>
|
||||
<button class="tw-mr-2" bitButton type="button" (click)="openDrawer()">Open Drawer</button>
|
||||
<button class="tw-mr-2" bitButton size="small" type="button" (click)="openSmallDrawer()">
|
||||
Open Small Drawer
|
||||
</button>
|
||||
<button bitButton type="button" (click)="openLargeDrawer()">Open Large Drawer</button>
|
||||
</bit-layout>
|
||||
`,
|
||||
imports: [ButtonModule, LayoutComponent],
|
||||
@@ -63,13 +67,29 @@ class StoryDialogComponent {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openSmallDrawer() {
|
||||
this.dialogService.openDrawer(SmallDrawerContentComponent, {
|
||||
data: {
|
||||
animal: "panda",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openLargeDrawer() {
|
||||
this.dialogService.openDrawer(LargeDrawerContentComponent, {
|
||||
data: {
|
||||
animal: "panda",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
template: `
|
||||
<bit-dialog title="Dialog Title" dialogSize="large">
|
||||
<bit-dialog title="Dialog Title">
|
||||
<span bitDialogContent>
|
||||
Dialog body text goes here.
|
||||
<br />
|
||||
@@ -100,7 +120,6 @@ class StoryDialogContentComponent {
|
||||
template: `
|
||||
<bit-dialog
|
||||
title="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore"
|
||||
dialogSize="large"
|
||||
>
|
||||
<span bitDialogContent>
|
||||
Dialog body text goes here.
|
||||
@@ -125,6 +144,64 @@ class NonDismissableContentComponent {
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
template: `
|
||||
<bit-dialog title="Small Drawer" dialogSize="small">
|
||||
<span bitDialogContent>
|
||||
Dialog body text goes here.
|
||||
<br />
|
||||
Animal: {{ animal }}
|
||||
</span>
|
||||
<ng-container bitDialogFooter>
|
||||
<button type="button" bitButton buttonType="primary" (click)="dialogRef.close()">
|
||||
Save
|
||||
</button>
|
||||
<button type="button" bitButton buttonType="secondary" bitDialogClose>Cancel</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
`,
|
||||
imports: [DialogModule, ButtonModule],
|
||||
})
|
||||
class SmallDrawerContentComponent {
|
||||
dialogRef = inject(DialogRef);
|
||||
private data = inject<Animal>(DIALOG_DATA);
|
||||
|
||||
get animal() {
|
||||
return this.data?.animal;
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
template: `
|
||||
<bit-dialog title="Large Drawer" dialogSize="large">
|
||||
<span bitDialogContent>
|
||||
Dialog body text goes here.
|
||||
<br />
|
||||
Animal: {{ animal }}
|
||||
</span>
|
||||
<ng-container bitDialogFooter>
|
||||
<button type="button" bitButton buttonType="primary" (click)="dialogRef.close()">
|
||||
Save
|
||||
</button>
|
||||
<button type="button" bitButton buttonType="secondary" bitDialogClose>Cancel</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
`,
|
||||
imports: [DialogModule, ButtonModule],
|
||||
})
|
||||
class LargeDrawerContentComponent {
|
||||
dialogRef = inject(DialogRef);
|
||||
private data = inject<Animal>(DIALOG_DATA);
|
||||
|
||||
get animal() {
|
||||
return this.data?.animal;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "Component Library/Dialogs/Service",
|
||||
component: StoryDialogComponent,
|
||||
@@ -206,3 +283,21 @@ export const Drawer: Story = {
|
||||
await userEvent.click(button);
|
||||
},
|
||||
};
|
||||
|
||||
export const DrawerSmall: Story = {
|
||||
play: async (context) => {
|
||||
const canvas = context.canvasElement;
|
||||
|
||||
const button = getAllByRole(canvas, "button")[3];
|
||||
await userEvent.click(button);
|
||||
},
|
||||
};
|
||||
|
||||
export const DrawerLarge: Story = {
|
||||
play: async (context) => {
|
||||
const canvas = context.canvasElement;
|
||||
|
||||
const button = getAllByRole(canvas, "button")[4];
|
||||
await userEvent.click(button);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<section
|
||||
class="tw-flex tw-w-full tw-flex-col tw-self-center tw-overflow-hidden tw-border tw-border-solid tw-border-secondary-100 tw-bg-background tw-text-main"
|
||||
[ngClass]="[
|
||||
width,
|
||||
width(),
|
||||
isDrawer ? 'tw-h-full tw-border-t-0' : 'tw-rounded-t-xl md:tw-rounded-xl tw-shadow-lg',
|
||||
]"
|
||||
cdkTrapFocus
|
||||
|
||||
@@ -26,6 +26,20 @@ import { DialogRef } from "../dialog.service";
|
||||
import { DialogCloseDirective } from "../directives/dialog-close.directive";
|
||||
import { DialogTitleContainerDirective } from "../directives/dialog-title-container.directive";
|
||||
|
||||
type DialogSize = "small" | "default" | "large";
|
||||
|
||||
const dialogSizeToWidth = {
|
||||
small: "md:tw-max-w-sm",
|
||||
default: "md:tw-max-w-xl",
|
||||
large: "md:tw-max-w-3xl",
|
||||
} as const;
|
||||
|
||||
const drawerSizeToWidth = {
|
||||
small: "md:tw-max-w-sm",
|
||||
default: "md:tw-max-w-lg",
|
||||
large: "md:tw-max-w-2xl",
|
||||
} as const;
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
@@ -71,7 +85,7 @@ export class DialogComponent {
|
||||
/**
|
||||
* Dialog size, more complex dialogs should use large, otherwise default is fine.
|
||||
*/
|
||||
readonly dialogSize = input<"small" | "default" | "large">("default");
|
||||
readonly dialogSize = input<DialogSize>("default");
|
||||
|
||||
/**
|
||||
* Title to show in the dialog's header
|
||||
@@ -100,21 +114,31 @@ export class DialogComponent {
|
||||
|
||||
private readonly animationCompleted = signal(false);
|
||||
|
||||
protected readonly width = computed(() => {
|
||||
const size = this.dialogSize() ?? "default";
|
||||
const isDrawer = this.dialogRef?.isDrawer;
|
||||
|
||||
if (isDrawer) {
|
||||
return drawerSizeToWidth[size];
|
||||
}
|
||||
|
||||
return dialogSizeToWidth[size];
|
||||
});
|
||||
|
||||
protected readonly classes = computed(() => {
|
||||
// `tw-max-h-[90vh]` is needed to prevent dialogs from overlapping the desktop header
|
||||
const baseClasses = ["tw-flex", "tw-flex-col", "tw-w-screen"];
|
||||
const sizeClasses = this.dialogRef?.isDrawer
|
||||
? ["tw-h-full", "md:tw-w-[23rem]"]
|
||||
: ["md:tw-p-4", "tw-w-screen", "tw-max-h-[90vh]"];
|
||||
const sizeClasses = this.dialogRef?.isDrawer ? ["tw-h-full"] : ["md:tw-p-4", "tw-max-h-[90vh]"];
|
||||
|
||||
const size = this.dialogSize() ?? "default";
|
||||
const animationClasses =
|
||||
this.disableAnimations() || this.animationCompleted() || this.dialogRef?.isDrawer
|
||||
? []
|
||||
: this.dialogSize() === "small"
|
||||
: size === "small"
|
||||
? ["tw-animate-slide-down"]
|
||||
: ["tw-animate-slide-up", "md:tw-animate-slide-down"];
|
||||
|
||||
return [...baseClasses, this.width, ...sizeClasses, ...animationClasses];
|
||||
return [...baseClasses, this.width(), ...sizeClasses, ...animationClasses];
|
||||
});
|
||||
|
||||
handleEsc(event: Event) {
|
||||
@@ -124,20 +148,6 @@ export class DialogComponent {
|
||||
}
|
||||
}
|
||||
|
||||
get width() {
|
||||
switch (this.dialogSize()) {
|
||||
case "small": {
|
||||
return "md:tw-max-w-sm";
|
||||
}
|
||||
case "large": {
|
||||
return "md:tw-max-w-3xl";
|
||||
}
|
||||
default: {
|
||||
return "md:tw-max-w-xl";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onAnimationEnd() {
|
||||
this.animationCompleted.set(true);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
[ariaLabel]="ariaLabel()"
|
||||
[hideActiveStyles]="parentHideActiveStyles()"
|
||||
[ariaCurrentWhenActive]="ariaCurrent()"
|
||||
[forceActiveStyles]="forceActiveStyles()"
|
||||
>
|
||||
<ng-template #button>
|
||||
<button
|
||||
|
||||
@@ -92,6 +92,12 @@ export class NavGroupComponent extends NavBaseComponent {
|
||||
*/
|
||||
readonly hideIfEmpty = input(false, { transform: booleanAttribute });
|
||||
|
||||
/** Forces active styles to be shown, regardless of the `routerLinkActiveOptions` */
|
||||
readonly forceActiveStyles = input(false, { transform: booleanAttribute });
|
||||
|
||||
/** Does not toggle the expanded state on click */
|
||||
readonly disableToggleOnClick = input(false, { transform: booleanAttribute });
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
||||
@Output()
|
||||
@@ -129,7 +135,7 @@ export class NavGroupComponent extends NavBaseComponent {
|
||||
this.sideNavService.setOpen();
|
||||
}
|
||||
this.open.set(true);
|
||||
} else {
|
||||
} else if (!this.disableToggleOnClick()) {
|
||||
this.toggle();
|
||||
}
|
||||
this.mainContentClicked.emit();
|
||||
|
||||
@@ -168,3 +168,23 @@ export const Tree: StoryObj<NavGroupComponent> = {
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const ForcedActive: StoryObj<NavGroupComponent> = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<bit-side-nav>
|
||||
<bit-nav-group text="Hello World (Anchor)" [route]="['a']" icon="bwi-filter" [hideIfEmpty]="hideIfEmpty">
|
||||
<bit-nav-item text="Child A" route="a" icon="bwi-filter" *ngIf="renderChildren"></bit-nav-item>
|
||||
<bit-nav-item text="Child B" route="b" *ngIf="renderChildren"></bit-nav-item>
|
||||
<bit-nav-item text="Child C" route="c" icon="bwi-filter" *ngIf="renderChildren"></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
<bit-nav-group text="Lorem Ipsum (Button)" icon="bwi-filter" forceActiveStyles disableToggleOnClick>
|
||||
<bit-nav-item text="Child A" icon="bwi-filter"></bit-nav-item>
|
||||
<bit-nav-item text="Child B"></bit-nav-item>
|
||||
<bit-nav-item text="Child C" icon="bwi-filter"></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
</bit-side-nav>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -319,8 +319,6 @@ export abstract class BaseImporter {
|
||||
}
|
||||
if (this.isNullOrWhitespace(cipher.notes)) {
|
||||
cipher.notes = null;
|
||||
} else {
|
||||
cipher.notes = cipher.notes.trim();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,10 @@ export class EnpassJsonImporter extends BaseImporter implements Importer {
|
||||
}
|
||||
}
|
||||
|
||||
cipher.notes += "\n" + this.getValueOrDefault(item.note, "");
|
||||
const note = this.getValueOrDefault(item.note, "");
|
||||
if (note) {
|
||||
cipher.notes = note.trimEnd();
|
||||
}
|
||||
this.convertToNoteIfNeeded(cipher);
|
||||
this.cleanupCipher(cipher);
|
||||
result.ciphers.push(cipher);
|
||||
|
||||
@@ -21,7 +21,7 @@ export class KeeperCsvImporter extends BaseImporter implements Importer {
|
||||
|
||||
const notes = this.getValueOrDefault(value[5]);
|
||||
if (notes) {
|
||||
cipher.notes = `${notes}\n`;
|
||||
cipher.notes = notes.trimEnd();
|
||||
}
|
||||
|
||||
cipher.name = this.getValueOrDefault(value[1], "--");
|
||||
|
||||
@@ -50,7 +50,7 @@ export class MykiCsvImporter extends BaseImporter implements Importer {
|
||||
results.forEach((value) => {
|
||||
const cipher = this.initLoginCipher();
|
||||
cipher.name = this.getValueOrDefault(value.nickname, "--");
|
||||
cipher.notes = this.getValueOrDefault(value.additionalInfo);
|
||||
cipher.notes = this.getValueOrDefault(value.additionalInfo, "").trimEnd();
|
||||
|
||||
if (value.url !== undefined) {
|
||||
// Accounts
|
||||
@@ -132,7 +132,7 @@ export class MykiCsvImporter extends BaseImporter implements Importer {
|
||||
cipher.secureNote = new SecureNoteView();
|
||||
cipher.type = CipherType.SecureNote;
|
||||
cipher.secureNote.type = SecureNoteType.Generic;
|
||||
cipher.notes = this.getValueOrDefault(value.content);
|
||||
cipher.notes = this.getValueOrDefault(value.content, "").trimEnd();
|
||||
|
||||
this.importUnmappedFields(cipher, value, _mappedUserNoteColumns);
|
||||
} else {
|
||||
|
||||
@@ -35,7 +35,7 @@ export class NetwrixPasswordSecureCsvImporter extends BaseImporter implements Im
|
||||
|
||||
const notes = this.getValueOrDefault(row.Informationen);
|
||||
if (notes) {
|
||||
cipher.notes = `${notes}\n`;
|
||||
cipher.notes = notes.trimEnd();
|
||||
}
|
||||
|
||||
cipher.name = this.getValueOrDefault(row.Beschreibung, "--");
|
||||
|
||||
@@ -97,7 +97,7 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer {
|
||||
this.processSections(category, item.details.sections, cipher);
|
||||
|
||||
if (!this.isNullOrWhitespace(item.details.notesPlain)) {
|
||||
cipher.notes = item.details.notesPlain.split(this.newLineRegex).join("\n") + "\n";
|
||||
cipher.notes = item.details.notesPlain.split(this.newLineRegex).join("\n").trimEnd();
|
||||
}
|
||||
|
||||
this.convertToNoteIfNeeded(cipher);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./send-form";
|
||||
export { NewSendDropdownComponent } from "./new-send-dropdown/new-send-dropdown.component";
|
||||
export { NewSendDropdownV2Component } from "./new-send-dropdown-v2/new-send-dropdown-v2.component";
|
||||
export * from "./add-edit/send-add-edit-dialog.component";
|
||||
export { SendListItemsContainerComponent } from "./send-list-items-container/send-list-items-container.component";
|
||||
export { SendItemsService } from "./services/send-items.service";
|
||||
@@ -7,3 +8,4 @@ export { SendSearchComponent } from "./send-search/send-search.component";
|
||||
export { SendListFiltersComponent } from "./send-list-filters/send-list-filters.component";
|
||||
export { SendListFiltersService } from "./services/send-list-filters.service";
|
||||
export { SendTableComponent } from "./send-table/send-table.component";
|
||||
export { SendListComponent, SendListState } from "./send-list/send-list.component";
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<button bitButton [bitMenuTriggerFor]="itemOptions" [buttonType]="buttonType()" type="button">
|
||||
@if (!hideIcon()) {
|
||||
<i class="bwi bwi-plus tw-me-2" aria-hidden="true"></i>
|
||||
}
|
||||
{{ (hideIcon() ? "createSend" : "new") | i18n }}
|
||||
</button>
|
||||
<bit-menu #itemOptions>
|
||||
<button bitMenuItem type="button" (click)="onTextSendClick()">
|
||||
<i class="bwi bwi-file-text" slot="start" aria-hidden="true"></i>
|
||||
{{ "sendTypeText" | i18n }}
|
||||
</button>
|
||||
<button bitMenuItem type="button" (click)="onFileSendClick()">
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<i class="bwi bwi-file" slot="start" aria-hidden="true"></i>
|
||||
{{ "sendTypeFile" | i18n }}
|
||||
<app-premium-badge></app-premium-badge>
|
||||
</div>
|
||||
</button>
|
||||
</bit-menu>
|
||||
@@ -0,0 +1,261 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
|
||||
import { NewSendDropdownV2Component } from "./new-send-dropdown-v2.component";
|
||||
|
||||
describe("NewSendDropdownV2Component", () => {
|
||||
let component: NewSendDropdownV2Component;
|
||||
let fixture: ComponentFixture<NewSendDropdownV2Component>;
|
||||
let billingService: MockProxy<BillingAccountProfileStateService>;
|
||||
let accountService: MockProxy<AccountService>;
|
||||
let premiumUpgradeService: MockProxy<PremiumUpgradePromptService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
billingService = mock<BillingAccountProfileStateService>();
|
||||
accountService = mock<AccountService>();
|
||||
premiumUpgradeService = mock<PremiumUpgradePromptService>();
|
||||
|
||||
// Default: user has premium
|
||||
accountService.activeAccount$ = of({ id: "user-123" } as any);
|
||||
billingService.hasPremiumFromAnySource$.mockReturnValue(of(true));
|
||||
|
||||
const i18nService = mock<I18nService>();
|
||||
i18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NewSendDropdownV2Component],
|
||||
providers: [
|
||||
{ provide: BillingAccountProfileStateService, useValue: billingService },
|
||||
{ provide: AccountService, useValue: accountService },
|
||||
{ provide: PremiumUpgradePromptService, useValue: premiumUpgradeService },
|
||||
{ provide: I18nService, useValue: i18nService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(NewSendDropdownV2Component);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("input signals", () => {
|
||||
it("has correct default input values", () => {
|
||||
expect(component.hideIcon()).toBe(false);
|
||||
expect(component.buttonType()).toBe("primary");
|
||||
});
|
||||
|
||||
it("accepts input signal values", () => {
|
||||
fixture.componentRef.setInput("hideIcon", true);
|
||||
fixture.componentRef.setInput("buttonType", "secondary");
|
||||
|
||||
expect(component.hideIcon()).toBe(true);
|
||||
expect(component.buttonType()).toBe("secondary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("premium status detection", () => {
|
||||
it("hasNoPremium is false when user has premium", () => {
|
||||
billingService.hasPremiumFromAnySource$.mockReturnValue(of(true));
|
||||
accountService.activeAccount$ = of({ id: "user-123" } as any);
|
||||
|
||||
fixture = TestBed.createComponent(NewSendDropdownV2Component);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component["hasNoPremium"]()).toBe(false);
|
||||
});
|
||||
|
||||
it("hasNoPremium is true when user lacks premium", () => {
|
||||
billingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
|
||||
accountService.activeAccount$ = of({ id: "user-123" } as any);
|
||||
|
||||
fixture = TestBed.createComponent(NewSendDropdownV2Component);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component["hasNoPremium"]()).toBe(true);
|
||||
});
|
||||
|
||||
it("hasNoPremium defaults to true when no active account", () => {
|
||||
accountService.activeAccount$ = of(null);
|
||||
|
||||
fixture = TestBed.createComponent(NewSendDropdownV2Component);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component["hasNoPremium"]()).toBe(true);
|
||||
});
|
||||
|
||||
it("hasNoPremium updates reactively when premium status changes", async () => {
|
||||
const premiumSubject = new BehaviorSubject(false);
|
||||
billingService.hasPremiumFromAnySource$.mockReturnValue(premiumSubject.asObservable());
|
||||
|
||||
fixture = TestBed.createComponent(NewSendDropdownV2Component);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component["hasNoPremium"]()).toBe(true);
|
||||
|
||||
premiumSubject.next(true);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component["hasNoPremium"]()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("text send functionality", () => {
|
||||
it("onTextSendClick emits SendType.Text", () => {
|
||||
const emitSpy = jest.fn();
|
||||
component.addSend.subscribe(emitSpy);
|
||||
|
||||
component["onTextSendClick"]();
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith(SendType.Text);
|
||||
});
|
||||
|
||||
it("allows text send without premium", () => {
|
||||
billingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
|
||||
|
||||
fixture = TestBed.createComponent(NewSendDropdownV2Component);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
const emitSpy = jest.fn();
|
||||
component.addSend.subscribe(emitSpy);
|
||||
|
||||
component["onTextSendClick"]();
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith(SendType.Text);
|
||||
expect(premiumUpgradeService.promptForPremium).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("file send premium gating", () => {
|
||||
it("onFileSendClick emits SendType.File when user has premium", async () => {
|
||||
const emitSpy = jest.fn();
|
||||
component.addSend.subscribe(emitSpy);
|
||||
|
||||
await component["onFileSendClick"]();
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith(SendType.File);
|
||||
expect(premiumUpgradeService.promptForPremium).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("onFileSendClick shows premium prompt without premium", async () => {
|
||||
billingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
|
||||
premiumUpgradeService.promptForPremium.mockResolvedValue();
|
||||
|
||||
fixture = TestBed.createComponent(NewSendDropdownV2Component);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
const emitSpy = jest.fn();
|
||||
component.addSend.subscribe(emitSpy);
|
||||
|
||||
await component["onFileSendClick"]();
|
||||
|
||||
expect(premiumUpgradeService.promptForPremium).toHaveBeenCalled();
|
||||
expect(emitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not emit file send type when premium prompt is shown", async () => {
|
||||
billingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
|
||||
|
||||
fixture = TestBed.createComponent(NewSendDropdownV2Component);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
const emitSpy = jest.fn();
|
||||
component.addSend.subscribe(emitSpy);
|
||||
|
||||
await component["onFileSendClick"]();
|
||||
|
||||
expect(emitSpy).not.toHaveBeenCalledWith(SendType.File);
|
||||
});
|
||||
|
||||
it("allows file send after user gains premium", async () => {
|
||||
const premiumSubject = new BehaviorSubject(false);
|
||||
billingService.hasPremiumFromAnySource$.mockReturnValue(premiumSubject.asObservable());
|
||||
|
||||
fixture = TestBed.createComponent(NewSendDropdownV2Component);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
// Initially no premium
|
||||
let emitSpy = jest.fn();
|
||||
component.addSend.subscribe(emitSpy);
|
||||
await component["onFileSendClick"]();
|
||||
expect(premiumUpgradeService.promptForPremium).toHaveBeenCalled();
|
||||
|
||||
// Gain premium
|
||||
premiumSubject.next(true);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
// Now should emit
|
||||
emitSpy = jest.fn();
|
||||
component.addSend.subscribe(emitSpy);
|
||||
await component["onFileSendClick"]();
|
||||
expect(emitSpy).toHaveBeenCalledWith(SendType.File);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles null account without errors", () => {
|
||||
accountService.activeAccount$ = of(null);
|
||||
|
||||
expect(() => {
|
||||
fixture = TestBed.createComponent(NewSendDropdownV2Component);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
}).not.toThrow();
|
||||
|
||||
expect(component["hasNoPremium"]()).toBe(true);
|
||||
});
|
||||
|
||||
it("handles rapid clicks without race conditions", async () => {
|
||||
const emitSpy = jest.fn();
|
||||
component.addSend.subscribe(emitSpy);
|
||||
|
||||
// Rapid text send clicks
|
||||
component["onTextSendClick"]();
|
||||
component["onTextSendClick"]();
|
||||
component["onTextSendClick"]();
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Rapid file send clicks (with premium)
|
||||
await Promise.all([
|
||||
component["onFileSendClick"](),
|
||||
component["onFileSendClick"](),
|
||||
component["onFileSendClick"](),
|
||||
]);
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledTimes(6); // 3 text + 3 file
|
||||
});
|
||||
|
||||
it("cleans up subscriptions on destroy", () => {
|
||||
const subscription = component["hasNoPremium"];
|
||||
|
||||
fixture.destroy();
|
||||
|
||||
// Signal should still exist but component cleanup handled by Angular
|
||||
expect(() => subscription()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, input, output } from "@angular/core";
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { map, of, switchMap } from "rxjs";
|
||||
|
||||
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { ButtonModule, ButtonType, MenuModule } from "@bitwarden/components";
|
||||
|
||||
// Desktop-specific version of NewSendDropdownComponent.
|
||||
// Unlike the shared library version, this component emits events instead of using Angular Router,
|
||||
// which aligns with Desktop's modal-based architecture.
|
||||
@Component({
|
||||
selector: "tools-new-send-dropdown-v2",
|
||||
templateUrl: "new-send-dropdown-v2.component.html",
|
||||
imports: [JslibModule, ButtonModule, MenuModule, PremiumBadgeComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class NewSendDropdownV2Component {
|
||||
readonly hideIcon = input<boolean>(false);
|
||||
readonly buttonType = input<ButtonType>("primary");
|
||||
|
||||
readonly addSend = output<SendType>();
|
||||
|
||||
protected sendType = SendType;
|
||||
|
||||
private readonly billingAccountProfileStateService = inject(BillingAccountProfileStateService);
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly premiumUpgradePromptService = inject(PremiumUpgradePromptService);
|
||||
|
||||
protected readonly hasNoPremium = toSignal(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
switchMap((account) => {
|
||||
if (!account) {
|
||||
return of(true);
|
||||
}
|
||||
return this.billingAccountProfileStateService
|
||||
.hasPremiumFromAnySource$(account.id)
|
||||
.pipe(map((hasPremium) => !hasPremium));
|
||||
}),
|
||||
),
|
||||
{ initialValue: true },
|
||||
);
|
||||
|
||||
protected onTextSendClick(): void {
|
||||
this.addSend.emit(SendType.Text);
|
||||
}
|
||||
|
||||
protected async onFileSendClick(): Promise<void> {
|
||||
if (this.hasNoPremium()) {
|
||||
await this.premiumUpgradePromptService.promptForPremium();
|
||||
} else {
|
||||
this.addSend.emit(SendType.File);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
@if (loading()) {
|
||||
<bit-spinner />
|
||||
} @else {
|
||||
@if (showSearchBar()) {
|
||||
<!-- Search Bar - hidden when no Sends exist -->
|
||||
<tools-send-search></tools-send-search>
|
||||
}
|
||||
<tools-send-table
|
||||
[dataSource]="dataSource"
|
||||
[disableSend]="disableSend()"
|
||||
(editSend)="onEditSend($event)"
|
||||
(copySend)="onCopySend($event)"
|
||||
(removePassword)="onRemovePassword($event)"
|
||||
(deleteSend)="onDeleteSend($event)"
|
||||
/>
|
||||
@if (noSearchResults()) {
|
||||
<!-- No Sends from Search results -->
|
||||
<bit-no-items [icon]="noItemIcon">
|
||||
<ng-container slot="title">{{ "sendsTitleNoSearchResults" | i18n }}</ng-container>
|
||||
<ng-container slot="description">{{ "sendsBodyNoSearchResults" | i18n }}</ng-container>
|
||||
</bit-no-items>
|
||||
} @else if (listState() === sendListState.NoResults || listState() === sendListState.Empty) {
|
||||
<!-- No Sends from Filter results ( File/Text ) -->
|
||||
<!-- No Sends exist at all -->
|
||||
<bit-no-items [icon]="noItemIcon">
|
||||
<ng-container slot="title">{{ "sendsTitleNoItems" | i18n }}</ng-container>
|
||||
<ng-container slot="description">{{ "sendsBodyNoItems" | i18n }}</ng-container>
|
||||
<ng-content select="[slot='empty-button']" slot="button" />
|
||||
</bit-no-items>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { SendItemsService } from "../services/send-items.service";
|
||||
|
||||
import { SendListComponent } from "./send-list.component";
|
||||
|
||||
describe("SendListComponent", () => {
|
||||
let component: SendListComponent;
|
||||
let fixture: ComponentFixture<SendListComponent>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let sendItemsService: MockProxy<SendItemsService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
i18nService = mock<I18nService>();
|
||||
i18nService.t.mockImplementation((key) => key);
|
||||
|
||||
// Mock SendItemsService for SendSearchComponent child component
|
||||
sendItemsService = mock<SendItemsService>();
|
||||
sendItemsService.latestSearchText$ = of("");
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SendListComponent],
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: i18nService },
|
||||
{ provide: SendItemsService, useValue: sendItemsService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SendListComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should display empty state when listState is Empty", () => {
|
||||
fixture.componentRef.setInput("sends", []);
|
||||
fixture.componentRef.setInput("listState", "Empty");
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.textContent).toContain("sendsTitleNoItems");
|
||||
});
|
||||
|
||||
it("should display no results state when listState is NoResults", () => {
|
||||
fixture.componentRef.setInput("sends", []);
|
||||
fixture.componentRef.setInput("listState", "NoResults");
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
// Component shows same empty state for both Empty and NoResults states
|
||||
expect(compiled.textContent).toContain("sendsTitleNoItems");
|
||||
});
|
||||
|
||||
it("should emit editSend event when send is edited", () => {
|
||||
const editSpy = jest.fn();
|
||||
component.editSend.subscribe(editSpy);
|
||||
|
||||
const mockSend = { id: "test-id", name: "Test Send" } as any;
|
||||
component["onEditSend"](mockSend);
|
||||
|
||||
expect(editSpy).toHaveBeenCalledWith(mockSend);
|
||||
});
|
||||
|
||||
it("should emit copySend event when send link is copied", () => {
|
||||
const copySpy = jest.fn();
|
||||
component.copySend.subscribe(copySpy);
|
||||
|
||||
const mockSend = { id: "test-id", name: "Test Send" } as any;
|
||||
component["onCopySend"](mockSend);
|
||||
|
||||
expect(copySpy).toHaveBeenCalledWith(mockSend);
|
||||
});
|
||||
|
||||
it("should emit deleteSend event when send is deleted", () => {
|
||||
const deleteSpy = jest.fn();
|
||||
component.deleteSend.subscribe(deleteSpy);
|
||||
|
||||
const mockSend = { id: "test-id", name: "Test Send" } as any;
|
||||
component["onDeleteSend"](mockSend);
|
||||
|
||||
expect(deleteSpy).toHaveBeenCalledWith(mockSend);
|
||||
});
|
||||
});
|
||||
105
libs/tools/send/send-ui/src/send-list/send-list.component.ts
Normal file
105
libs/tools/send/send-ui/src/send-list/send-list.component.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
} from "@angular/core";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { NoResults, NoSendsIcon } from "@bitwarden/assets/svg";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import {
|
||||
ButtonModule,
|
||||
NoItemsModule,
|
||||
SpinnerComponent,
|
||||
TableDataSource,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { SendSearchComponent } from "../send-search/send-search.component";
|
||||
import { SendTableComponent } from "../send-table/send-table.component";
|
||||
|
||||
/** A state of the Send list UI. */
|
||||
export const SendListState = Object.freeze({
|
||||
/** No Sends exist at all (File or Text). */
|
||||
Empty: "Empty",
|
||||
/** Sends exist, but none match the current Side Nav Filter (File or Text). */
|
||||
NoResults: "NoResults",
|
||||
} as const);
|
||||
|
||||
/** A state of the Send list UI. */
|
||||
export type SendListState = (typeof SendListState)[keyof typeof SendListState];
|
||||
|
||||
/**
|
||||
* A container component for displaying the Send list with search, table, and empty states.
|
||||
* Handles the presentation layer while delegating data management to services.
|
||||
*/
|
||||
@Component({
|
||||
selector: "tools-send-list",
|
||||
templateUrl: "./send-list.component.html",
|
||||
imports: [
|
||||
CommonModule,
|
||||
JslibModule,
|
||||
ButtonModule,
|
||||
NoItemsModule,
|
||||
SpinnerComponent,
|
||||
SendSearchComponent,
|
||||
SendTableComponent,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SendListComponent {
|
||||
protected readonly noItemIcon = NoSendsIcon;
|
||||
protected readonly noResultsIcon = NoResults;
|
||||
protected readonly sendListState = SendListState;
|
||||
|
||||
private i18nService = inject(I18nService);
|
||||
|
||||
readonly sends = input.required<SendView[]>();
|
||||
readonly loading = input<boolean>(false);
|
||||
readonly disableSend = input<boolean>(false);
|
||||
readonly listState = input<SendListState | null>(null);
|
||||
readonly searchText = input<string>("");
|
||||
|
||||
protected readonly showSearchBar = computed(
|
||||
() => this.sends().length > 0 || this.searchText().length > 0,
|
||||
);
|
||||
|
||||
protected readonly noSearchResults = computed(
|
||||
() => this.showSearchBar() && (this.sends().length === 0 || this.searchText().length > 0),
|
||||
);
|
||||
|
||||
// Reusable data source instance - updated reactively when sends change
|
||||
protected readonly dataSource = new TableDataSource<SendView>();
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.dataSource.data = this.sends();
|
||||
});
|
||||
}
|
||||
|
||||
readonly editSend = output<SendView>();
|
||||
readonly copySend = output<SendView>();
|
||||
readonly removePassword = output<SendView>();
|
||||
readonly deleteSend = output<SendView>();
|
||||
|
||||
protected onEditSend(send: SendView): void {
|
||||
this.editSend.emit(send);
|
||||
}
|
||||
|
||||
protected onCopySend(send: SendView): void {
|
||||
this.copySend.emit(send);
|
||||
}
|
||||
|
||||
protected onRemovePassword(send: SendView): void {
|
||||
this.removePassword.emit(send);
|
||||
}
|
||||
|
||||
protected onDeleteSend(send: SendView): void {
|
||||
this.deleteSend.emit(send);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user