From 28a2014e69a812d69df1f77aa6a1d7071daffee2 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Thu, 8 Aug 2024 13:24:49 -0500 Subject: [PATCH] [PM-8204] V2 Folder View (#10423) * add no folders icon to icon library * add/edit folder contained within a dialog * add/edit folder dialog contained new item dropdown * browser refresh folders page component * swap in v2 folder component for extension refresh * add copy for all folder related changes --- apps/browser/src/_locales/en/messages.json | 18 ++ apps/browser/src/popup/app-routing.module.ts | 6 +- .../add-edit-folder-dialog.component.html | 41 +++++ .../add-edit-folder-dialog.component.spec.ts | 157 ++++++++++++++++++ .../add-edit-folder-dialog.component.ts | 155 +++++++++++++++++ .../new-item-dropdown-v2.component.html | 13 +- .../new-item-dropdown-v2.component.spec.ts | 108 ++++++++++++ .../new-item-dropdown-v2.component.ts | 12 +- .../popup/settings/folders-v2.component.html | 47 ++++++ .../settings/folders-v2.component.spec.ts | 115 +++++++++++++ .../popup/settings/folders-v2.component.ts | 76 +++++++++ libs/components/src/icon/icons/index.ts | 1 + libs/components/src/icon/icons/no-folders.ts | 19 +++ 13 files changed, 759 insertions(+), 9 deletions(-) create mode 100644 apps/browser/src/vault/popup/components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component.html create mode 100644 apps/browser/src/vault/popup/components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component.spec.ts create mode 100644 apps/browser/src/vault/popup/components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component.ts create mode 100644 apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.spec.ts create mode 100644 apps/browser/src/vault/popup/settings/folders-v2.component.html create mode 100644 apps/browser/src/vault/popup/settings/folders-v2.component.spec.ts create mode 100644 apps/browser/src/vault/popup/settings/folders-v2.component.ts create mode 100644 libs/components/src/icon/icons/no-folders.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 0217e3f6331..6c41706ddd7 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -304,6 +304,24 @@ "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" + }, + "noFoldersAdded": { + "message": "No folders added" + }, + "createFoldersToOrganize": { + "message": "Create folders to organize your vault items" + }, + "deleteFolderPermanently":{ + "message": "Are you sure you want to permanently delete this folder?" + }, "deleteFolder": { "message": "Delete folder" }, diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 9f13ab57d96..16e12c3d75c 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -81,6 +81,7 @@ import { AttachmentsV2Component } from "../vault/popup/components/vault-v2/attac import { ViewV2Component } from "../vault/popup/components/vault-v2/view-v2/view-v2.component"; import { AppearanceComponent } from "../vault/popup/settings/appearance.component"; import { FolderAddEditComponent } from "../vault/popup/settings/folder-add-edit.component"; +import { FoldersV2Component } from "../vault/popup/settings/folders-v2.component"; import { FoldersComponent } from "../vault/popup/settings/folders.component"; import { SyncComponent } from "../vault/popup/settings/sync.component"; import { VaultSettingsV2Component } from "../vault/popup/settings/vault-settings-v2.component"; @@ -303,12 +304,11 @@ const routes: Routes = [ canActivate: [authGuard], data: { state: "vault-settings" }, }), - { + ...extensionRefreshSwap(FoldersComponent, FoldersV2Component, { path: "folders", - component: FoldersComponent, canActivate: [authGuard], data: { state: "folders" }, - }, + }), { path: "add-folder", component: FolderAddEditComponent, diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component.html b/apps/browser/src/vault/popup/components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component.html new file mode 100644 index 00000000000..0e6dbf24427 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component.html @@ -0,0 +1,41 @@ +
+ + + {{ (variant === "add" ? "newFolder" : "editFolder") | i18n }} + +
+ + {{ "folderName" | i18n }} + + + {{ "folderHintText" | i18n }} + + +
+
+ + + + +
+
+
diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component.spec.ts new file mode 100644 index 00000000000..8453b4cc63e --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component.spec.ts @@ -0,0 +1,157 @@ +import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; + +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 { 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 { + AddEditFolderDialogComponent, + AddEditFolderDialogData, +} from "./add-edit-folder-dialog.component"; + +describe("AddEditFolderDialogComponent", () => { + let component: AddEditFolderDialogComponent; + let fixture: ComponentFixture; + + 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 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(); + + 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: 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", + }); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component.ts new file mode 100644 index 00000000000..33263533990 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit-folder-dialog/add-edit-folder-dialog.component.ts @@ -0,0 +1,155 @@ +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 { JslibModule } from "@bitwarden/angular/jslib.module"; +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"; + +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 destroyRef = inject(DestroyRef); + + constructor( + private formBuilder: FormBuilder, + private folderService: FolderService, + private folderApiService: FolderApiServiceAbstraction, + 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 folder = await this.folderService.encrypt(this.folder); + await this.folderApiService.save(folder); + + 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 { + await this.folderApiService.delete(this.folder.id); + 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(); + } +} diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html index 0bd85c21696..78403784f46 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html @@ -3,20 +3,25 @@ {{ "new" | i18n }} - + {{ "typeLogin" | i18n }} - + {{ "typeCard" | i18n }} - + {{ "typeIdentity" | i18n }} - + {{ "note" | i18n }} + + diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.spec.ts new file mode 100644 index 00000000000..868cb242aa2 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.spec.ts @@ -0,0 +1,108 @@ +import { CommonModule } from "@angular/common"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { ButtonModule, DialogService, MenuModule } from "@bitwarden/components"; + +import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; +import { AddEditFolderDialogComponent } from "../add-edit-folder-dialog/add-edit-folder-dialog.component"; + +import { NewItemDropdownV2Component, NewItemInitialValues } from "./new-item-dropdown-v2.component"; + +describe("NewItemDropdownV2Component", () => { + let component: NewItemDropdownV2Component; + let fixture: ComponentFixture; + const open = jest.fn(); + const navigate = jest.fn(); + + beforeEach(async () => { + open.mockClear(); + navigate.mockClear(); + + await TestBed.configureTestingModule({ + imports: [NewItemDropdownV2Component, MenuModule, ButtonModule, JslibModule, CommonModule], + providers: [ + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: Router, useValue: { navigate } }, + ], + }) + .overrideProvider(DialogService, { useValue: { open } }) + .compileComponents(); + + fixture = TestBed.createComponent(NewItemDropdownV2Component); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("opens new folder dialog", () => { + component.openFolderDialog(); + + expect(open).toHaveBeenCalledWith(AddEditFolderDialogComponent); + }); + + describe("new item", () => { + const emptyParams: AddEditQueryParams = { + collectionId: undefined, + organizationId: undefined, + folderId: undefined, + }; + + beforeEach(() => { + jest.spyOn(component, "newItemNavigate"); + }); + + it("navigates to new login", () => { + component.newItemNavigate(CipherType.Login); + + expect(navigate).toHaveBeenCalledWith(["/add-cipher"], { + queryParams: { type: CipherType.Login.toString(), ...emptyParams }, + }); + }); + + it("navigates to new card", () => { + component.newItemNavigate(CipherType.Card); + + expect(navigate).toHaveBeenCalledWith(["/add-cipher"], { + queryParams: { type: CipherType.Card.toString(), ...emptyParams }, + }); + }); + + it("navigates to new identity", () => { + component.newItemNavigate(CipherType.Identity); + + expect(navigate).toHaveBeenCalledWith(["/add-cipher"], { + queryParams: { type: CipherType.Identity.toString(), ...emptyParams }, + }); + }); + + it("navigates to new note", () => { + component.newItemNavigate(CipherType.SecureNote); + + expect(navigate).toHaveBeenCalledWith(["/add-cipher"], { + queryParams: { type: CipherType.SecureNote.toString(), ...emptyParams }, + }); + }); + + it("includes initial values", () => { + component.initialValues = { + folderId: "222-333-444", + organizationId: "444-555-666", + collectionId: "777-888-999", + } as NewItemInitialValues; + + component.newItemNavigate(CipherType.Login); + + expect(navigate).toHaveBeenCalledWith(["/add-cipher"], { + queryParams: { + type: CipherType.Login.toString(), + folderId: "222-333-444", + organizationId: "444-555-666", + collectionId: "777-888-999", + }, + }); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts index 65456fd74ae..ee9d7c28fec 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts @@ -5,9 +5,10 @@ import { Router, RouterLink } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums"; -import { ButtonModule, MenuModule, NoItemsModule } from "@bitwarden/components"; +import { ButtonModule, DialogService, MenuModule, NoItemsModule } from "@bitwarden/components"; 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; @@ -30,7 +31,10 @@ export class NewItemDropdownV2Component { @Input() initialValues: NewItemInitialValues; - constructor(private router: Router) {} + constructor( + private router: Router, + private dialogService: DialogService, + ) {} private buildQueryParams(type: CipherType): AddEditQueryParams { return { @@ -44,4 +48,8 @@ export class NewItemDropdownV2Component { newItemNavigate(type: CipherType) { void this.router.navigate(["/add-cipher"], { queryParams: this.buildQueryParams(type) }); } + + openFolderDialog() { + this.dialogService.open(AddEditFolderDialogComponent); + } } diff --git a/apps/browser/src/vault/popup/settings/folders-v2.component.html b/apps/browser/src/vault/popup/settings/folders-v2.component.html new file mode 100644 index 00000000000..7ac69876cc0 --- /dev/null +++ b/apps/browser/src/vault/popup/settings/folders-v2.component.html @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + {{ folder.name }} + + + + + + + + + {{ "noFoldersAdded" | i18n }} + {{ "createFoldersToOrganize" | i18n }} + + + + + diff --git a/apps/browser/src/vault/popup/settings/folders-v2.component.spec.ts b/apps/browser/src/vault/popup/settings/folders-v2.component.spec.ts new file mode 100644 index 00000000000..eecad04613e --- /dev/null +++ b/apps/browser/src/vault/popup/settings/folders-v2.component.spec.ts @@ -0,0 +1,115 @@ +import { Component, Input } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { mock } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.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 { 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 { 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"; + +@Component({ + standalone: true, + selector: "popup-header", + template: ``, +}) +class MockPopupHeaderComponent { + @Input() pageTitle: string; + @Input() backAction: () => void; +} + +@Component({ + standalone: true, + selector: "popup-footer", + template: ``, +}) +class MockPopupFooterComponent { + @Input() pageTitle: string; +} + +describe("FoldersV2Component", () => { + let component: FoldersV2Component; + let fixture: ComponentFixture; + const folderViews$ = new BehaviorSubject([]); + const open = jest.fn(); + + beforeEach(async () => { + open.mockClear(); + + await TestBed.configureTestingModule({ + imports: [FoldersV2Component], + providers: [ + { provide: PlatformUtilsService, useValue: mock() }, + { provide: ConfigService, useValue: mock() }, + { provide: LogService, useValue: mock() }, + { provide: FolderService, useValue: { folderViews$ } }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + ], + }) + .overrideComponent(FoldersV2Component, { + remove: { + imports: [PopupHeaderComponent, PopupFooterComponent], + }, + add: { + imports: [MockPopupHeaderComponent, MockPopupFooterComponent], + }, + }) + .overrideProvider(DialogService, { useValue: { open } }) + .compileComponents(); + + fixture = TestBed.createComponent(FoldersV2Component); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + beforeEach(() => { + folderViews$.next([ + { id: "1", name: "Folder 1" }, + { id: "2", name: "Folder 2" }, + { id: "0", name: "No Folder" }, + ] as FolderView[]); + fixture.detectChanges(); + }); + + it("removes the last option in the folder array", (done) => { + component.folders$.subscribe((folders) => { + expect(folders).toEqual([ + { id: "1", name: "Folder 1" }, + { id: "2", name: "Folder 2" }, + ]); + done(); + }); + }); + + it("opens edit dialog for existing folder", () => { + const folder = { id: "1", name: "Folder 1" } as FolderView; + const editButton = fixture.debugElement.query(By.css('[data-testid="edit-folder-button"]')); + + editButton.triggerEventHandler("click"); + + expect(open).toHaveBeenCalledWith(AddEditFolderDialogComponent, { + data: { editFolderConfig: { folder } }, + }); + }); + + it("opens add dialog for new folder when there are no folders", () => { + folderViews$.next([]); + fixture.detectChanges(); + + const addButton = fixture.debugElement.query(By.css('[data-testid="empty-new-folder-button"]')); + + addButton.triggerEventHandler("click"); + + expect(open).toHaveBeenCalledWith(AddEditFolderDialogComponent, { data: {} }); + }); +}); diff --git a/apps/browser/src/vault/popup/settings/folders-v2.component.ts b/apps/browser/src/vault/popup/settings/folders-v2.component.ts new file mode 100644 index 00000000000..503484410b1 --- /dev/null +++ b/apps/browser/src/vault/popup/settings/folders-v2.component.ts @@ -0,0 +1,76 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { map, Observable } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; +import { + AsyncActionsModule, + ButtonModule, + DialogService, + IconButtonModule, + Icons, +} from "@bitwarden/components"; + +import { ItemGroupComponent } from "../../../../../../libs/components/src/item/item-group.component"; +import { ItemModule } from "../../../../../../libs/components/src/item/item.module"; +import { NoItemsModule } from "../../../../../../libs/components/src/no-items/no-items.module"; +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"; +import { NewItemDropdownV2Component } from "../components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component"; + +@Component({ + standalone: true, + templateUrl: "./folders-v2.component.html", + imports: [ + CommonModule, + JslibModule, + NewItemDropdownV2Component, + PopOutComponent, + PopupPageComponent, + PopupHeaderComponent, + ItemModule, + ItemGroupComponent, + NoItemsModule, + IconButtonModule, + ButtonModule, + AsyncActionsModule, + ], +}) +export class FoldersV2Component { + folders$: Observable; + + NoFoldersIcon = Icons.NoFolders; + + constructor( + private folderService: FolderService, + private dialogService: DialogService, + ) { + this.folders$ = this.folderService.folderViews$.pipe( + map((folders) => { + // Remove the last folder, which is the "no folder" option folder + if (folders.length > 0) { + return folders.slice(0, folders.length - 1); + } + + return folders; + }), + ); + } + + /** Open the Add/Edit folder dialog */ + openAddEditFolderDialog(folder?: FolderView) { + // If a folder is provided, the edit variant should be shown + const editFolderConfig = folder ? { folder } : undefined; + + this.dialogService.open(AddEditFolderDialogComponent, { + data: { editFolderConfig }, + }); + } +} diff --git a/libs/components/src/icon/icons/index.ts b/libs/components/src/icon/icons/index.ts index ea583031f61..c0a8f3bd1a6 100644 --- a/libs/components/src/icon/icons/index.ts +++ b/libs/components/src/icon/icons/index.ts @@ -3,3 +3,4 @@ export * from "./search"; export * from "./no-access"; export * from "./vault"; export * from "./no-results"; +export * from "./no-folders"; diff --git a/libs/components/src/icon/icons/no-folders.ts b/libs/components/src/icon/icons/no-folders.ts new file mode 100644 index 00000000000..ee69aac1ae9 --- /dev/null +++ b/libs/components/src/icon/icons/no-folders.ts @@ -0,0 +1,19 @@ +import { svgIcon } from "../icon"; + +export const NoFolders = svgIcon` + + + + + + + + + + + + + + + +`;