diff --git a/apps/web/src/app/organizations/vault/vault.component.html b/apps/web/src/app/organizations/vault/vault.component.html index 9445a254f2c..8c9d1d3c84d 100644 --- a/apps/web/src/app/organizations/vault/vault.component.html +++ b/apps/web/src/app/organizations/vault/vault.component.html @@ -16,6 +16,17 @@
+ + + + {{ collection.node.name | i18n }} + {{ collection.node.name }} + +

{{ "vaultItems" | i18n }} diff --git a/apps/web/src/app/organizations/vault/vault.component.ts b/apps/web/src/app/organizations/vault/vault.component.ts index baed8fdcf90..7d52bba53ac 100644 --- a/apps/web/src/app/organizations/vault/vault.component.ts +++ b/apps/web/src/app/organizations/vault/vault.component.ts @@ -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[] { + 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) { + const filter = this.activeFilter; + filter.resetFilter(); + filter.selectedCollectionNode = collection; + this.applyVaultFilter(filter); + } + private go(queryParams: any = null) { if (queryParams == null) { queryParams = { diff --git a/apps/web/src/app/shared/components/breadcrumbs/breadcrumb.component.html b/apps/web/src/app/shared/components/breadcrumbs/breadcrumb.component.html new file mode 100644 index 00000000000..5291f0cab4c --- /dev/null +++ b/apps/web/src/app/shared/components/breadcrumbs/breadcrumb.component.html @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/src/app/shared/components/breadcrumbs/breadcrumb.component.ts b/apps/web/src/app/shared/components/breadcrumbs/breadcrumb.component.ts new file mode 100644 index 00000000000..060154b4f69 --- /dev/null +++ b/apps/web/src/app/shared/components/breadcrumbs/breadcrumb.component.ts @@ -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 = {}; + + @Output() + click = new EventEmitter(); + + @ViewChild(TemplateRef, { static: true }) content: TemplateRef; + + onClick(args: unknown) { + this.click.next(args); + } +} diff --git a/apps/web/src/app/shared/components/breadcrumbs/breadcrumbs.component.html b/apps/web/src/app/shared/components/breadcrumbs/breadcrumbs.component.html new file mode 100644 index 00000000000..e291c4a9b97 --- /dev/null +++ b/apps/web/src/app/shared/components/breadcrumbs/breadcrumbs.component.html @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/app/shared/components/breadcrumbs/breadcrumbs.component.ts b/apps/web/src/app/shared/components/breadcrumbs/breadcrumbs.component.ts new file mode 100644 index 00000000000..64ca8146c80 --- /dev/null +++ b/apps/web/src/app/shared/components/breadcrumbs/breadcrumbs.component.ts @@ -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) { + 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; + } +} diff --git a/apps/web/src/app/shared/components/breadcrumbs/breadcrumbs.module.ts b/apps/web/src/app/shared/components/breadcrumbs/breadcrumbs.module.ts new file mode 100644 index 00000000000..d609baf6262 --- /dev/null +++ b/apps/web/src/app/shared/components/breadcrumbs/breadcrumbs.module.ts @@ -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 {} diff --git a/apps/web/src/app/shared/components/breadcrumbs/breadcrumbs.stories.ts b/apps/web/src/app/shared/components/breadcrumbs/breadcrumbs.stories.ts new file mode 100644 index 00000000000..2d306a79ffa --- /dev/null +++ b/apps/web/src/app/shared/components/breadcrumbs/breadcrumbs.stories.ts @@ -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 = (args: BreadcrumbsComponent) => ({ + props: args, + template: ` +

Router links

+

+ + {{item.name}} + +

+ +

Click emit

+

+ + {{item.name}} + +

+ `, +}); + +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[], +}; diff --git a/apps/web/src/app/shared/shared.module.ts b/apps/web/src/app/shared/shared.module.ts index 0f3e87fd31d..491fbf9160e 100644 --- a/apps/web/src/app/shared/shared.module.ts +++ b/apps/web/src/app/shared/shared.module.ts @@ -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: [], diff --git a/apps/web/src/app/vault/vault-filter/services/vault-filter.service.spec.ts b/apps/web/src/app/vault/vault-filter/services/vault-filter.service.spec.ts index a0fa600f835..9136e9d1b20 100644 --- a/apps/web/src/app/vault/vault-filter/services/vault-filter.service.spec.ts +++ b/apps/web/src/app/vault/vault-filter/services/vault-filter.service.spec.ts @@ -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"); }); }); }); diff --git a/apps/web/src/app/vault/vault-filter/services/vault-filter.service.ts b/apps/web/src/app/vault/vault-filter/services/vault-filter.service.ts index e0e433d6ede..7b2ade9da73 100644 --- a/apps/web/src/app/vault/vault-filter/services/vault-filter.service.ts +++ b/apps/web/src/app/vault/vault-filter/services/vault-filter.service.ts @@ -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(orgCopy, headNode.node, orgCopy.name); + const node = new TreeNode(orgCopy, headNode, orgCopy.name); headNode.children.push(node); }); } @@ -163,7 +163,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { ): Observable> { const headNode = new TreeNode(head, null); array?.forEach((filter) => { - const node = new TreeNode(filter, head, filter.name); + const node = new TreeNode(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; diff --git a/apps/web/src/app/vault/vault.component.html b/apps/web/src/app/vault/vault.component.html index 2f3b9cbfc00..0a9833e56e6 100644 --- a/apps/web/src/app/vault/vault.component.html +++ b/apps/web/src/app/vault/vault.component.html @@ -17,6 +17,17 @@

