1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 07:43:35 +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:
Nick Krantz
2025-02-04 15:13:13 -06:00
committed by GitHub
parent a9f24b6d24
commit aa024b419c
11 changed files with 86 additions and 55 deletions

View File

@@ -1,41 +0,0 @@
<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-border-0 tw-ml-auto"
bitIconButton="bwi-trash"
[appA11yTitle]="'deleteFolder' | i18n"
[bitAction]="deleteFolder"
></button>
</div>
</bit-dialog>
</form>

View File

@@ -1,182 +0,0 @@
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,
} 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: null,
variant: "success",
});
});
it("closes the dialog after saving", async () => {
component.folderForm.controls.name.setValue("New Folder");
await component.submit();
expect(close).toHaveBeenCalled();
});
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: null,
message: "deletedFolder",
});
});
});
});

View File

@@ -1,166 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
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 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;
variant: "add" | "edit";
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,
@Inject(DIALOG_DATA) private data?: AddEditFolderDialogData,
) {}
ngOnInit(): void {
this.variant = this.data?.editFolderConfig ? "edit" : "add";
if (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: null,
message: this.i18nService.t("editedFolder"),
});
this.close();
} 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: null,
message: this.i18nService.t("deletedFolder"),
});
} catch (e) {
this.logService.error(e);
}
this.close();
};
/** Close the dialog */
private close() {
this.dialogRef.close();
}
}

View File

@@ -11,11 +11,11 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums";
import { ButtonModule, DialogService, MenuModule, NoItemsModule } from "@bitwarden/components";
import { AddEditFolderDialogComponent } from "@bitwarden/vault";
import { BrowserApi } from "../../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
import { AddEditQueryParams } from "../add-edit/add-edit-v2.component";
import { AddEditFolderDialogComponent } from "../add-edit-folder-dialog/add-edit-folder-dialog.component";
export interface NewItemInitialValues {
folderId?: string;
@@ -72,6 +72,6 @@ export class NewItemDropdownV2Component implements OnInit {
}
openFolderDialog() {
this.dialogService.open(AddEditFolderDialogComponent);
AddEditFolderDialogComponent.open(this.dialogService);
}
}

View File

