diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index bf2b26a11b..8cbbf48e27 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2090,6 +2090,9 @@ "passwordProtected": { "message": "Password protected" }, + "copyLink": { + "message": "Copy link" + }, "copySendLink": { "message": "Copy Send link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.html b/apps/browser/src/tools/popup/send-v2/send-v2.component.html index 3499f8c32e..52f7c3ed8f 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.html +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.html @@ -8,14 +8,12 @@ -
+
{{ "sendsNoItemsTitle" | i18n }} {{ "sendsNoItemsMessage" | i18n }}
+ diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts new file mode 100644 index 0000000000..d7a302b790 --- /dev/null +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts @@ -0,0 +1,101 @@ +import { CommonModule } from "@angular/common"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { RouterLink } from "@angular/router"; +import { RouterTestingModule } from "@angular/router/testing"; +import { mock } from "jest-mock-extended"; +import { Observable, of } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { ButtonModule, NoItemsModule } from "@bitwarden/components"; +import { + NewSendDropdownComponent, + SendListItemsContainerComponent, + SendListFiltersComponent, +} from "@bitwarden/send-ui"; + +import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component"; +import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; +import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; + +import { SendV2Component } from "./send-v2.component"; + +describe("SendV2Component", () => { + let component: SendV2Component; + let fixture: ComponentFixture; + let sendViews$: Observable; + + beforeEach(async () => { + sendViews$ = of([ + { id: "1", name: "Send A" }, + { id: "2", name: "Send B" }, + ] as SendView[]); + + await TestBed.configureTestingModule({ + imports: [ + CommonModule, + RouterTestingModule, + JslibModule, + NoItemsModule, + ButtonModule, + NoItemsModule, + RouterLink, + NewSendDropdownComponent, + SendListItemsContainerComponent, + SendListFiltersComponent, + PopupPageComponent, + PopupHeaderComponent, + PopOutComponent, + CurrentAccountComponent, + ], + providers: [ + { provide: AccountService, useValue: mock() }, + { provide: AuthService, useValue: mock() }, + { provide: AvatarService, useValue: mock() }, + { + provide: BillingAccountProfileStateService, + useValue: mock(), + }, + { provide: ConfigService, useValue: mock() }, + { provide: EnvironmentService, useValue: mock() }, + { provide: LogService, useValue: mock() }, + { provide: PlatformUtilsService, useValue: mock() }, + { provide: SendApiService, useValue: mock() }, + { provide: SendService, useValue: { sendViews$ } }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SendV2Component); + component = fixture.componentInstance; + + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should sort sends by name on initialization", async () => { + const sortedSends = [ + { id: "1", name: "Send A" }, + { id: "2", name: "Send B" }, + ] as SendView[]; + + await component.ngOnInit(); + + expect(component.sends).toEqual(sortedSends); + }); +}); diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts index ebb014e4fc..6ee5f832be 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts @@ -1,13 +1,17 @@ import { CommonModule } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; import { RouterLink } from "@angular/router"; +import { mergeMap, Subject, takeUntil } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { ButtonModule, NoItemsModule } from "@bitwarden/components"; import { NoSendsIcon, NewSendDropdownComponent, + SendListItemsContainerComponent, SendListFiltersComponent, } from "@bitwarden/send-ui"; @@ -16,10 +20,6 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; -enum SendsListState { - Empty, -} - @Component({ templateUrl: "send-v2.component.html", standalone: true, @@ -34,24 +34,31 @@ enum SendsListState { ButtonModule, RouterLink, NewSendDropdownComponent, + SendListItemsContainerComponent, SendListFiltersComponent, ], }) export class SendV2Component implements OnInit, OnDestroy { sendType = SendType; - /** Visual state of the Sends list */ - protected sendsListState: SendsListState | null = null; + private destroy$ = new Subject(); + + sends: SendView[] = []; protected noItemIcon = NoSendsIcon; - protected SendsListStateEnum = SendsListState; + constructor(protected sendService: SendService) {} - constructor() { - this.sendsListState = SendsListState.Empty; + async ngOnInit() { + this.sendService.sendViews$ + .pipe( + mergeMap(async (sends) => { + this.sends = sends.sort((a, b) => a.name.localeCompare(b.name)); + }), + takeUntil(this.destroy$), + ) + .subscribe(); } - ngOnInit(): void {} - ngOnDestroy(): void {} } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 9f1d15f648..2aed43787e 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -4386,6 +4386,9 @@ "message": "Send link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "copyLink": { + "message": "Copy link" + }, "copySendLink": { "message": "Copy Send link", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." diff --git a/libs/tools/send/send-ui/src/index.ts b/libs/tools/send/send-ui/src/index.ts index c85d646bbd..02326ac222 100644 --- a/libs/tools/send/send-ui/src/index.ts +++ b/libs/tools/send/send-ui/src/index.ts @@ -1,4 +1,5 @@ export * from "./icons"; export * from "./send-form"; export { NewSendDropdownComponent } from "./new-send-dropdown/new-send-dropdown.component"; +export { SendListItemsContainerComponent } from "./send-list-items-container/send-list-items-container.component"; export { SendListFiltersComponent } from "./send-list-filters/send-list-filters.component"; diff --git a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html new file mode 100644 index 0000000000..6a4c6a308e --- /dev/null +++ b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.html @@ -0,0 +1,54 @@ + + +

+ {{ "allSends" | i18n }} +

+ {{ sends.length }} +
+ + + + + + + + + + + +
diff --git a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.spec.ts b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.spec.ts new file mode 100644 index 0000000000..847283ef5e --- /dev/null +++ b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.spec.ts @@ -0,0 +1,126 @@ +import { CommonModule } from "@angular/common"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { RouterTestingModule } from "@angular/router/testing"; +import { MockProxy, mock } from "jest-mock-extended"; +import { of } 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 { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.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 { + ButtonModule, + BadgeModule, + DialogService, + IconButtonModule, + ItemModule, + SectionComponent, + SectionHeaderComponent, + ToastService, + TypographyModule, +} from "@bitwarden/components"; + +import { SendListItemsContainerComponent } from "./send-list-items-container.component"; + +describe("SendListItemsContainerComponent", () => { + let component: SendListItemsContainerComponent; + let fixture: ComponentFixture; + let environmentService: MockProxy; + + const openSimpleDialog = jest.fn(); + const showToast = jest.fn(); + const copyToClipboard = jest.fn().mockImplementation(() => {}); + const deleteFn = jest.fn().mockResolvedValue(undefined); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + CommonModule, + RouterTestingModule, + JslibModule, + ItemModule, + ButtonModule, + BadgeModule, + IconButtonModule, + SectionComponent, + SectionHeaderComponent, + TypographyModule, + ], + providers: [ + { provide: EnvironmentService, useValue: environmentService }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: LogService, useValue: mock() }, + { provide: PlatformUtilsService, useValue: { copyToClipboard } }, + { provide: SendApiService, useValue: { delete: deleteFn } }, + { provide: ToastService, useValue: { showToast } }, + ], + }) + .overrideProvider(DialogService, { + useValue: { + openSimpleDialog, + }, + }) + .compileComponents(); + + environmentService = mock(); + Object.defineProperty(environmentService, "environment$", { + configurable: true, + get: () => of(new SelfHostedEnvironment({ webVault: "https://example.com" })), + }); + + deleteFn.mockClear(); + showToast.mockClear(); + openSimpleDialog.mockClear(); + copyToClipboard.mockClear(); + + fixture = TestBed.createComponent(SendListItemsContainerComponent); + component = fixture.componentInstance; + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should delete a send", async () => { + openSimpleDialog.mockResolvedValue(true); + const send = { id: "123", accessId: "abc", urlB64Key: "xyz" } as SendView; + + await component.deleteSend(send); + + expect(openSimpleDialog).toHaveBeenCalled(); + expect(deleteFn).toHaveBeenCalledWith(send.id); + expect(showToast).toHaveBeenCalledWith({ + variant: "success", + title: null, + message: "deletedSend", + }); + }); + + it("should handle delete send cancellation", async () => { + const send = { id: "123", accessId: "abc", urlB64Key: "xyz" } as SendView; + openSimpleDialog.mockResolvedValue(false); + + await component.deleteSend(send); + + expect(openSimpleDialog).toHaveBeenCalled(); + expect(deleteFn).not.toHaveBeenCalled(); + expect(showToast).not.toHaveBeenCalled(); + }); + + it("should copy send link", async () => { + const send = { id: "123", accessId: "abc", urlB64Key: "xyz" } as SendView; + + await component.copySendLink(send); + + expect(copyToClipboard).toHaveBeenCalledWith("https://example.com/#/send/abc/xyz"); + expect(showToast).toHaveBeenCalledWith({ + variant: "success", + title: null, + message: "valueCopied", + }); + }); +}); diff --git a/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts new file mode 100644 index 0000000000..ef7232e97a --- /dev/null +++ b/libs/tools/send/send-ui/src/send-list-items-container/send-list-items-container.component.ts @@ -0,0 +1,95 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input } from "@angular/core"; +import { RouterLink } from "@angular/router"; +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 { 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 { + BadgeModule, + ButtonModule, + DialogService, + IconButtonModule, + ItemModule, + SectionComponent, + SectionHeaderComponent, + ToastService, + TypographyModule, +} from "@bitwarden/components"; + +@Component({ + imports: [ + CommonModule, + ItemModule, + ButtonModule, + BadgeModule, + IconButtonModule, + SectionComponent, + TypographyModule, + JslibModule, + SectionHeaderComponent, + RouterLink, + ], + selector: "app-send-list-items-container", + templateUrl: "send-list-items-container.component.html", + standalone: true, +}) +export class SendListItemsContainerComponent { + sendType = SendType; + /** + * The list of sends to display. + */ + @Input() + sends: SendView[] = []; + + constructor( + protected dialogService: DialogService, + protected environmentService: EnvironmentService, + protected i18nService: I18nService, + protected logService: LogService, + protected platformUtilsService: PlatformUtilsService, + protected sendApiService: SendApiService, + protected toastService: ToastService, + ) {} + + async deleteSend(s: SendView): Promise { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "deleteSend" }, + content: { key: "deleteSendConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return false; + } + + await this.sendApiService.delete(s.id); + + try { + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("deletedSend"), + }); + } catch (e) { + this.logService.error(e); + } + } + + async copySendLink(s: SendView) { + const env = await firstValueFrom(this.environmentService.environment$); + const link = env.getSendUrl() + s.accessId + "/" + s.urlB64Key; + this.platformUtilsService.copyToClipboard(link); + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("valueCopied", this.i18nService.t("sendLink")), + }); + } +}