+ + + + {{ collection.node.name | i18n }} + {{ collection.node.name }} + +

{{ "vaultItems" | i18n }} diff --git a/apps/web/src/app/vault/vault.component.ts b/apps/web/src/app/vault/vault.component.ts index 2f407ecf9c6..ee6753d3487 100644 --- a/apps/web/src/app/vault/vault.component.ts +++ b/apps/web/src/app/vault/vault.component.ts @@ -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[] { + 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) { + const filter = this.activeFilter; + filter.resetFilter(); + filter.selectedCollectionNode = collection; + this.applyVaultFilter(filter); + } + private go(queryParams: any = null) { if (queryParams == null) { queryParams = { diff --git a/libs/common/src/misc/serviceUtils.spec.ts b/libs/common/src/misc/serviceUtils.spec.ts index 592171507b7..7cfa318f8c3 100644 --- a/libs/common/src/misc/serviceUtils.spec.ts +++ b/libs/common/src/misc/serviceUtils.spec.ts @@ -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[]; + let nodeTree: TreeNode[]; 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 = ( + obj: T, + children?: TreeNodeFactoryWithoutParent[] +) => TreeNodeFactoryWithoutParent; + +type TreeNodeFactoryWithoutParent = ( + parent?: TreeNode +) => TreeNode; + +const createTreeNode: TreeNodeFactory = + (obj, children = []) => + (parent) => { + const node = new TreeNode(obj, parent, obj.name, obj.id); + node.children = children.map((childFunc) => childFunc(node)); + return node; + }; diff --git a/libs/common/src/misc/serviceUtils.ts b/libs/common/src/misc/serviceUtils.ts index 8934e842195..ac85cf0853a 100644 --- a/libs/common/src/misc/serviceUtils.ts +++ b/libs/common/src/misc/serviceUtils.ts @@ -15,7 +15,7 @@ export class ServiceUtils { partIndex: number, parts: string[], obj: ITreeNodeObject, - parent: ITreeNodeObject, + parent: TreeNode | 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; diff --git a/libs/common/src/models/domain/tree-node.ts b/libs/common/src/models/domain/tree-node.ts index ea36eeafacf..7af1d9e6ab4 100644 --- a/libs/common/src/models/domain/tree-node.ts +++ b/libs/common/src/models/domain/tree-node.ts @@ -1,9 +1,9 @@ export class TreeNode { - parent: T; node: T; + parent: TreeNode; children: TreeNode[] = []; - constructor(node: T, parent: T, name?: string, id?: string) { + constructor(node: T, parent: TreeNode, name?: string, id?: string) { this.parent = parent; this.node = node; if (name) {