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 @@
+
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`
+
+`;