mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 13:53:34 +00: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:
@@ -16,6 +16,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-9">
|
<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">
|
<div class="tw-mb-4 tw-flex">
|
||||||
<h1>
|
<h1>
|
||||||
{{ "vaultItems" | i18n }}
|
{{ "vaultItems" | i18n }}
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ import { PasswordRepromptService } from "@bitwarden/common/abstractions/password
|
|||||||
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
|
||||||
import { SyncService } from "@bitwarden/common/abstractions/sync/sync.service.abstraction";
|
import { SyncService } from "@bitwarden/common/abstractions/sync/sync.service.abstraction";
|
||||||
import { Organization } from "@bitwarden/common/models/domain/organization";
|
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 { CipherView } from "@bitwarden/common/models/view/cipher.view";
|
||||||
import { DialogService } from "@bitwarden/components";
|
import { DialogService } from "@bitwarden/components";
|
||||||
|
|
||||||
import { VaultFilterService } from "../../vault/vault-filter/services/abstractions/vault-filter.service";
|
import { VaultFilterService } from "../../vault/vault-filter/services/abstractions/vault-filter.service";
|
||||||
import { VaultFilter } from "../../vault/vault-filter/shared/models/vault-filter.model";
|
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 { EntityEventsComponent } from "../manage/entity-events.component";
|
||||||
import {
|
import {
|
||||||
CollectionDialogResult,
|
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) {
|
private go(queryParams: any = null) {
|
||||||
if (queryParams == null) {
|
if (queryParams == null) {
|
||||||
queryParams = {
|
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,
|
ColorPasswordModule,
|
||||||
} from "@bitwarden/components";
|
} from "@bitwarden/components";
|
||||||
|
|
||||||
|
import { BreadcrumbsModule } from "./components/breadcrumbs/breadcrumbs.module";
|
||||||
|
|
||||||
// Register the locales for the application
|
// Register the locales for the application
|
||||||
import "./locales";
|
import "./locales";
|
||||||
|
|
||||||
@@ -71,6 +73,7 @@ import "./locales";
|
|||||||
ColorPasswordModule,
|
ColorPasswordModule,
|
||||||
|
|
||||||
// Web specific
|
// Web specific
|
||||||
|
BreadcrumbsModule,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
@@ -104,6 +107,7 @@ import "./locales";
|
|||||||
ColorPasswordModule,
|
ColorPasswordModule,
|
||||||
|
|
||||||
// Web specific
|
// Web specific
|
||||||
|
BreadcrumbsModule,
|
||||||
],
|
],
|
||||||
providers: [DatePipe],
|
providers: [DatePipe],
|
||||||
bootstrap: [],
|
bootstrap: [],
|
||||||
|
|||||||
@@ -186,20 +186,70 @@ describe("vault filter service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("collection tree", () => {
|
describe("collection tree", () => {
|
||||||
it("returns a nested tree", async () => {
|
it("returns tree with children", async () => {
|
||||||
const storedCollections = [
|
const storedCollections = [
|
||||||
createCollectionView("Collection 1 Id", "Collection 1", "org test id"),
|
createCollectionView("id-1", "Collection 1", "org test id"),
|
||||||
createCollectionView("Collection 2 Id", "Collection 1/Collection 2", "org test id"),
|
createCollectionView("id-2", "Collection 1/Collection 2", "org test id"),
|
||||||
createCollectionView("Collection 3 Id", "Collection 1/Collection 3", "org test id"),
|
createCollectionView("id-3", "Collection 1/Collection 3", "org test id"),
|
||||||
];
|
];
|
||||||
collectionService.getAllDecrypted.mockResolvedValue(storedCollections);
|
collectionService.getAllDecrypted.mockResolvedValue(storedCollections);
|
||||||
vaultFilterService.reloadCollections();
|
vaultFilterService.reloadCollections();
|
||||||
|
|
||||||
const result = await firstValueFrom(vaultFilterService.collectionTree$);
|
const result = await firstValueFrom(vaultFilterService.collectionTree$);
|
||||||
|
|
||||||
expect(result.children[0].node.id === "Collection 1 Id");
|
expect(result.children.map((c) => c.node.id)).toEqual(["id-1"]);
|
||||||
expect(result.children[0].children.find((c) => c.node.id === "Collection 2 Id"));
|
expect(result.children[0].children.map((c) => c.node.id)).toEqual(["id-2", "id-3"]);
|
||||||
expect(result.children[0].children.find((c) => c.node.id === "Collection 3 Id"));
|
});
|
||||||
|
|
||||||
|
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) => {
|
orgs.forEach((org) => {
|
||||||
const orgCopy = org as OrganizationFilter;
|
const orgCopy = org as OrganizationFilter;
|
||||||
orgCopy.icon = "bwi-business";
|
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);
|
headNode.children.push(node);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -163,7 +163,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
|
|||||||
): Observable<TreeNode<CipherTypeFilter>> {
|
): Observable<TreeNode<CipherTypeFilter>> {
|
||||||
const headNode = new TreeNode<CipherTypeFilter>(head, null);
|
const headNode = new TreeNode<CipherTypeFilter>(head, null);
|
||||||
array?.forEach((filter) => {
|
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);
|
headNode.children.push(node);
|
||||||
});
|
});
|
||||||
return of(headNode);
|
return of(headNode);
|
||||||
@@ -196,7 +196,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
|
|||||||
ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, null, NestingDelimiter);
|
ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, null, NestingDelimiter);
|
||||||
});
|
});
|
||||||
nodes.forEach((n) => {
|
nodes.forEach((n) => {
|
||||||
n.parent = headNode.node;
|
n.parent = headNode;
|
||||||
headNode.children.push(n);
|
headNode.children.push(n);
|
||||||
});
|
});
|
||||||
return headNode;
|
return headNode;
|
||||||
@@ -239,7 +239,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
|
|||||||
});
|
});
|
||||||
|
|
||||||
nodes.forEach((n) => {
|
nodes.forEach((n) => {
|
||||||
n.parent = headNode.node;
|
n.parent = headNode;
|
||||||
headNode.children.push(n);
|
headNode.children.push(n);
|
||||||
});
|
});
|
||||||
return headNode;
|
return headNode;
|
||||||
|
|||||||
@@ -17,6 +17,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div [ngClass]="{ 'col-6': isShowingCards, 'col-9': !isShowingCards }">
|
<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">
|
<div class="tw-mb-4 tw-flex">
|
||||||
<h1>
|
<h1>
|
||||||
{{ "vaultItems" | i18n }}
|
{{ "vaultItems" | i18n }}
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ import { ShareComponent } from "./share.component";
|
|||||||
import { VaultFilterComponent } from "./vault-filter/components/vault-filter.component";
|
import { VaultFilterComponent } from "./vault-filter/components/vault-filter.component";
|
||||||
import { VaultFilterService } from "./vault-filter/services/abstractions/vault-filter.service";
|
import { VaultFilterService } from "./vault-filter/services/abstractions/vault-filter.service";
|
||||||
import { VaultFilter } from "./vault-filter/shared/models/vault-filter.model";
|
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";
|
import { VaultItemsComponent } from "./vault-items.component";
|
||||||
|
|
||||||
const BroadcasterSubscriptionId = "VaultComponent";
|
const BroadcasterSubscriptionId = "VaultComponent";
|
||||||
@@ -380,6 +384,29 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||||||
await this.modalService.openViewRef(UpdateKeyComponent, this.updateKeyModalRef);
|
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) {
|
private go(queryParams: any = null) {
|
||||||
if (queryParams == null) {
|
if (queryParams == null) {
|
||||||
queryParams = {
|
queryParams = {
|
||||||
|
|||||||
@@ -1,56 +1,27 @@
|
|||||||
import { TreeNode } from "../models/domain/tree-node";
|
import { ITreeNodeObject, TreeNode } from "../models/domain/tree-node";
|
||||||
|
|
||||||
import { ServiceUtils } from "./serviceUtils";
|
import { ServiceUtils } from "./serviceUtils";
|
||||||
|
|
||||||
|
type FakeObject = { id: string; name: string };
|
||||||
|
|
||||||
describe("serviceUtils", () => {
|
describe("serviceUtils", () => {
|
||||||
type fakeObject = { id: string; name: string };
|
let nodeTree: TreeNode<FakeObject>[];
|
||||||
let nodeTree: TreeNode<fakeObject>[];
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
nodeTree = [
|
nodeTree = [
|
||||||
{
|
createTreeNode({ id: "1", name: "1" }, [
|
||||||
parent: null,
|
createTreeNode({ id: "1.1", name: "1.1" }, [
|
||||||
node: { id: "1", name: "1" },
|
createTreeNode({ id: "1.1.1", name: "1.1.1" }),
|
||||||
children: [
|
]),
|
||||||
{
|
createTreeNode({ id: "1.2", name: "1.2" }),
|
||||||
parent: { id: "1", name: "1" },
|
])(null),
|
||||||
node: { id: "1.1", name: "1.1" },
|
createTreeNode({ id: "2", name: "2" }, [createTreeNode({ id: "2.1", name: "2.1" })])(null),
|
||||||
children: [
|
createTreeNode({ id: "3", name: "3" }, [])(null),
|
||||||
{
|
|
||||||
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: [],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("nestedTraverse", () => {
|
describe("nestedTraverse", () => {
|
||||||
it("should traverse a tree and add a node at the correct position given a valid path", () => {
|
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"];
|
const path = ["1", "1.2", "1.2.1"];
|
||||||
|
|
||||||
ServiceUtils.nestedTraverse(nodeTree, 0, path, nodeToBeAdded, null, "/");
|
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", () => {
|
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"];
|
const path = ["3", "3.1", "3.1.1"];
|
||||||
|
|
||||||
ServiceUtils.nestedTraverse(nodeTree, 0, path, nodeToBeAdded, null, "/");
|
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,
|
partIndex: number,
|
||||||
parts: string[],
|
parts: string[],
|
||||||
obj: ITreeNodeObject,
|
obj: ITreeNodeObject,
|
||||||
parent: ITreeNodeObject,
|
parent: TreeNode<ITreeNodeObject> | undefined,
|
||||||
delimiter: string
|
delimiter: string
|
||||||
) {
|
) {
|
||||||
if (parts.length <= partIndex) {
|
if (parts.length <= partIndex) {
|
||||||
@@ -40,7 +40,7 @@ export class ServiceUtils {
|
|||||||
partIndex + 1,
|
partIndex + 1,
|
||||||
parts,
|
parts,
|
||||||
obj,
|
obj,
|
||||||
nodeTree[i].node,
|
nodeTree[i],
|
||||||
delimiter
|
delimiter
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export class TreeNode<T extends ITreeNodeObject> {
|
export class TreeNode<T extends ITreeNodeObject> {
|
||||||
parent: T;
|
|
||||||
node: T;
|
node: T;
|
||||||
|
parent: TreeNode<T>;
|
||||||
children: 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.parent = parent;
|
||||||
this.node = node;
|
this.node = node;
|
||||||
if (name) {
|
if (name) {
|
||||||
|
|||||||
Reference in New Issue
Block a user