[PM-10045] - SendCreated page (#10331)

* WIP - send created component

* WIP - send created page

* finalize send created component and specs

* add extra padding

* undo browser extension refresh

* fix tests

* fix error
This commit is contained in:
Jordan Aasen 2024-08-15 07:59:00 -07:00 committed by GitHub
parent 8fbdd8d22e
commit 199ac3de45
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 317 additions and 5 deletions

View File

@ -2298,6 +2298,24 @@
"message": "Send created",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"createdSendSuccessfully": {
"message": "Send created successfully!",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"sendAvailability": {
"message": "The Send will be available to anyone with the link for the next $DAYS$ days.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.",
"placeholders": {
"days": {
"content": "$1",
"example": "5"
}
}
},
"sendLinkCopied": {
"message": "Send link copied",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"editedSend": {
"message": "Send saved",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
@ -3813,6 +3831,9 @@
"copySuccessful": {
"message": "Copy Successful"
},
"copyLink": {
"message": "Copy link"
},
"upload": {
"message": "Upload"
},

View File

@ -54,6 +54,7 @@ import { PasswordGeneratorHistoryComponent } from "../tools/popup/generator/pass
import { SendAddEditComponent } from "../tools/popup/send/send-add-edit.component";
import { SendGroupingsComponent } from "../tools/popup/send/send-groupings.component";
import { SendTypeComponent } from "../tools/popup/send/send-type.component";
import { SendCreatedComponent } from "../tools/popup/send-v2/send-created/send-created.component";
import { SendV2Component } from "../tools/popup/send-v2/send-v2.component";
import { AboutPageV2Component } from "../tools/popup/settings/about-page/about-page-v2.component";
import { AboutPageComponent } from "../tools/popup/settings/about-page/about-page.component";
@ -370,6 +371,12 @@ const routes: Routes = [
canActivate: [authGuard],
data: { state: "edit-send" },
},
{
path: "send-created",
component: SendCreatedComponent,
canActivate: [authGuard],
data: { state: "send" },
},
{
path: "update-temp-password",
component: UpdateTempPasswordComponent,

View File

@ -0,0 +1,28 @@
<main class="tw-top-0">
<popup-page>
<popup-header slot="header" [pageTitle]="'createdSend' | i18n" showBackButton>
<ng-container slot="end">
<app-pop-out></app-pop-out>
</ng-container>
</popup-header>
<div
class="tw-flex tw-bg-background-alt tw-flex-col tw-justify-center tw-items-center tw-gap-2 tw-h-full tw-px-5"
>
<bit-icon [icon]="sendCreatedIcon"></bit-icon>
<h3 class="tw-font-semibold">{{ "createdSendSuccessfully" | i18n }}</h3>
<p class="tw-text-center">{{ "sendAvailability" | i18n: daysAvailable }}</p>
<button bitButton type="button" buttonType="primary" (click)="copyLink()">
<b>{{ "copyLink" | i18n }}</b>
</button>
</div>
<popup-footer slot="footer">
<button bitButton type="button" buttonType="primary" (click)="copyLink()">
<b>{{ "copyLink" | i18n }}</b>
</button>
<button bitButton type="button" buttonType="secondary" (click)="close()">
{{ "close" | i18n }}
</button>
</popup-footer>
</popup-page>
</main>

View File

@ -0,0 +1,140 @@
import { CommonModule, Location } from "@angular/common";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute, RouterLink } from "@angular/router";
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 { 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 { 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 { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { ButtonModule, IconModule, ToastService } from "@bitwarden/components";
import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component";
import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
import { PopupRouterCacheService } from "../../../../platform/popup/view-cache/popup-router-cache.service";
import { SendCreatedComponent } from "./send-created.component";
describe("SendCreatedComponent", () => {
let component: SendCreatedComponent;
let fixture: ComponentFixture<SendCreatedComponent>;
let i18nService: MockProxy<I18nService>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let sendService: MockProxy<SendService>;
let toastService: MockProxy<ToastService>;
let location: MockProxy<Location>;
let activatedRoute: MockProxy<ActivatedRoute>;
let environmentService: MockProxy<EnvironmentService>;
const sendId = "test-send-id";
const deletionDate = new Date();
deletionDate.setDate(deletionDate.getDate() + 7);
const sendView: SendView = {
id: sendId,
deletionDate,
accessId: "abc",
urlB64Key: "123",
} as SendView;
beforeEach(async () => {
i18nService = mock<I18nService>();
platformUtilsService = mock<PlatformUtilsService>();
sendService = mock<SendService>();
toastService = mock<ToastService>();
location = mock<Location>();
activatedRoute = mock<ActivatedRoute>();
environmentService = mock<EnvironmentService>();
Object.defineProperty(environmentService, "environment$", {
configurable: true,
get: () => of(new SelfHostedEnvironment({ webVault: "https://example.com" })),
});
activatedRoute.snapshot = {
queryParamMap: {
get: jest.fn().mockReturnValue(sendId),
},
} as any;
sendService.sendViews$ = of([sendView]);
await TestBed.configureTestingModule({
imports: [
CommonModule,
RouterTestingModule,
JslibModule,
ButtonModule,
IconModule,
PopOutComponent,
PopupHeaderComponent,
PopupPageComponent,
RouterLink,
PopupFooterComponent,
SendCreatedComponent,
],
providers: [
{ provide: I18nService, useValue: i18nService },
{ provide: PlatformUtilsService, useValue: platformUtilsService },
{ provide: SendService, useValue: sendService },
{ provide: ToastService, useValue: toastService },
{ provide: Location, useValue: location },
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: ConfigService, useValue: mock<ConfigService>() },
{ provide: EnvironmentService, useValue: environmentService },
{ provide: PopupRouterCacheService, useValue: mock<PopupRouterCacheService>() },
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(SendCreatedComponent);
component = fixture.componentInstance;
});
it("should create", () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
it("should initialize send and daysAvailable", () => {
fixture.detectChanges();
expect(component["send"]).toBe(sendView);
expect(component["daysAvailable"]).toBe(7);
});
it("should navigate back on close", () => {
fixture.detectChanges();
component.close();
expect(location.back).toHaveBeenCalled();
});
describe("getDaysAvailable", () => {
it("returns the correct number of days", () => {
fixture.detectChanges();
expect(component.getDaysAvailable(sendView)).toBe(7);
});
});
describe("copyLink", () => {
it("should copy link and show toast", async () => {
fixture.detectChanges();
const link = "https://example.com/#/send/abc/123";
await component.copyLink();
expect(platformUtilsService.copyToClipboard).toHaveBeenCalledWith(link);
expect(toastService.showToast).toHaveBeenCalledWith({
variant: "success",
title: null,
message: i18nService.t("sendLinkCopied"),
});
});
});
});

View File

@ -0,0 +1,82 @@
import { CommonModule, Location } from "@angular/common";
import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { ButtonModule, IconModule, ToastService } from "@bitwarden/components";
import { SendCreatedIcon } from "@bitwarden/send-ui";
import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component";
import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
@Component({
selector: "app-send-created",
templateUrl: "./send-created.component.html",
standalone: true,
imports: [
ButtonModule,
CommonModule,
JslibModule,
PopOutComponent,
PopupHeaderComponent,
PopupPageComponent,
RouterLink,
PopupFooterComponent,
IconModule,
],
})
export class SendCreatedComponent {
protected sendCreatedIcon = SendCreatedIcon;
protected send: SendView;
protected daysAvailable = 0;
constructor(
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private sendService: SendService,
private route: ActivatedRoute,
private toastService: ToastService,
private location: Location,
private environmentService: EnvironmentService,
) {
const sendId = this.route.snapshot.queryParamMap.get("sendId");
this.sendService.sendViews$.pipe(takeUntilDestroyed()).subscribe((sendViews) => {
this.send = sendViews.find((s) => s.id === sendId);
if (this.send) {
this.daysAvailable = this.getDaysAvailable(this.send);
}
});
}
getDaysAvailable(send: SendView): number {
const now = new Date().getTime();
return Math.max(0, Math.ceil((send.deletionDate.getTime() - now) / (1000 * 60 * 60 * 24)));
}
close() {
this.location.back();
}
async copyLink() {
if (!this.send || !this.send.accessId || !this.send.urlB64Key) {
return;
}
const env = await firstValueFrom(this.environmentService.environment$);
const link = env.getSendUrl() + this.send.accessId + "/" + this.send.urlB64Key;
this.platformUtilsService.copyToClipboard(link);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("sendLinkCopied"),
});
}
}

View File

@ -4836,6 +4836,9 @@
"copySendLinkOnSave": {
"message": "Copy the link to share this Send to my clipboard upon save."
},
"copyLink": {
"message": "Copy link"
},
"sendLinkLabel": {
"message": "Send link",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."

View File

@ -1,5 +1,8 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { firstValueFrom, of } from "rxjs";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service";
import {
FakeAccountService,
@ -41,6 +44,7 @@ describe("SendService", () => {
const i18nService = mock<I18nService>();
const keyGenerationService = mock<KeyGenerationService>();
const encryptService = mock<EncryptService>();
const environmentService = mock<EnvironmentService>();
let sendStateProvider: SendStateProvider;
let sendService: SendService;
@ -56,6 +60,10 @@ describe("SendService", () => {
accountService = mockAccountServiceWith(mockUserId);
stateProvider = new FakeStateProvider(accountService);
sendStateProvider = new SendStateProvider(stateProvider);
Object.defineProperty(environmentService, "environment$", {
configurable: true,
get: () => of(new SelfHostedEnvironment({ webVault: "https://example.com" })),
});
(window as any).bitwardenContainerService = new ContainerService(cryptoService, encryptService);

View File

@ -1,2 +1,3 @@
export { NoSendsIcon } from "./no-send.icon";
export { ExpiredSendIcon } from "./expired-send.icon";
export { NoSendsIcon } from "./no-send.icon";
export { SendCreatedIcon } from "./send-created.icon";

View File

@ -0,0 +1,16 @@
import { svgIcon } from "@bitwarden/components";
export const SendCreatedIcon = svgIcon`
<svg width="96" height="95" viewBox="0 0 96 95" fill="none" xmlns="http://www.w3.org/2000/svg">
<path class="tw-stroke-text-headers" d="M89.4998 48.3919C89.4998 70.5749 70.9198 88.5573 47.9998 88.5573C46.0374 88.5573 44.1068 88.4257 42.217 88.1707M6.49976 48.3919C6.49976 26.2092 25.08 8.22656 47.9998 8.22656C51.8283 8.22656 55.5353 8.72824 59.0553 9.66744" stroke-linecap="round" stroke-linejoin="round"/>
<path class="tw-stroke-text-headers" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M5.47085 67.8617C2.60335 61.9801 1 55.4075 1 48.4729C1 23.3503 22.0426 2.98438 48 2.98438C52.3355 2.98438 56.534 3.55257 60.5205 4.61618M92.211 32.9993C94.016 37.8295 95 43.0399 95 48.4729C95 73.5956 73.9575 93.9614 48 93.9614C45.7775 93.9614 43.5911 93.8119 41.4508 93.5235" />
<path class="tw-fill-text-headers" d="M20.8242 84.8672C20.8242 84.3149 20.3765 83.8672 19.8242 83.8672C19.2719 83.8672 18.8242 84.3149 18.8242 84.8672H20.8242ZM18.8242 87.2442C18.8242 87.7965 19.2719 88.2442 19.8242 88.2442C20.3765 88.2442 20.8242 87.7965 20.8242 87.2442H18.8242ZM18.8242 84.1908C18.8242 84.7431 19.2719 85.1908 19.8242 85.1908C20.3765 85.1908 20.8242 84.7431 20.8242 84.1908H18.8242ZM20.8242 83.8516C20.8242 83.2993 20.3765 82.8516 19.8242 82.8516C19.2719 82.8516 18.8242 83.2993 18.8242 83.8516H20.8242ZM26.7882 76.042C26.7882 72.0015 23.7427 68.5898 19.8238 68.5898V70.5898C22.4931 70.5898 24.7882 72.9552 24.7882 76.042H26.7882ZM19.8238 68.5898C15.9049 68.5898 12.8594 72.0015 12.8594 76.042H14.8594C14.8594 72.9552 17.1545 70.5898 19.8238 70.5898V68.5898ZM11.5 77.0391H28.1475V75.0391H11.5V77.0391ZM28.1475 77.0391C28.4548 77.0391 28.6475 77.2719 28.6475 77.4908H30.6475C30.6475 76.1062 29.4972 75.0391 28.1475 75.0391V77.0391ZM28.6475 77.4908V90.5469H30.6475V77.4908H28.6475ZM28.6475 90.5469C28.6475 90.7658 28.4548 90.9987 28.1475 90.9987V92.9987C29.4972 92.9987 30.6475 91.9315 30.6475 90.5469H28.6475ZM28.1475 90.9987H11.5V92.9987H28.1475V90.9987ZM11.5 90.9987C11.1928 90.9987 11 90.7658 11 90.5469H9C9 91.9315 10.1504 92.9987 11.5 92.9987V90.9987ZM11 90.5469V77.4908H9V90.5469H11ZM11 77.4908C11 77.2719 11.1928 77.0391 11.5 77.0391V75.0391C10.1504 75.0391 9 76.1062 9 77.4908H11ZM18.8242 84.8672V87.2442H20.8242V84.8672H18.8242ZM20.8242 84.1908V83.8516H18.8242V84.1908H20.8242Z"/>
<path class="tw-stroke-text-headers" d="M36 64L37 63" stroke-width="2" stroke-linecap="round"/>
<path class="tw-stroke-text-headers" d="M29.9998 69.9995L30.9998 68.9995" stroke-width="2" stroke-linecap="round"/>
<path class="tw-stroke-text-headers" d="M40.0174 51.8491L33 48.7544L61.5083 33L56.6108 58.4604L48.4968 55.4359L44.2571 60.2185V53.8888L55.5873 40.8772" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<mask id="path-7-inside-1_752_3985" fill="white">
<path d="M94 17.5C94 26.6127 86.6127 34 77.5 34C68.3873 34 61 26.6127 61 17.5C61 8.3873 68.3873 1 77.5 1C86.6127 1 94 8.3873 94 17.5Z"/>
</mask>
<path style="fill: rgb(var(--color-success-600) / 1)" d="M70.7244 19.4372C70.3435 19.0372 69.7106 19.0218 69.3106 19.4027C68.9107 19.7836 68.8953 20.4166 69.2761 20.8165L70.7244 19.4372ZM74.6431 25.0018L73.919 25.6915C74.1205 25.9031 74.4046 26.016 74.6964 26.0004C74.9882 25.9848 75.2586 25.8424 75.4365 25.6105L74.6431 25.0018ZM69.2761 20.8165L73.919 25.6915L75.3673 24.3122L70.7244 19.4372L69.2761 20.8165ZM75.4365 25.6105L85.7937 12.1105L84.2069 10.8931L73.8497 24.3931L75.4365 25.6105ZM92 17.5C92 25.5081 85.5081 32 77.5 32V36C87.7173 36 96 27.7173 96 17.5H92ZM77.5 32C69.4919 32 63 25.5081 63 17.5H59C59 27.7173 67.2827 36 77.5 36V32ZM63 17.5C63 9.49187 69.4919 3 77.5 3V-1C67.2827 -1 59 7.28273 59 17.5H63ZM77.5 3C85.5081 3 92 9.49187 92 17.5H96C96 7.28273 87.7173 -1 77.5 -1V3Z" mask="url(#path-7-inside-1_752_3985)"/>
</svg>
`;

View File

@ -12,6 +12,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
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 { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import {
ButtonModule,
BadgeModule,
@ -30,6 +31,7 @@ describe("SendListItemsContainerComponent", () => {
let component: SendListItemsContainerComponent;
let fixture: ComponentFixture<SendListItemsContainerComponent>;
let environmentService: MockProxy<EnvironmentService>;
let sendService: MockProxy<SendService>;
const openSimpleDialog = jest.fn();
const showToast = jest.fn();
@ -37,6 +39,8 @@ describe("SendListItemsContainerComponent", () => {
const deleteFn = jest.fn().mockResolvedValue(undefined);
beforeEach(async () => {
sendService = mock<SendService>();
await TestBed.configureTestingModule({
imports: [
CommonModule,
@ -57,6 +61,7 @@ describe("SendListItemsContainerComponent", () => {
{ provide: PlatformUtilsService, useValue: { copyToClipboard } },
{ provide: SendApiService, useValue: { delete: deleteFn } },
{ provide: ToastService, useValue: { showToast } },
{ provide: SendService, useValue: sendService },
],
})
.overrideProvider(DialogService, {
@ -113,10 +118,11 @@ describe("SendListItemsContainerComponent", () => {
it("should copy send link", async () => {
const send = { id: "123", accessId: "abc", urlB64Key: "xyz" } as SendView;
const link = "https://example.com/#/send/abc/xyz";
await component.copySendLink(send);
expect(copyToClipboard).toHaveBeenCalledWith("https://example.com/#/send/abc/xyz");
expect(copyToClipboard).toHaveBeenCalledWith(link);
expect(showToast).toHaveBeenCalledWith({
variant: "success",
title: null,

View File

@ -85,9 +85,9 @@ export class SendListItemsContainerComponent {
}
}
async copySendLink(s: SendView) {
async copySendLink(send: SendView) {
const env = await firstValueFrom(this.environmentService.environment$);
const link = env.getSendUrl() + s.accessId + "/" + s.urlB64Key;
const link = env.getSendUrl() + send.accessId + "/" + send.urlB64Key;
this.platformUtilsService.copyToClipboard(link);
this.toastService.showToast({
variant: "success",