mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 06:13:38 +00:00
[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
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<form [formGroup]="folderForm" [bitSubmit]="submit" id="add-edit-folder">
|
||||
<bit-dialog>
|
||||
<span bitDialogTitle>
|
||||
{{ (variant === "add" ? "newFolder" : "editFolder") | i18n }}
|
||||
</span>
|
||||
<div bitDialogContent>
|
||||
<bit-form-field disableMargin>
|
||||
<bit-label>{{ "folderName" | i18n }}</bit-label>
|
||||
<input bitInput id="folderName" formControlName="name" type="text" />
|
||||
<bit-hint>
|
||||
{{ "folderHintText" | i18n }}
|
||||
</bit-hint>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
<div bitDialogFooter class="tw-flex tw-gap-2 tw-w-full">
|
||||
<button
|
||||
#submitBtn
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
form="add-edit-folder"
|
||||
[disabled]="folderForm.invalid"
|
||||
>
|
||||
{{ "save" | i18n }}
|
||||
</button>
|
||||
<button bitButton bitDialogClose buttonType="secondary" type="button">
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
*ngIf="variant === 'edit'"
|
||||
type="button"
|
||||
buttonType="danger"
|
||||
class="tw-border-0 tw-ml-auto"
|
||||
bitIconButton="bwi-trash"
|
||||
[appA11yTitle]="'deleteFolder' | i18n"
|
||||
[bitAction]="deleteFolder"
|
||||
></button>
|
||||
</div>
|
||||
</bit-dialog>
|
||||
</form>
|
||||
@@ -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<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 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,25 @@
|
||||
{{ "new" | i18n }}
|
||||
</button>
|
||||
<bit-menu #itemOptions>
|
||||
<a type="button" bitMenuItem (click)="newItemNavigate(cipherType.Login)">
|
||||
<a bitMenuItem (click)="newItemNavigate(cipherType.Login)">
|
||||
<i class="bwi bwi-globe" slot="start" aria-hidden="true"></i>
|
||||
{{ "typeLogin" | i18n }}
|
||||
</a>
|
||||
<a type="button" bitMenuItem (click)="newItemNavigate(cipherType.Card)">
|
||||
<a bitMenuItem (click)="newItemNavigate(cipherType.Card)">
|
||||
<i class="bwi bwi-credit-card" slot="start" aria-hidden="true"></i>
|
||||
{{ "typeCard" | i18n }}
|
||||
</a>
|
||||
<a type="button" bitMenuItem (click)="newItemNavigate(cipherType.Identity)">
|
||||
<a bitMenuItem (click)="newItemNavigate(cipherType.Identity)">
|
||||
<i class="bwi bwi-id-card" slot="start" aria-hidden="true"></i>
|
||||
{{ "typeIdentity" | i18n }}
|
||||
</a>
|
||||
<a type="button" bitMenuItem (click)="newItemNavigate(cipherType.SecureNote)">
|
||||
<a bitMenuItem (click)="newItemNavigate(cipherType.SecureNote)">
|
||||
<i class="bwi bwi-sticky-note" slot="start" aria-hidden="true"></i>
|
||||
{{ "note" | i18n }}
|
||||
</a>
|
||||
<bit-menu-divider></bit-menu-divider>
|
||||
<button type="button" bitMenuItem (click)="openFolderDialog()">
|
||||
<i class="bwi bwi-folder" slot="start" aria-hidden="true"></i>
|
||||
{{ "folder" | i18n }}
|
||||
</button>
|
||||
</bit-menu>
|
||||
|
||||
@@ -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<NewItemDropdownV2Component>;
|
||||
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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<popup-page>
|
||||
<popup-header slot="header" [pageTitle]="'folders' | i18n" showBackButton>
|
||||
<ng-container slot="end">
|
||||
<app-new-item-dropdown></app-new-item-dropdown>
|
||||
<app-pop-out></app-pop-out>
|
||||
</ng-container>
|
||||
</popup-header>
|
||||
|
||||
<ng-container *ngIf="folders$ | async as folders">
|
||||
<ng-container *ngIf="folders.length; else noFolders">
|
||||
<bit-item-group>
|
||||
<bit-item *ngFor="let folder of folders">
|
||||
<bit-item-content>
|
||||
{{ folder.name }}
|
||||
<button
|
||||
slot="end"
|
||||
type="button"
|
||||
(click)="openAddEditFolderDialog(folder)"
|
||||
[appA11yTitle]="'editFolder' | i18n"
|
||||
bitIconButton="bwi-pencil-square"
|
||||
class="tw-self-end"
|
||||
data-testid="edit-folder-button"
|
||||
></button>
|
||||
</bit-item-content>
|
||||
</bit-item>
|
||||
</bit-item-group>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #noFolders>
|
||||
<bit-no-items [icon]="NoFoldersIcon" class="tw-h-full tw-flex tw-items-center">
|
||||
<ng-container slot="title">{{ "noFoldersAdded" | i18n }}</ng-container>
|
||||
<ng-container slot="description">{{ "createFoldersToOrganize" | i18n }}</ng-container>
|
||||
<button
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
type="button"
|
||||
slot="button"
|
||||
(click)="openAddEditFolderDialog()"
|
||||
data-testid="empty-new-folder-button"
|
||||
>
|
||||
<i class="bwi bwi-plus-f" aria-hidden="true"></i>
|
||||
{{ "newFolder" | i18n }}
|
||||
</button>
|
||||
</bit-no-items>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</popup-page>
|
||||
@@ -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: `<ng-content></ng-content>`,
|
||||
})
|
||||
class MockPopupHeaderComponent {
|
||||
@Input() pageTitle: string;
|
||||
@Input() backAction: () => void;
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "popup-footer",
|
||||
template: `<ng-content></ng-content>`,
|
||||
})
|
||||
class MockPopupFooterComponent {
|
||||
@Input() pageTitle: string;
|
||||
}
|
||||
|
||||
describe("FoldersV2Component", () => {
|
||||
let component: FoldersV2Component;
|
||||
let fixture: ComponentFixture<FoldersV2Component>;
|
||||
const folderViews$ = new BehaviorSubject<FolderView[]>([]);
|
||||
const open = jest.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
open.mockClear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [FoldersV2Component],
|
||||
providers: [
|
||||
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
|
||||
{ provide: ConfigService, useValue: mock<ConfigService>() },
|
||||
{ provide: LogService, useValue: mock<LogService>() },
|
||||
{ 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: {} });
|
||||
});
|
||||
});
|
||||
@@ -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<FolderView[]>;
|
||||
|
||||
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<unknown, AddEditFolderDialogData>(AddEditFolderDialogComponent, {
|
||||
data: { editFolderConfig },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user