1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-09-27 04:03:00 +02:00

[EC-63] Implement breadcrumb component (#3762)

* [EC-63] feat: scaffold breadcrumb module

* [EC-63] feat: add first very basic structure

* [EC-63] feat: dynamically rendered crumbs with styling

* [EC-63] feat: implement overflow logic

* [EC-63] feat: hide overflow and show ellipsis

* [EC-63] feat: fully working with links

* [EC-63] feat: add support for only showing last crumb

* [EC-63] chore: fix missing template

* [EC-63] chore: refactor and add test case

* [EC-63] refactor: change parent type to treenode

* [EC-63] feat: add breadcrumbs to org vault

* [EC-63] feat: add links to breadcrumbs (dont work yet)

* [EC-63] feat: add support for click handler in breadcrumbs

* [EC-63] feat: working breadcrumb links

* [EC-63] feat: add collections group head

* [EC-63] feat: add breadcrumbs to personal vault

* [EC-63] feat: use icon button

* [EC-63] feat: use small icon button

* [EC-63] fix: add margin to breadcrumb links

The reason for this fix is that the bitIconButton used to open the overflow menu is much taller than the rest of the elements in the list. This causes the whole component to grow and shrink depending on if it contains too many breadcrumbs or not. In the web vault this causes the cipher list to jump up and down while navigating. This increases the height of the entire component so that the icon button no longer affects it.

* [EC-63] fix: tests using wrong parent

* [EC-63] feat: use ngIf instead of else

* [EC-63] refactor: attempt to improve tree node factory readability
This commit is contained in:
Andreas Coroiu 2022-12-21 18:01:46 +01:00 committed by GitHub
parent c5ae076018
commit ef20ee1882
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 431 additions and 59 deletions

View File

@ -16,6 +16,17 @@
</div>
</div>
<div class="col-9">
<bit-breadcrumbs *ngIf="breadcrumbs.length > 0">
<bit-breadcrumb
*ngFor="let collection of breadcrumbs; let first = first"
[icon]="first ? undefined : 'bwi-collection'"
(click)="applyCollectionFilter(collection)"
>
<!-- First node in the tree contains a translation key. The rest come from user input. -->
<ng-container *ngIf="first">{{ collection.node.name | i18n }}</ng-container>
<ng-template *ngIf="!first">{{ collection.node.name }}</ng-template>
</bit-breadcrumb>
</bit-breadcrumbs>
<div class="tw-mb-4 tw-flex">
<h1>
{{ "vaultItems" | i18n }}

View File

@ -21,11 +21,13 @@ import { PasswordRepromptService } from "@bitwarden/common/abstractions/password
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { SyncService } from "@bitwarden/common/abstractions/sync/sync.service.abstraction";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { TreeNode } from "@bitwarden/common/models/domain/tree-node";
import { CipherView } from "@bitwarden/common/models/view/cipher.view";
import { DialogService } from "@bitwarden/components";
import { VaultFilterService } from "../../vault/vault-filter/services/abstractions/vault-filter.service";
import { VaultFilter } from "../../vault/vault-filter/shared/models/vault-filter.model";
import { CollectionFilter } from "../../vault/vault-filter/shared/models/vault-filter.type";
import { EntityEventsComponent } from "../manage/entity-events.component";
import {
CollectionDialogResult,
@ -306,6 +308,29 @@ export class VaultComponent implements OnInit, OnDestroy {
});
}
get breadcrumbs(): TreeNode<CollectionFilter>[] {
if (!this.activeFilter.selectedCollectionNode) {
return [];
}
const collections = [this.activeFilter.selectedCollectionNode];
while (collections[collections.length - 1].parent != undefined) {
collections.push(collections[collections.length - 1].parent);
}
return collections
.map((c) => c)
.slice(1) // 1 for self
.reverse();
}
protected applyCollectionFilter(collection: TreeNode<CollectionFilter>) {
const filter = this.activeFilter;
filter.resetFilter();
filter.selectedCollectionNode = collection;
this.applyVaultFilter(filter);
}
private go(queryParams: any = null) {
if (queryParams == null) {
queryParams = {

View File

@ -0,0 +1,4 @@
<ng-template>
<i *ngIf="icon" class="bwi {{ icon }} tw-mr-1" aria-hidden="true"></i>
<ng-content></ng-content>
</ng-template>

View File

@ -0,0 +1,25 @@
import { Component, EventEmitter, Input, Output, TemplateRef, ViewChild } from "@angular/core";
@Component({
selector: "bit-breadcrumb",
templateUrl: "./breadcrumb.component.html",
})
export class BreadcrumbComponent {
@Input()
icon?: string;
@Input()
route?: string | any[] = undefined;
@Input()
queryParams?: Record<string, string> = {};
@Output()
click = new EventEmitter();
@ViewChild(TemplateRef, { static: true }) content: TemplateRef<unknown>;
onClick(args: unknown) {
this.click.next(args);
}
}

View File

@ -0,0 +1,81 @@
<ng-container *ngFor="let breadcrumb of beforeOverflow; let last = last">
<ng-container *ngIf="breadcrumb.route">
<a
bitLink
linkType="primary"
class="tw-my-2 tw-inline-block"
[routerLink]="breadcrumb.route"
[queryParams]="breadcrumb.queryParams"
>
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
</a>
</ng-container>
<ng-container *ngIf="!breadcrumb.route">
<button
bitLink
linkType="primary"
class="tw-my-2 tw-inline-block"
(click)="breadcrumb.onClick($event)"
>
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
</button>
</ng-container>
<i *ngIf="!last" class="bwi bwi-angle-right tw-mx-1.5 tw-text-main"></i>
</ng-container>
<ng-container *ngIf="hasOverflow">
<i *ngIf="beforeOverflow.length > 0" class="bwi bwi-angle-right tw-mx-1.5 tw-text-main"></i>
<button
bitIconButton="bwi-ellipsis-h"
[bitMenuTriggerFor]="overflowMenu"
size="small"
aria-haspopup
></button>
<bit-menu #overflowMenu>
<ng-container *ngFor="let breadcrumb of overflow">
<ng-container *ngIf="breadcrumb.route">
<a
bitMenuItem
linkType="primary"
[routerLink]="breadcrumb.route"
[queryParams]="breadcrumb.queryParams"
>
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
</a>
</ng-container>
<ng-container *ngIf="!breadcrumb.route">
<button bitMenuItem linkType="primary" (click)="breadcrumb.onClick($event)">
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
</button>
</ng-container>
</ng-container>
</bit-menu>
<i class="bwi bwi-angle-right tw-mx-1.5 tw-text-main"></i>
<ng-container *ngFor="let breadcrumb of afterOverflow; let last = last">
<ng-container *ngIf="breadcrumb.route">
<a
bitLink
linkType="primary"
class="tw-my-2 tw-inline-block"
[routerLink]="breadcrumb.route"
[queryParams]="breadcrumb.queryParams"
>
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
</a>
</ng-container>
<ng-container *ngIf="!breadcrumb.route">
<button
bitLink
linkType="primary"
class="tw-my-2 tw-inline-block"
(click)="breadcrumb.onClick($event)"
>
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
</button>
</ng-container>
<i *ngIf="!last" class="bwi bwi-angle-right tw-mx-1.5 tw-text-main"></i>
</ng-container>
</ng-container>

View File

@ -0,0 +1,39 @@
import { Component, ContentChildren, Input, QueryList } from "@angular/core";
import { BreadcrumbComponent } from "./breadcrumb.component";
@Component({
selector: "bit-breadcrumbs",
templateUrl: "./breadcrumbs.component.html",
})
export class BreadcrumbsComponent {
@Input()
show = 3;
private breadcrumbs: BreadcrumbComponent[] = [];
@ContentChildren(BreadcrumbComponent)
protected set breadcrumbList(value: QueryList<BreadcrumbComponent>) {
this.breadcrumbs = value.toArray();
}
protected get beforeOverflow() {
if (this.hasOverflow) {
return this.breadcrumbs.slice(0, this.show - 1);
}
return this.breadcrumbs;
}
protected get overflow() {
return this.breadcrumbs.slice(this.show - 1, -1);
}
protected get afterOverflow() {
return this.breadcrumbs.slice(-1);
}
protected get hasOverflow() {
return this.breadcrumbs.length > this.show;
}
}

View File

@ -0,0 +1,15 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { RouterModule } from "@angular/router";
import { IconButtonModule, LinkModule, MenuModule } from "@bitwarden/components";
import { BreadcrumbComponent } from "./breadcrumb.component";
import { BreadcrumbsComponent } from "./breadcrumbs.component";
@NgModule({
imports: [CommonModule, LinkModule, IconButtonModule, MenuModule, RouterModule],
declarations: [BreadcrumbsComponent, BreadcrumbComponent],
exports: [BreadcrumbsComponent, BreadcrumbComponent],
})
export class BreadcrumbsModule {}

View File

@ -0,0 +1,92 @@
import { Component } from "@angular/core";
import { RouterModule } from "@angular/router";
import { Meta, Story, moduleMetadata } from "@storybook/angular";
import { IconButtonModule, LinkModule, MenuModule } from "@bitwarden/components";
import { PreloadedEnglishI18nModule } from "../../../tests/preloaded-english-i18n.module";
import { BreadcrumbComponent } from "./breadcrumb.component";
import { BreadcrumbsComponent } from "./breadcrumbs.component";
interface Breadcrumb {
icon?: string;
name: string;
route: string;
}
@Component({
template: "",
})
class EmptyComponent {}
export default {
title: "Web/Breadcrumbs",
component: BreadcrumbsComponent,
decorators: [
moduleMetadata({
declarations: [BreadcrumbComponent],
imports: [
LinkModule,
MenuModule,
IconButtonModule,
PreloadedEnglishI18nModule,
RouterModule.forRoot([{ path: "**", component: EmptyComponent }], { useHash: true }),
],
}),
],
args: {
items: [],
},
argTypes: {
breadcrumbs: {
table: { disable: true },
},
click: { action: "clicked" },
},
} as Meta;
const Template: Story<BreadcrumbsComponent> = (args: BreadcrumbsComponent) => ({
props: args,
template: `
<h3 class="tw-text-main">Router links</h3>
<p>
<bit-breadcrumbs [show]="show">
<bit-breadcrumb *ngFor="let item of items" [icon]="item.icon" [route]="[item.route]">{{item.name}}</bit-breadcrumb>
</bit-breadcrumbs>
</p>
<h3 class="tw-text-main">Click emit</h3>
<p>
<bit-breadcrumbs [show]="show">
<bit-breadcrumb *ngFor="let item of items" [icon]="item.icon" (click)="click($event)">{{item.name}}</bit-breadcrumb>
</bit-breadcrumbs>
</p>
`,
});
export const TopLevel = Template.bind({});
TopLevel.args = {
items: [{ icon: "bwi-star", name: "Top Level" }] as Breadcrumb[],
};
export const SecondLevel = Template.bind({});
SecondLevel.args = {
items: [
{ name: "Acme Vault", route: "/" },
{ icon: "bwi-collection", name: "Collection", route: "collection" },
] as Breadcrumb[],
};
export const Overflow = Template.bind({});
Overflow.args = {
items: [
{ name: "Acme Vault", route: "" },
{ icon: "bwi-collection", name: "Collection", route: "collection" },
{ icon: "bwi-collection", name: "Middle-Collection 1", route: "middle-collection-1" },
{ icon: "bwi-collection", name: "Middle-Collection 2", route: "middle-collection-2" },
{ icon: "bwi-collection", name: "Middle-Collection 3", route: "middle-collection-3" },
{ icon: "bwi-collection", name: "Middle-Collection 4", route: "middle-collection-4" },
{ icon: "bwi-collection", name: "End Collection", route: "end-collection" },
] as Breadcrumb[],
};

View File

@ -28,6 +28,8 @@ import {
ColorPasswordModule,
} from "@bitwarden/components";
import { BreadcrumbsModule } from "./components/breadcrumbs/breadcrumbs.module";
// Register the locales for the application
import "./locales";
@ -71,6 +73,7 @@ import "./locales";
ColorPasswordModule,
// Web specific
BreadcrumbsModule,
],
exports: [
CommonModule,
@ -104,6 +107,7 @@ import "./locales";
ColorPasswordModule,
// Web specific
BreadcrumbsModule,
],
providers: [DatePipe],
bootstrap: [],

View File

@ -186,20 +186,70 @@ describe("vault filter service", () => {
});
describe("collection tree", () => {
it("returns a nested tree", async () => {
it("returns tree with children", async () => {
const storedCollections = [
createCollectionView("Collection 1 Id", "Collection 1", "org test id"),
createCollectionView("Collection 2 Id", "Collection 1/Collection 2", "org test id"),
createCollectionView("Collection 3 Id", "Collection 1/Collection 3", "org test id"),
createCollectionView("id-1", "Collection 1", "org test id"),
createCollectionView("id-2", "Collection 1/Collection 2", "org test id"),
createCollectionView("id-3", "Collection 1/Collection 3", "org test id"),
];
collectionService.getAllDecrypted.mockResolvedValue(storedCollections);
vaultFilterService.reloadCollections();
const result = await firstValueFrom(vaultFilterService.collectionTree$);
expect(result.children[0].node.id === "Collection 1 Id");
expect(result.children[0].children.find((c) => c.node.id === "Collection 2 Id"));
expect(result.children[0].children.find((c) => c.node.id === "Collection 3 Id"));
expect(result.children.map((c) => c.node.id)).toEqual(["id-1"]);
expect(result.children[0].children.map((c) => c.node.id)).toEqual(["id-2", "id-3"]);
});
it("returns tree where non-existing collections are excluded from children", async () => {
const storedCollections = [
createCollectionView("id-1", "Collection 1", "org test id"),
createCollectionView("id-3", "Collection 1/Collection 2/Collection 3", "org test id"),
];
collectionService.getAllDecrypted.mockResolvedValue(storedCollections);
vaultFilterService.reloadCollections();
const result = await firstValueFrom(vaultFilterService.collectionTree$);
expect(result.children.map((c) => c.node.id)).toEqual(["id-1"]);
expect(result.children[0].children.map((c) => c.node.id)).toEqual(["id-3"]);
expect(result.children[0].children[0].node.name).toBe("Collection 2/Collection 3");
});
it("returns tree with parents", async () => {
const storedCollections = [
createCollectionView("id-1", "Collection 1", "org test id"),
createCollectionView("id-2", "Collection 1/Collection 2", "org test id"),
createCollectionView("id-3", "Collection 1/Collection 2/Collection 3", "org test id"),
createCollectionView("id-4", "Collection 1/Collection 4", "org test id"),
];
collectionService.getAllDecrypted.mockResolvedValue(storedCollections);
vaultFilterService.reloadCollections();
const result = await firstValueFrom(vaultFilterService.collectionTree$);
const c1 = result.children[0];
const c2 = c1.children[0];
const c3 = c2.children[0];
const c4 = c1.children[1];
expect(c2.parent.node.id).toEqual("id-1");
expect(c3.parent.node.id).toEqual("id-2");
expect(c4.parent.node.id).toEqual("id-1");
});
it("returns tree where non-existing collections are excluded from parents", async () => {
const storedCollections = [
createCollectionView("id-1", "Collection 1", "org test id"),
createCollectionView("id-3", "Collection 1/Collection 2/Collection 3", "org test id"),
];
collectionService.getAllDecrypted.mockResolvedValue(storedCollections);
vaultFilterService.reloadCollections();
const result = await firstValueFrom(vaultFilterService.collectionTree$);
const c1 = result.children[0];
const c3 = c1.children[0];
expect(c3.parent.node.id).toEqual("id-1");
});
});
});

View File

@ -135,7 +135,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
orgs.forEach((org) => {
const orgCopy = org as OrganizationFilter;
orgCopy.icon = "bwi-business";
const node = new TreeNode<OrganizationFilter>(orgCopy, headNode.node, orgCopy.name);
const node = new TreeNode<OrganizationFilter>(orgCopy, headNode, orgCopy.name);
headNode.children.push(node);
});
}
@ -163,7 +163,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
): Observable<TreeNode<CipherTypeFilter>> {
const headNode = new TreeNode<CipherTypeFilter>(head, null);
array?.forEach((filter) => {
const node = new TreeNode<CipherTypeFilter>(filter, head, filter.name);
const node = new TreeNode<CipherTypeFilter>(filter, headNode, filter.name);
headNode.children.push(node);
});
return of(headNode);
@ -196,7 +196,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, null, NestingDelimiter);
});
nodes.forEach((n) => {
n.parent = headNode.node;
n.parent = headNode;
headNode.children.push(n);
});
return headNode;
@ -239,7 +239,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
});
nodes.forEach((n) => {
n.parent = headNode.node;
n.parent = headNode;
headNode.children.push(n);
});
return headNode;

