1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-02-16 01:21:48 +01: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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 86 additions and 55 deletions

View File

@ -11,11 +11,11 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
import { ButtonModule, DialogService, MenuModule, NoItemsModule } from "@bitwarden/components"; import { ButtonModule, DialogService, MenuModule, NoItemsModule } from "@bitwarden/components";
import { AddEditFolderDialogComponent } from "@bitwarden/vault";
import { BrowserApi } from "../../../../../platform/browser/browser-api"; import { BrowserApi } from "../../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils"; import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; import { AddEditQueryParams } from "../add-edit/add-edit-v2.component";
import { AddEditFolderDialogComponent } from "../add-edit-folder-dialog/add-edit-folder-dialog.component";
export interface NewItemInitialValues { export interface NewItemInitialValues {
folderId?: string; folderId?: string;
@ -72,6 +72,6 @@ export class NewItemDropdownV2Component implements OnInit {
} }
openFolderDialog() { 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 { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { AddEditFolderDialogComponent } from "@bitwarden/vault";
import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component"; import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.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"; import { FoldersV2Component } from "./folders-v2.component";
@ -27,8 +27,8 @@ import { FoldersV2Component } from "./folders-v2.component";
template: `<ng-content></ng-content>`, template: `<ng-content></ng-content>`,
}) })
class MockPopupHeaderComponent { class MockPopupHeaderComponent {
@Input() pageTitle: string; @Input() pageTitle: string = "";
@Input() backAction: () => void; @Input() backAction: () => void = () => {};
} }
@Component({ @Component({
@ -37,14 +37,15 @@ class MockPopupHeaderComponent {
template: `<ng-content></ng-content>`, template: `<ng-content></ng-content>`,
}) })
class MockPopupFooterComponent { class MockPopupFooterComponent {
@Input() pageTitle: string; @Input() pageTitle: string = "";
} }
describe("FoldersV2Component", () => { describe("FoldersV2Component", () => {
let component: FoldersV2Component; let component: FoldersV2Component;
let fixture: ComponentFixture<FoldersV2Component>; let fixture: ComponentFixture<FoldersV2Component>;
const folderViews$ = new BehaviorSubject<FolderView[]>([]); const folderViews$ = new BehaviorSubject<FolderView[]>([]);
const open = jest.fn(); const open = jest.spyOn(AddEditFolderDialogComponent, "open");
const mockDialogService = { open: jest.fn() };
beforeEach(async () => { beforeEach(async () => {
open.mockClear(); open.mockClear();
@ -68,7 +69,7 @@ describe("FoldersV2Component", () => {
imports: [MockPopupHeaderComponent, MockPopupFooterComponent], imports: [MockPopupHeaderComponent, MockPopupFooterComponent],
}, },
}) })
.overrideProvider(DialogService, { useValue: { open } }) .overrideProvider(DialogService, { useValue: mockDialogService })
.compileComponents(); .compileComponents();
fixture = TestBed.createComponent(FoldersV2Component); fixture = TestBed.createComponent(FoldersV2Component);
@ -101,9 +102,7 @@ describe("FoldersV2Component", () => {
editButton.triggerEventHandler("click"); editButton.triggerEventHandler("click");
expect(open).toHaveBeenCalledWith(AddEditFolderDialogComponent, { expect(open).toHaveBeenCalledWith(mockDialogService, { editFolderConfig: { folder } });
data: { editFolderConfig: { folder } },
});
}); });
it("opens add dialog for new folder when there are no folders", () => { it("opens add dialog for new folder when there are no folders", () => {
@ -114,6 +113,6 @@ describe("FoldersV2Component", () => {
addButton.triggerEventHandler("click"); addButton.triggerEventHandler("click");
expect(open).toHaveBeenCalledWith(AddEditFolderDialogComponent, { data: {} }); expect(open).toHaveBeenCalledWith(mockDialogService, {});
}); });
}); });

View File

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

View File

@ -274,6 +274,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
folderCopy.id = f.id; folderCopy.id = f.id;
folderCopy.revisionDate = f.revisionDate; folderCopy.revisionDate = f.revisionDate;
folderCopy.icon = "bwi-folder"; 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) : []; const parts = f.name != null ? f.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
ServiceUtils.nestedTraverse(nodes, 0, parts, folderCopy, null, 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 & { export type CollectionFilter = CollectionAdminView & {
icon: string; 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 }; 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 { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { DialogService, Icons, ToastService } from "@bitwarden/components"; import { DialogService, Icons, ToastService } from "@bitwarden/components";
import { import {
AddEditFolderDialogComponent,
AddEditFolderDialogResult,
CipherFormConfig, CipherFormConfig,
CollectionAssignmentResult, CollectionAssignmentResult,
DecryptionFailureDialogComponent, DecryptionFailureDialogComponent,
@ -118,7 +120,6 @@ import {
BulkMoveDialogResult, BulkMoveDialogResult,
openBulkMoveDialog, openBulkMoveDialog,
} from "./bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component"; } 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 { VaultBannersComponent } from "./vault-banners/vault-banners.component";
import { VaultFilterComponent } from "./vault-filter/components/vault-filter.component"; import { VaultFilterComponent } from "./vault-filter/components/vault-filter.component";
import { VaultFilterService } from "./vault-filter/services/abstractions/vault-filter.service"; 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); await this.filterComponent.filters?.organizationFilter?.action(orgNode);
} }
addFolder = async (): Promise<void> => { addFolder = (): void => {
openFolderAddEditDialog(this.dialogService); AddEditFolderDialogComponent.open(this.dialogService);
}; };
editFolder = async (folder: FolderFilter): Promise<void> => { editFolder = async (folder: FolderFilter): Promise<void> => {
const dialog = openFolderAddEditDialog(this.dialogService, { const dialogRef = AddEditFolderDialogComponent.open(this.dialogService, {
data: { editFolderConfig: {
folderId: folder.id, // 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([], { await this.router.navigate([], {
queryParams: { folderId: null }, queryParams: { folderId: null },
queryParamsHandling: "merge", queryParamsHandling: "merge",

View File

@ -485,6 +485,18 @@
"editFolder": { "editFolder": {
"message": "Edit folder" "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": { "baseDomain": {
"message": "Base domain", "message": "Base domain",
"description": "Domain name. Example: website.com" "description": "Domain name. Example: website.com"

View File

@ -31,7 +31,7 @@
*ngIf="variant === 'edit'" *ngIf="variant === 'edit'"
type="button" type="button"
buttonType="danger" buttonType="danger"
class="tw-border-0 tw-ml-auto" class="tw-ml-auto"
bitIconButton="bwi-trash" bitIconButton="bwi-trash"
[appA11yTitle]="'deleteFolder' | i18n" [appA11yTitle]="'deleteFolder' | i18n"
[bitAction]="deleteFolder" [bitAction]="deleteFolder"

View File

@ -17,6 +17,7 @@ import { KeyService } from "@bitwarden/key-management";
import { import {
AddEditFolderDialogComponent, AddEditFolderDialogComponent,
AddEditFolderDialogData, AddEditFolderDialogData,
AddEditFolderDialogResult,
} from "./add-edit-folder-dialog.component"; } from "./add-edit-folder-dialog.component";
describe("AddEditFolderDialogComponent", () => { describe("AddEditFolderDialogComponent", () => {
@ -115,7 +116,7 @@ describe("AddEditFolderDialogComponent", () => {
expect(showToast).toHaveBeenCalledWith({ expect(showToast).toHaveBeenCalledWith({
message: "editedFolder", message: "editedFolder",
title: null, title: "",
variant: "success", variant: "success",
}); });
}); });
@ -125,7 +126,7 @@ describe("AddEditFolderDialogComponent", () => {
await component.submit(); await component.submit();
expect(close).toHaveBeenCalled(); expect(close).toHaveBeenCalledWith(AddEditFolderDialogResult.Created);
}); });
it("logs error if saving fails", async () => { it("logs error if saving fails", async () => {
@ -161,7 +162,7 @@ describe("AddEditFolderDialogComponent", () => {
expect(encrypt).toHaveBeenCalledWith( expect(encrypt).toHaveBeenCalledWith(
{ {
...dialogData.editFolderConfig.folder, ...dialogData.editFolderConfig!.folder,
name: "Edited Folder", name: "Edited Folder",
}, },
"", "",
@ -174,9 +175,10 @@ describe("AddEditFolderDialogComponent", () => {
expect(deleteFolder).toHaveBeenCalledWith(folderView.id, ""); expect(deleteFolder).toHaveBeenCalledWith(folderView.id, "");
expect(showToast).toHaveBeenCalledWith({ expect(showToast).toHaveBeenCalledWith({
variant: "success", variant: "success",
title: null, title: "",
message: "deletedFolder", message: "deletedFolder",
}); });
expect(close).toHaveBeenCalledWith(AddEditFolderDialogResult.Deleted);
}); });
}); });
}); });

View File

@ -1,5 +1,3 @@
// 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 { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { import {
@ -35,6 +33,11 @@ import {
} from "@bitwarden/components"; } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management"; import { KeyService } from "@bitwarden/key-management";
export enum AddEditFolderDialogResult {
Created = "created",
Deleted = "deleted",
}
export type AddEditFolderDialogData = { export type AddEditFolderDialogData = {
/** When provided, dialog will display edit folder variant */ /** When provided, dialog will display edit folder variant */
editFolderConfig?: { folder: FolderView }; editFolderConfig?: { folder: FolderView };
@ -56,12 +59,12 @@ export type AddEditFolderDialogData = {
], ],
}) })
export class AddEditFolderDialogComponent implements AfterViewInit, OnInit { export class AddEditFolderDialogComponent implements AfterViewInit, OnInit {
@ViewChild(BitSubmitDirective) private bitSubmit: BitSubmitDirective; @ViewChild(BitSubmitDirective) private bitSubmit?: BitSubmitDirective;
@ViewChild("submitBtn") private submitBtn: ButtonComponent; @ViewChild("submitBtn") private submitBtn?: ButtonComponent;
folder: FolderView; folder: FolderView = new FolderView();
variant: "add" | "edit"; variant: "add" | "edit" = "add";
folderForm = this.formBuilder.group({ folderForm = this.formBuilder.group({
name: ["", Validators.required], name: ["", Validators.required],
@ -80,14 +83,13 @@ export class AddEditFolderDialogComponent implements AfterViewInit, OnInit {
private i18nService: I18nService, private i18nService: I18nService,
private logService: LogService, private logService: LogService,
private dialogService: DialogService, private dialogService: DialogService,
private dialogRef: DialogRef, private dialogRef: DialogRef<AddEditFolderDialogResult>,
@Inject(DIALOG_DATA) private data?: AddEditFolderDialogData, @Inject(DIALOG_DATA) private data?: AddEditFolderDialogData,
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
this.variant = this.data?.editFolderConfig ? "edit" : "add"; if (this.data?.editFolderConfig) {
this.variant = "edit";
if (this.variant === "edit") {
this.folderForm.controls.name.setValue(this.data.editFolderConfig.folder.name); this.folderForm.controls.name.setValue(this.data.editFolderConfig.folder.name);
this.folder = this.data.editFolderConfig.folder; this.folder = this.data.editFolderConfig.folder;
} else { } else {
@ -97,7 +99,7 @@ export class AddEditFolderDialogComponent implements AfterViewInit, OnInit {
} }
ngAfterViewInit(): void { ngAfterViewInit(): void {
this.bitSubmit.loading$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((loading) => { this.bitSubmit?.loading$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((loading) => {
if (!this.submitBtn) { if (!this.submitBtn) {
return; return;
} }
@ -112,21 +114,21 @@ export class AddEditFolderDialogComponent implements AfterViewInit, OnInit {
return; return;
} }
this.folder.name = this.folderForm.controls.name.value; this.folder.name = this.folderForm.controls.name.value ?? "";
try { try {
const activeUserId = await firstValueFrom(this.activeUserId$); const activeUserId = await firstValueFrom(this.activeUserId$);
const userKey = await this.keyService.getUserKeyWithLegacySupport(activeUserId); const userKey = await this.keyService.getUserKeyWithLegacySupport(activeUserId!);
const folder = await this.folderService.encrypt(this.folder, userKey); const folder = await this.folderService.encrypt(this.folder, userKey);
await this.folderApiService.save(folder, activeUserId); await this.folderApiService.save(folder, activeUserId!);
this.toastService.showToast({ this.toastService.showToast({
variant: "success", variant: "success",
title: null, title: "",
message: this.i18nService.t("editedFolder"), message: this.i18nService.t("editedFolder"),
}); });
this.close(); this.close(AddEditFolderDialogResult.Created);
} catch (e) { } catch (e) {
this.logService.error(e); this.logService.error(e);
} }
@ -146,21 +148,28 @@ export class AddEditFolderDialogComponent implements AfterViewInit, OnInit {
try { try {
const activeUserId = await firstValueFrom(this.activeUserId$); const activeUserId = await firstValueFrom(this.activeUserId$);
await this.folderApiService.delete(this.folder.id, activeUserId); await this.folderApiService.delete(this.folder.id, activeUserId!);
this.toastService.showToast({ this.toastService.showToast({
variant: "success", variant: "success",
title: null, title: "",
message: this.i18nService.t("deletedFolder"), message: this.i18nService.t("deletedFolder"),
}); });
} catch (e) { } catch (e) {
this.logService.error(e); this.logService.error(e);
} }
this.close(); this.close(AddEditFolderDialogResult.Deleted);
}; };
/** Close the dialog */ /** Close the dialog */
private close() { private close(result: AddEditFolderDialogResult) {
this.dialogRef.close(); this.dialogRef.close(result);
}
static open(dialogService: DialogService, data?: AddEditFolderDialogData) {
return dialogService.open<AddEditFolderDialogResult, AddEditFolderDialogData>(
AddEditFolderDialogComponent,
{ data },
);
} }
} }

View File

@ -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 { 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 { 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 { 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"; export * as VaultIcons from "./icons";