1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 22:03:36 +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 { 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("CollectionUtils Service", () => {
describe("getNestedCollectionTree", () => { describe("getNestedCollectionTree", () => {
@@ -36,4 +37,63 @@ describe("CollectionUtils Service", () => {
expect(result).toEqual([]); 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; 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: CollectionView): CollectionView;
function cloneCollection(collection: CollectionAdminView): CollectionAdminView; function cloneCollection(collection: CollectionAdminView): CollectionAdminView;
function cloneCollection( function cloneCollection(

View File

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

View File

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