mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 13:23:34 +00:00
[PM-12571][PM-13807] Add/Edit Folder Dialog (#12487)
* move `add-edit-folder` component to `angular/vault/components` so it can be consumed by other platforms * add edit/add folder copy to web app copy * add extension refresh folder dialog to individual vault * adding folder delete message to the web * add deletion result for add/edit folder dialog * allow editing folder from web * fix strict types for changed files * update tests * remove border class so hover state shows * revert changes to new-item-dropdown-v2 * migrate `AddEditFolderDialogComponent` to `libs/vault` package * add Created enum type * add static open method for folder dialog * add fullName to `FolderFilter` type * save the full name of a folder before splitting it into parts * use the full name of the folder filter when available * use a shallow copy to edit the folder's full name --------- Co-authored-by: SmithThe4th <gsmith@bitwarden.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
<form [formGroup]="folderForm" [bitSubmit]="submit" id="add-edit-folder">
|
||||
<bit-dialog>
|
||||
<span bitDialogTitle>
|
||||
{{ (variant === "add" ? "newFolder" : "editFolder") | i18n }}
|
||||
</span>
|
||||
<div bitDialogContent>
|
||||
<bit-form-field disableMargin>
|
||||
<bit-label>{{ "folderName" | i18n }}</bit-label>
|
||||
<input bitInput id="folderName" formControlName="name" type="text" />
|
||||
<bit-hint>
|
||||
{{ "folderHintText" | i18n }}
|
||||
</bit-hint>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div bitDialogFooter class="tw-flex tw-gap-2 tw-w-full">
|
||||
<button
|
||||
#submitBtn
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
form="add-edit-folder"
|
||||
[disabled]="folderForm.invalid"
|
||||
>
|
||||
{{ "save" | i18n }}
|
||||
</button>
|
||||
<button bitButton bitDialogClose buttonType="secondary" type="button">
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
*ngIf="variant === 'edit'"
|
||||
type="button"
|
||||
buttonType="danger"
|
||||
class="tw-ml-auto"
|
||||
bitIconButton="bwi-trash"
|
||||
[appA11yTitle]="'deleteFolder' | i18n"
|
||||
[bitAction]="deleteFolder"
|
||||
></button>
|
||||
</div>
|
||||
</bit-dialog>
|
||||
</form>
|
||||
@@ -0,0 +1,184 @@
|
||||
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { Folder } from "@bitwarden/common/vault/models/domain/folder";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import {
|
||||
AddEditFolderDialogComponent,
|
||||
AddEditFolderDialogData,
|
||||
AddEditFolderDialogResult,
|
||||
} from "./add-edit-folder-dialog.component";
|
||||
|
||||
describe("AddEditFolderDialogComponent", () => {
|
||||
let component: AddEditFolderDialogComponent;
|
||||
let fixture: ComponentFixture<AddEditFolderDialogComponent>;
|
||||
|
||||
const dialogData = {} as AddEditFolderDialogData;
|
||||
const folder = new Folder();
|
||||
const encrypt = jest.fn().mockResolvedValue(folder);
|
||||
const save = jest.fn().mockResolvedValue(null);
|
||||
const deleteFolder = jest.fn().mockResolvedValue(null);
|
||||
const openSimpleDialog = jest.fn().mockResolvedValue(true);
|
||||
const getUserKeyWithLegacySupport = jest.fn().mockResolvedValue("");
|
||||
const error = jest.fn();
|
||||
const close = jest.fn();
|
||||
const showToast = jest.fn();
|
||||
|
||||
const dialogRef = {
|
||||
close,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
encrypt.mockClear();
|
||||
save.mockClear();
|
||||
deleteFolder.mockClear();
|
||||
error.mockClear();
|
||||
close.mockClear();
|
||||
showToast.mockClear();
|
||||
|
||||
const userId = "" as UserId;
|
||||
const accountInfo: AccountInfo = {
|
||||
email: "",
|
||||
emailVerified: true,
|
||||
name: undefined,
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AddEditFolderDialogComponent, NoopAnimationsModule],
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
{ provide: FolderService, useValue: { encrypt } },
|
||||
{ provide: FolderApiServiceAbstraction, useValue: { save, delete: deleteFolder } },
|
||||
{
|
||||
provide: AccountService,
|
||||
useValue: { activeAccount$: new BehaviorSubject({ id: userId, ...accountInfo }) },
|
||||
},
|
||||
{
|
||||
provide: KeyService,
|
||||
useValue: {
|
||||
getUserKeyWithLegacySupport,
|
||||
},
|
||||
},
|
||||
{ provide: LogService, useValue: { error } },
|
||||
{ provide: ToastService, useValue: { showToast } },
|
||||
{ provide: DIALOG_DATA, useValue: dialogData },
|
||||
{ provide: DialogRef, useValue: dialogRef },
|
||||
],
|
||||
})
|
||||
.overrideProvider(DialogService, { useValue: { openSimpleDialog } })
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AddEditFolderDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe("new folder", () => {
|
||||
it("requires a folder name", async () => {
|
||||
await component.submit();
|
||||
|
||||
expect(encrypt).not.toHaveBeenCalled();
|
||||
|
||||
component.folderForm.controls.name.setValue("New Folder");
|
||||
|
||||
await component.submit();
|
||||
|
||||
expect(encrypt).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits a new folder view", async () => {
|
||||
component.folderForm.controls.name.setValue("New Folder");
|
||||
|
||||
await component.submit();
|
||||
|
||||
const newFolder = new FolderView();
|
||||
newFolder.name = "New Folder";
|
||||
|
||||
expect(encrypt).toHaveBeenCalledWith(newFolder, "");
|
||||
expect(save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows success toast after saving", async () => {
|
||||
component.folderForm.controls.name.setValue("New Folder");
|
||||
|
||||
await component.submit();
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith({
|
||||
message: "editedFolder",
|
||||
title: "",
|
||||
variant: "success",
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the dialog after saving", async () => {
|
||||
component.folderForm.controls.name.setValue("New Folder");
|
||||
|
||||
await component.submit();
|
||||
|
||||
expect(close).toHaveBeenCalledWith(AddEditFolderDialogResult.Created);
|
||||
});
|
||||
|
||||
it("logs error if saving fails", async () => {
|
||||
const errorObj = new Error("Failed to save folder");
|
||||
save.mockRejectedValue(errorObj);
|
||||
|
||||
component.folderForm.controls.name.setValue("New Folder");
|
||||
|
||||
await component.submit();
|
||||
|
||||
expect(error).toHaveBeenCalledWith(errorObj);
|
||||
});
|
||||
});
|
||||
|
||||
describe("editing folder", () => {
|
||||
const folderView = new FolderView();
|
||||
folderView.id = "1";
|
||||
folderView.name = "Folder 1";
|
||||
|
||||
beforeEach(() => {
|
||||
dialogData.editFolderConfig = { folder: folderView };
|
||||
|
||||
component.ngOnInit();
|
||||
});
|
||||
|
||||
it("populates form with folder name", () => {
|
||||
expect(component.folderForm.controls.name.value).toBe("Folder 1");
|
||||
});
|
||||
|
||||
it("submits the updated folder", async () => {
|
||||
component.folderForm.controls.name.setValue("Edited Folder");
|
||||
await component.submit();
|
||||
|
||||
expect(encrypt).toHaveBeenCalledWith(
|
||||
{
|
||||
...dialogData.editFolderConfig!.folder,
|
||||
name: "Edited Folder",
|
||||
},
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes the folder", async () => {
|
||||
await component.deleteFolder();
|
||||
|
||||
expect(deleteFolder).toHaveBeenCalledWith(folderView.id, "");
|
||||
expect(showToast).toHaveBeenCalledWith({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: "deletedFolder",
|
||||
});
|
||||
expect(close).toHaveBeenCalledWith(AddEditFolderDialogResult.Deleted);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
DestroyRef,
|
||||
inject,
|
||||
Inject,
|
||||
OnInit,
|
||||
ViewChild,
|
||||
} from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
BitSubmitDirective,
|
||||
ButtonComponent,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
export enum AddEditFolderDialogResult {
|
||||
Created = "created",
|
||||
Deleted = "deleted",
|
||||
}
|
||||
|
||||
export type AddEditFolderDialogData = {
|
||||
/** When provided, dialog will display edit folder variant */
|
||||
editFolderConfig?: { folder: FolderView };
|
||||
};
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "vault-add-edit-folder-dialog",
|
||||
templateUrl: "./add-edit-folder-dialog.component.html",
|
||||
imports: [
|
||||
CommonModule,
|
||||
JslibModule,
|
||||
DialogModule,
|
||||
ButtonModule,
|
||||
FormFieldModule,
|
||||
ReactiveFormsModule,
|
||||
IconButtonModule,
|
||||
AsyncActionsModule,
|
||||
],
|
||||
})
|
||||
export class AddEditFolderDialogComponent implements AfterViewInit, OnInit {
|
||||
@ViewChild(BitSubmitDirective) private bitSubmit?: BitSubmitDirective;
|
||||
@ViewChild("submitBtn") private submitBtn?: ButtonComponent;
|
||||
|
||||
folder: FolderView = new FolderView();
|
||||
|
||||
variant: "add" | "edit" = "add";
|
||||
|
||||
folderForm = this.formBuilder.group({
|
||||
name: ["", Validators.required],
|
||||
});
|
||||
|
||||
private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id));
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor(
|
||||
private formBuilder: FormBuilder,
|
||||
private folderService: FolderService,
|
||||
private folderApiService: FolderApiServiceAbstraction,
|
||||
private accountService: AccountService,
|
||||
private keyService: KeyService,
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
private logService: LogService,
|
||||
private dialogService: DialogService,
|
||||
private dialogRef: DialogRef<AddEditFolderDialogResult>,
|
||||
@Inject(DIALOG_DATA) private data?: AddEditFolderDialogData,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.data?.editFolderConfig) {
|
||||
this.variant = "edit";
|
||||
this.folderForm.controls.name.setValue(this.data.editFolderConfig.folder.name);
|
||||
this.folder = this.data.editFolderConfig.folder;
|
||||
} else {
|
||||
// Create a new folder view
|
||||
this.folder = new FolderView();
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.bitSubmit?.loading$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((loading) => {
|
||||
if (!this.submitBtn) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitBtn.loading = loading;
|
||||
});
|
||||
}
|
||||
|
||||
/** Submit the new folder */
|
||||
submit = async () => {
|
||||
if (this.folderForm.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.folder.name = this.folderForm.controls.name.value ?? "";
|
||||
|
||||
try {
|
||||
const activeUserId = await firstValueFrom(this.activeUserId$);
|
||||
const userKey = await this.keyService.getUserKeyWithLegacySupport(activeUserId!);
|
||||
const folder = await this.folderService.encrypt(this.folder, userKey);
|
||||
await this.folderApiService.save(folder, activeUserId!);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("editedFolder"),
|
||||
});
|
||||
|
||||
this.close(AddEditFolderDialogResult.Created);
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
/** Delete the folder with when the user provides a confirmation */
|
||||
deleteFolder = async () => {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "deleteFolder" },
|
||||
content: { key: "deleteFolderPermanently" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const activeUserId = await firstValueFrom(this.activeUserId$);
|
||||
await this.folderApiService.delete(this.folder.id, activeUserId!);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("deletedFolder"),
|
||||
});
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
|
||||
this.close(AddEditFolderDialogResult.Deleted);
|
||||
};
|
||||
|
||||
/** Close the dialog */
|
||||
private close(result: AddEditFolderDialogResult) {
|
||||
this.dialogRef.close(result);
|
||||
}
|
||||
|
||||
static open(dialogService: DialogService, data?: AddEditFolderDialogData) {
|
||||
return dialogService.open<AddEditFolderDialogResult, AddEditFolderDialogData>(
|
||||
AddEditFolderDialogComponent,
|
||||
{ data },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export { PasswordHistoryViewComponent } from "./components/password-history-view
|
||||
export { NewDeviceVerificationNoticePageOneComponent } from "./components/new-device-verification-notice/new-device-verification-notice-page-one.component";
|
||||
export { NewDeviceVerificationNoticePageTwoComponent } from "./components/new-device-verification-notice/new-device-verification-notice-page-two.component";
|
||||
export { DecryptionFailureDialogComponent } from "./components/decryption-failure-dialog/decryption-failure-dialog.component";
|
||||
export * from "./components/add-edit-folder-dialog/add-edit-folder-dialog.component";
|
||||
|
||||
export * as VaultIcons from "./icons";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user