@@ -14,10 +14,10 @@ import { UserId } from "@bitwarden/common/types/guid";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { DialogService } from "@bitwarden/components";
import { AddEditFolderDialogComponent } from "@bitwarden/vault";
import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { AddEditFolderDialogComponent } from "../components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component";
import { FoldersV2Component } from "./folders-v2.component";
@@ -27,8 +27,8 @@ import { FoldersV2Component } from "./folders-v2.component";
template: `<ng-content></ng-content>`,
})
class MockPopupHeaderComponent {
@Input() pageTitle: string;
@Input() backAction: () => void;
@Input() pageTitle: string = "";
@Input() backAction: () => void = () => {};
}
@Component({
@@ -37,14 +37,15 @@ class MockPopupHeaderComponent {
template: `<ng-content></ng-content>`,
})
class MockPopupFooterComponent {
@Input() pageTitle: string;
@Input() pageTitle: string = "";
}
describe("FoldersV2Component", () => {
let component: FoldersV2Component;
let fixture: ComponentFixture<FoldersV2Component>;
const folderViews$ = new BehaviorSubject<FolderView[]>([]);
const open = jest.fn();
const open = jest.spyOn(AddEditFolderDialogComponent, "open");
const mockDialogService = { open: jest.fn() };
beforeEach(async () => {
open.mockClear();
@@ -68,7 +69,7 @@ describe("FoldersV2Component", () => {
imports: [MockPopupHeaderComponent, MockPopupFooterComponent],
},
})
.overrideProvider(DialogService, { useValue: { open } })
.overrideProvider(DialogService, { useValue: mockDialogService })
.compileComponents();
fixture = TestBed.createComponent(FoldersV2Component);
@@ -101,9 +102,7 @@ describe("FoldersV2Component", () => {
editButton.triggerEventHandler("click");
expect(open).toHaveBeenCalledWith(AddEditFolderDialogComponent, {
data: { editFolderConfig: { folder } },
});
expect(open).toHaveBeenCalledWith(mockDialogService, { editFolderConfig: { folder } });
});
it("opens add dialog for new folder when there are no folders", () => {
@@ -114,6 +113,6 @@ describe("FoldersV2Component", () => {
addButton.triggerEventHandler("click");
expect(open).toHaveBeenCalledWith(AddEditFolderDialogComponent, { data: {} });
expect(open).toHaveBeenCalledWith(mockDialogService, {});
});
});

View File

@@ -13,7 +13,7 @@ import {
DialogService,
IconButtonModule,
} from "@bitwarden/components";
import { VaultIcons } from "@bitwarden/vault";
import { AddEditFolderDialogComponent, VaultIcons } from "@bitwarden/vault";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
@@ -27,10 +27,6 @@ import { NoItemsModule } from "../../../../../../libs/components/src/no-items/no
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 {
AddEditFolderDialogComponent,
AddEditFolderDialogData,
} from "../components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component";
@Component({
standalone: true,
@@ -78,8 +74,6 @@ export class FoldersV2Component {
// If a folder is provided, the edit variant should be shown
const editFolderConfig = folder ? { folder } : undefined;
this.dialogService.open<unknown, AddEditFolderDialogData>(AddEditFolderDialogComponent, {
data: { editFolderConfig },
});
AddEditFolderDialogComponent.open(this.dialogService, { editFolderConfig });
}
}

View File

@@ -274,6 +274,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
folderCopy.id = f.id;
folderCopy.revisionDate = f.revisionDate;
folderCopy.icon = "bwi-folder";
folderCopy.fullName = f.name; // save full folder name before separating it into parts
const parts = f.name != null ? f.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
ServiceUtils.nestedTraverse(nodes, 0, parts, folderCopy, null, NestingDelimiter);
});

View File

@@ -12,5 +12,13 @@ export type CipherTypeFilter = ITreeNodeObject & { type: CipherStatus; icon: str
export type CollectionFilter = CollectionAdminView & {
icon: string;
};
export type FolderFilter = FolderView & { icon: string };
export type FolderFilter = FolderView & {
icon: string;
/**
* Full folder name.
*
* Used for when the folder `name` property is be separated into parts.
*/
fullName?: string;
};
export type OrganizationFilter = Organization & { icon: string; hideOptions?: boolean };

View File

@@ -77,6 +77,8 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { DialogService, Icons, ToastService } from "@bitwarden/components";
import {
AddEditFolderDialogComponent,
AddEditFolderDialogResult,
CipherFormConfig,
CollectionAssignmentResult,
DecryptionFailureDialogComponent,
@@ -118,7 +120,6 @@ import {
BulkMoveDialogResult,
openBulkMoveDialog,
} from "./bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component";
import { FolderAddEditDialogResult, openFolderAddEditDialog } from "./folder-add-edit.component";
import { VaultBannersComponent } from "./vault-banners/vault-banners.component";
import { VaultFilterComponent } from "./vault-filter/components/vault-filter.component";
import { VaultFilterService } from "./vault-filter/services/abstractions/vault-filter.service";
@@ -607,20 +608,24 @@ export class VaultComponent implements OnInit, OnDestroy {
await this.filterComponent.filters?.organizationFilter?.action(orgNode);
}
addFolder = async (): Promise<void> => {
openFolderAddEditDialog(this.dialogService);
addFolder = (): void => {
AddEditFolderDialogComponent.open(this.dialogService);
};
editFolder = async (folder: FolderFilter): Promise<void> => {
const dialog = openFolderAddEditDialog(this.dialogService, {
data: {
folderId: folder.id,
const dialogRef = AddEditFolderDialogComponent.open(this.dialogService, {
editFolderConfig: {
// Shallow copy is used so the original folder object is not modified
folder: {
...folder,
name: folder.fullName ?? folder.name, // If the filter has a fullName populated, use that as the editable name
},
},
});
const result = await lastValueFrom(dialog.closed);
const result = await lastValueFrom(dialogRef.closed);
if (result === FolderAddEditDialogResult.Deleted) {
if (result === AddEditFolderDialogResult.Deleted) {
await this.router.navigate([], {
queryParams: { folderId: null },
queryParamsHandling: "merge",

View File

@@ -485,6 +485,18 @@
"editFolder": {
"message": "Edit folder"
},
"newFolder": {
"message": "New folder"
},
"folderName": {
"message": "Folder name"
},
"folderHintText": {
"message": "Nest a folder by adding the parent folder's name followed by a “/”. Example: Social/Forums"
},
"deleteFolderPermanently": {
"message": "Are you sure you want to permanently delete this folder?"
},
"baseDomain": {
"message": "Base domain",
"description": "Domain name. Example: website.com"