mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-25 12:15:18 +01: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:
parent
c5ae076018
commit
ef20ee1882
@ -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 }}
|
||||
|
@ -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 = {
|
||||
|
@ -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>
|
@ -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);
|
||||
}
|
||||
}
|
@ -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>
|
@ -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;
|
||||
}
|
||||
}
|
@ -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 {}
|
@ -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[],
|
||||
};
|
@ -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: [],
|
||||
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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 }}
|
||||
|
@ -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 = {
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user