mirror of
https://github.com/bitwarden/browser.git
synced 2025-02-16 01:21:48 +01: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:
parent
d2c4c4cad4
commit
28a2014e69
@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
@ -3,3 +3,4 @@ export * from "./search";
|
||||
export * from "./no-access";
|
||||
export * from "./vault";
|
||||
export * from "./no-results";
|
||||
export * from "./no-folders";
|
||||
|
19
libs/components/src/icon/icons/no-folders.ts
Normal file
19
libs/components/src/icon/icons/no-folders.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { svgIcon } from "../icon";
|
||||
|
||||
export const NoFolders = svgIcon`
|
||||
<svg width="147" height="91" viewBox="0 0 147 91" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path class="tw-stroke-info-600" d="M64.8263 1.09473V9.90589" stroke-width="1.25" stroke-linecap="round"/>
|
||||
<path class="tw-stroke-info-600" d="M42.1936 6.09082L46.6564 13.7215" stroke-width="1.25" stroke-linecap="round"/>
|
||||
<path class="tw-stroke-info-600" d="M53.8507 2.4209L55.4006 11.0982" stroke-width="1.25" stroke-linecap="round"/>
|
||||
<path class="tw-stroke-info-600" d="M76.1821 2.50293L73.8719 11.0139" stroke-width="1.25" stroke-linecap="round"/>
|
||||
<path class="tw-stroke-info-600" d="M87.4594 6.09082L82.9966 13.7215" stroke-width="1.25" stroke-linecap="round"/>
|
||||
<path class="tw-stroke-text-headers" d="M100.358 89.3406H28.3419C26.1377 89.3406 24.3422 87.8759 24.3422 86.0715V24.5211C24.3422 22.7167 26.127 21.252 28.3419 21.252H61.6082C63.8124 21.252 65.608 22.7167 65.608 24.5211V28.5013C65.608 30.5073 67.3928 32.1313 69.6077 32.1313H82.105" stroke-width="2.5" stroke-miterlimit="10"/>
|
||||
<path class="tw-stroke-text-headers" d="M82.344 41.7686H40.1042C37.9646 41.7686 36.1475 42.7663 35.8465 44.1036L26.3203 86.2411C25.9547 87.8757 27.9653 89.3404 30.5781 89.3404H107.906C110.045 89.3404 111.862 88.3427 112.163 87.0053L116.904 62.5844" stroke-width="2.5" stroke-miterlimit="10"/>
|
||||
<path class="tw-stroke-info-600" d="M28.1209 36.6897V27.0451C28.1209 25.9523 29.0068 25.0664 30.0996 25.0664H40.7775" stroke-width="1.25" stroke-linecap="round"/>
|
||||
<path class="tw-stroke-text-headers" d="M105.433 64.0751C119.875 65.725 132.919 55.3558 134.569 40.9147C136.219 26.4737 125.849 13.4294 111.408 11.7794C96.9673 10.1295 83.923 20.4988 82.2731 34.9398C80.6232 49.3809 90.9924 62.4252 105.433 64.0751Z" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path class="tw-stroke-info-600" d="M105.834 60.5706C118.253 61.9895 129.484 52.9549 130.92 40.3912M85.9456 35.2528C87.381 22.6891 98.6125 13.6544 111.032 15.0734" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path class="tw-stroke-text-headers" d="M122.014 60.6246L125.553 65.0773L138.676 81.5851C139.806 83.0073 141.876 83.2437 143.298 82.1132L143.558 81.906C144.98 80.7755 145.217 78.7062 144.086 77.2841L130.964 60.7762L127.424 56.3235" stroke-width="2.19854" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path class="tw-stroke-text-headers" d="M101.309 31.3349C101.309 31.3349 101.415 28.8033 103.894 26.6553C105.382 25.3511 107.153 25.0059 108.747 24.9675C110.199 24.9291 111.51 25.1976 112.254 25.6196C113.6 26.31 116.186 27.9594 116.186 31.5267C116.186 35.2858 113.919 36.9735 111.368 38.8531C108.818 40.7326 109.185 43.1796 109.185 45.2509" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path class="tw-fill-text-headers" d="M109.191 51.7764C110.02 51.7764 110.691 51.1049 110.691 50.2764C110.691 49.448 110.02 48.7764 109.191 48.7764C108.363 48.7764 107.691 49.448 107.691 50.2764C107.691 51.1049 108.363 51.7764 109.191 51.7764Z" />
|
||||
</svg>
|
||||
`;
|
Loading…
Reference in New Issue
Block a user