1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 13:23:34 +00:00

[PM-17690] Improve collection search to consider nested collections (#14420)

* Add getFlatCollectionTree function and corresponding tests

- Implemented getFlatCollectionTree to flatten a tree structure of collections.
- Added unit tests for getFlatCollectionTree to verify functionality.

* Refactor VaultComponent to utilize getFlatCollectionTree to search within all sub-levels

- Updated vault.component.ts to import and use getFlatCollectionTree for flattening collection nodes during search.
- Ensured consistent handling of collections across both vault and admin-console components.
This commit is contained in:
Rui Tomé
2025-04-30 11:40:55 +01:00
committed by GitHub
parent d43e4757df
commit a92afe1efb
4 changed files with 116 additions and 18 deletions

View File

@@ -1,6 +1,7 @@
import { CollectionView } from "@bitwarden/admin-console/common";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { getNestedCollectionTree } from "./collection-utils";
import { getNestedCollectionTree, getFlatCollectionTree } from "./collection-utils";
describe("CollectionUtils Service", () => {
describe("getNestedCollectionTree", () => {
@@ -36,4 +37,63 @@ describe("CollectionUtils Service", () => {
expect(result).toEqual([]);
});
});
describe("getFlatCollectionTree", () => {
it("should flatten a tree node with no children", () => {
// Arrange
const collection = new CollectionView();
collection.name = "Test Collection";
collection.id = "test-id";
const treeNodes: TreeNode<CollectionView>[] = [
new TreeNode<CollectionView>(collection, null),
];
// Act
const result = getFlatCollectionTree(treeNodes);
// Assert
expect(result.length).toBe(1);
expect(result[0]).toBe(collection);
});
it("should flatten a tree node with children", () => {
// Arrange
const parentCollection = new CollectionView();
parentCollection.name = "Parent";
parentCollection.id = "parent-id";
const child1Collection = new CollectionView();
child1Collection.name = "Child 1";
child1Collection.id = "child1-id";
const child2Collection = new CollectionView();
child2Collection.name = "Child 2";
child2Collection.id = "child2-id";
const grandchildCollection = new CollectionView();
grandchildCollection.name = "Grandchild";
grandchildCollection.id = "grandchild-id";
const parentNode = new TreeNode<CollectionView>(parentCollection, null);
const child1Node = new TreeNode<CollectionView>(child1Collection, parentNode);
const child2Node = new TreeNode<CollectionView>(child2Collection, parentNode);
const grandchildNode = new TreeNode<CollectionView>(grandchildCollection, child1Node);
parentNode.children = [child1Node, child2Node];
child1Node.children = [grandchildNode];
const treeNodes: TreeNode<CollectionView>[] = [parentNode];
// Act
const result = getFlatCollectionTree(treeNodes);
// Assert
expect(result.length).toBe(4);
expect(result[0]).toBe(parentCollection);
expect(result).toContain(child1Collection);
expect(result).toContain(child2Collection);
expect(result).toContain(grandchildCollection);
});
});
});

View File

@@ -37,6 +37,27 @@ export function getNestedCollectionTree(
return nodes;
}
export function getFlatCollectionTree(
nodes: TreeNode<CollectionAdminView>[],
): CollectionAdminView[];
export function getFlatCollectionTree(nodes: TreeNode<CollectionView>[]): CollectionView[];
export function getFlatCollectionTree(
nodes: TreeNode<CollectionView | CollectionAdminView>[],
): (CollectionView | CollectionAdminView)[] {
if (!nodes || nodes.length === 0) {
return [];
}
return nodes.flatMap((node) => {
if (!node.children || node.children.length === 0) {
return [node.node];
}
const children = getFlatCollectionTree(node.children);
return [node.node, ...children];
});
}
function cloneCollection(collection: CollectionView): CollectionView;
function cloneCollection(collection: CollectionAdminView): CollectionAdminView;
function cloneCollection(

View File

@@ -121,7 +121,7 @@ import {
BulkCollectionsDialogResult,
} from "./bulk-collections-dialog";
import { CollectionAccessRestrictedComponent } from "./collection-access-restricted.component";
import { getNestedCollectionTree } from "./utils";
import { getNestedCollectionTree, getFlatCollectionTree } from "./utils";
import { VaultFilterModule } from "./vault-filter/vault-filter.module";
import { VaultHeaderComponent } from "./vault-header/vault-header.component";
@@ -432,23 +432,33 @@ export class VaultComponent implements OnInit, OnDestroy {
}
this.showAddAccessToggle = false;
let collectionsToReturn = [];
let searchableCollectionNodes: TreeNode<CollectionAdminView>[] = [];
if (filter.collectionId === undefined || filter.collectionId === All) {
collectionsToReturn = collections.map((c) => c.node);
searchableCollectionNodes = collections;
} else {
const selectedCollection = ServiceUtils.getTreeNodeObjectFromList(
collections,
filter.collectionId,
);
collectionsToReturn = selectedCollection?.children.map((c) => c.node) ?? [];
searchableCollectionNodes = selectedCollection?.children ?? [];
}
let collectionsToReturn: CollectionAdminView[] = [];
if (await this.searchService.isSearchable(this.userId, searchText)) {
// Flatten the tree for searching through all levels
const flatCollectionTree: CollectionAdminView[] =
getFlatCollectionTree(searchableCollectionNodes);
collectionsToReturn = this.searchPipe.transform(
collectionsToReturn,
flatCollectionTree,
searchText,
(collection: CollectionAdminView) => collection.name,
(collection: CollectionAdminView) => collection.id,
(collection) => collection.name,
(collection) => collection.id,
);
} else {
collectionsToReturn = searchableCollectionNodes.map(
(treeNode: TreeNode<CollectionAdminView>): CollectionAdminView => treeNode.node,
);
}

View File

@@ -79,7 +79,10 @@ import {
PasswordRepromptService,
} from "@bitwarden/vault";
import { getNestedCollectionTree } from "../../admin-console/organizations/collections";
import {
getNestedCollectionTree,
getFlatCollectionTree,
} from "../../admin-console/organizations/collections";
import {
CollectionDialogAction,
CollectionDialogTabType,
@@ -372,31 +375,35 @@ export class VaultComponent implements OnInit, OnDestroy {
if (filter.collectionId === undefined || filter.collectionId === Unassigned) {
return [];
}
let collectionsToReturn = [];
let searchableCollectionNodes: TreeNode<CollectionView>[] = [];
if (filter.organizationId !== undefined && filter.collectionId === All) {
collectionsToReturn = collections
.filter((c) => c.node.organizationId === filter.organizationId)
.map((c) => c.node);
searchableCollectionNodes = collections.filter(
(c) => c.node.organizationId === filter.organizationId,
);
} else if (filter.collectionId === All) {
collectionsToReturn = collections.map((c) => c.node);
searchableCollectionNodes = collections;
} else {
const selectedCollection = ServiceUtils.getTreeNodeObjectFromList(
collections,
filter.collectionId,
);
collectionsToReturn = selectedCollection?.children.map((c) => c.node) ?? [];
searchableCollectionNodes = selectedCollection?.children ?? [];
}
if (await this.searchService.isSearchable(activeUserId, searchText)) {
collectionsToReturn = this.searchPipe.transform(
collectionsToReturn,
// Flatten the tree for searching through all levels
const flatCollectionTree: CollectionView[] =
getFlatCollectionTree(searchableCollectionNodes);
return this.searchPipe.transform(
flatCollectionTree,
searchText,
(collection) => collection.name,
(collection) => collection.id,
);
}
return collectionsToReturn;
return searchableCollectionNodes.map((treeNode: TreeNode<CollectionView>) => treeNode.node);
}),
shareReplay({ refCount: true, bufferSize: 1 }),
);