View File

@ -17,6 +17,17 @@
</div>
</div>
<div [ngClass]="{ 'col-6': isShowingCards, 'col-9': !isShowingCards }">
<bit-breadcrumbs *ngIf="breadcrumbs.length > 0">
<bit-breadcrumb
*ngFor="let collection of breadcrumbs; let first = first"
[icon]="first ? undefined : 'bwi-collection'"
(click)="applyCollectionFilter(collection)"
>
<!-- First node in the tree contains a translation key. The rest come from user input. -->
<ng-container *ngIf="first">{{ collection.node.name | i18n }}</ng-container>
<ng-template *ngIf="!first">{{ collection.node.name }}</ng-template>
</bit-breadcrumb>
</bit-breadcrumbs>
<div class="tw-mb-4 tw-flex">
<h1>
{{ "vaultItems" | i18n }}

View File

@ -37,7 +37,11 @@ import { ShareComponent } from "./share.component";
import { VaultFilterComponent } from "./vault-filter/components/vault-filter.component";
import { VaultFilterService } from "./vault-filter/services/abstractions/vault-filter.service";
import { VaultFilter } from "./vault-filter/shared/models/vault-filter.model";
import { FolderFilter, OrganizationFilter } from "./vault-filter/shared/models/vault-filter.type";
import {
CollectionFilter,
FolderFilter,
OrganizationFilter,
} from "./vault-filter/shared/models/vault-filter.type";
import { VaultItemsComponent } from "./vault-items.component";
const BroadcasterSubscriptionId = "VaultComponent";
@ -380,6 +384,29 @@ export class VaultComponent implements OnInit, OnDestroy {
await this.modalService.openViewRef(UpdateKeyComponent, this.updateKeyModalRef);
}
get breadcrumbs(): TreeNode<CollectionFilter>[] {
if (!this.activeFilter.selectedCollectionNode) {
return [];
}
const collections = [this.activeFilter.selectedCollectionNode];
while (collections[collections.length - 1].parent != undefined) {
collections.push(collections[collections.length - 1].parent);
}
return collections
.map((c) => c)
.slice(1) // 1 for self
.reverse();
}
protected applyCollectionFilter(collection: TreeNode<CollectionFilter>) {
const filter = this.activeFilter;
filter.resetFilter();
filter.selectedCollectionNode = collection;
this.applyVaultFilter(filter);
}
private go(queryParams: any = null) {
if (queryParams == null) {
queryParams = {

View File

@ -1,56 +1,27 @@
import { TreeNode } from "../models/domain/tree-node";
import { ITreeNodeObject, TreeNode } from "../models/domain/tree-node";
import { ServiceUtils } from "./serviceUtils";
type FakeObject = { id: string; name: string };
describe("serviceUtils", () => {
type fakeObject = { id: string; name: string };
let nodeTree: TreeNode<fakeObject>[];
let nodeTree: TreeNode<FakeObject>[];
beforeEach(() => {
nodeTree = [
{
parent: null,
node: { id: "1", name: "1" },
children: [
{
parent: { id: "1", name: "1" },
node: { id: "1.1", name: "1.1" },
children: [
{
parent: { id: "1.1", name: "1.1" },
node: { id: "1.1.1", name: "1.1.1" },
children: [],
},
],
},
{
parent: { id: "1", name: "1" },
node: { id: "1.2", name: "1.2" },
children: [],
},
],
},
{
parent: null,
node: { id: "2", name: "2" },
children: [
{
parent: { id: "2", name: "2" },
node: { id: "2.1", name: "2.1" },
children: [],
},
],
},
{
parent: null,
node: { id: "3", name: "3" },
children: [],
},
createTreeNode({ id: "1", name: "1" }, [
createTreeNode({ id: "1.1", name: "1.1" }, [
createTreeNode({ id: "1.1.1", name: "1.1.1" }),
]),
createTreeNode({ id: "1.2", name: "1.2" }),
])(null),
createTreeNode({ id: "2", name: "2" }, [createTreeNode({ id: "2.1", name: "2.1" })])(null),
createTreeNode({ id: "3", name: "3" }, [])(null),
];
});
describe("nestedTraverse", () => {
it("should traverse a tree and add a node at the correct position given a valid path", () => {
const nodeToBeAdded: fakeObject = { id: "1.2.1", name: "1.2.1" };
const nodeToBeAdded: FakeObject = { id: "1.2.1", name: "1.2.1" };
const path = ["1", "1.2", "1.2.1"];
ServiceUtils.nestedTraverse(nodeTree, 0, path, nodeToBeAdded, null, "/");
@ -58,7 +29,7 @@ describe("serviceUtils", () => {
});
it("should combine the path for missing nodes and use as the added node name given an invalid path", () => {
const nodeToBeAdded: fakeObject = { id: "blank", name: "blank" };
const nodeToBeAdded: FakeObject = { id: "blank", name: "blank" };
const path = ["3", "3.1", "3.1.1"];
ServiceUtils.nestedTraverse(nodeTree, 0, path, nodeToBeAdded, null, "/");
@ -82,3 +53,20 @@ describe("serviceUtils", () => {
});
});
});
type TreeNodeFactory<T extends ITreeNodeObject> = (
obj: T,
children?: TreeNodeFactoryWithoutParent<T>[]
) => TreeNodeFactoryWithoutParent<T>;
type TreeNodeFactoryWithoutParent<T extends ITreeNodeObject> = (
parent?: TreeNode<T>
) => TreeNode<T>;
const createTreeNode: TreeNodeFactory<FakeObject> =
(obj, children = []) =>
(parent) => {
const node = new TreeNode<FakeObject>(obj, parent, obj.name, obj.id);
node.children = children.map((childFunc) => childFunc(node));
return node;
};

View File

@ -15,7 +15,7 @@ export class ServiceUtils {
partIndex: number,
parts: string[],
obj: ITreeNodeObject,
parent: ITreeNodeObject,
parent: TreeNode<ITreeNodeObject> | undefined,
delimiter: string
) {
if (parts.length <= partIndex) {
@ -40,7 +40,7 @@ export class ServiceUtils {
partIndex + 1,
parts,
obj,
nodeTree[i].node,
nodeTree[i],
delimiter
);
return;

View File

@ -1,9 +1,9 @@
export class TreeNode<T extends ITreeNodeObject> {
parent: T;
node: T;
parent: TreeNode<T>;
children: TreeNode<T>[] = [];
constructor(node: T, parent: T, name?: string, id?: string) {
constructor(node: T, parent: TreeNode<T>, name?: string, id?: string) {
this.parent = parent;
this.node = node;
if (name) {