1
0
mirror of https://github.com/bitwarden/jslib synced 2025-12-22 11:13:17 +00:00

[refactor] Break down vault filter into several components

These changes rename and rewrite the GroupingsComponent into a VaultFiltersModule. The module follows typical angular patterns for structure and purpose, and contain components for each filter type. The mostly communicate via Input and Output, and depend on a VaultFilterService for sending and recieving data from other parts of the product.
This commit is contained in:
addison
2022-03-24 11:51:51 -04:00
parent 93b5a155bd
commit 1dc9e5ff9a
12 changed files with 485 additions and 253 deletions

View File

@@ -1,253 +0,0 @@
import { Directive, EventEmitter, Input, Output } from "@angular/core";
import { CipherService } from "jslib-common/abstractions/cipher.service";
import { CollectionService } from "jslib-common/abstractions/collection.service";
import { FolderService } from "jslib-common/abstractions/folder.service";
import { OrganizationService } from "jslib-common/abstractions/organization.service";
import { StateService } from "jslib-common/abstractions/state.service";
import { CipherType } from "jslib-common/enums/cipherType";
import { Organization } from "jslib-common/models/domain/organization";
import { ITreeNodeObject, TreeNode } from "jslib-common/models/domain/treeNode";
import { CollectionView } from "jslib-common/models/view/collectionView";
import { FolderView } from "jslib-common/models/view/folderView";
export type TopLevelGroupingId = "vaults" | "types" | "collections" | "folders";
export class TopLevelGroupingView implements ITreeNodeObject {
id: TopLevelGroupingId;
name: string; // localizationString
}
@Directive()
export class GroupingsComponent {
@Input() showFolders = true;
@Input() showCollections = true;
@Input() showFavorites = true;
@Input() showTrash = true;
@Input() showOrganizations = true;
@Output() onAllClicked = new EventEmitter();
@Output() onFavoritesClicked = new EventEmitter();
@Output() onTrashClicked = new EventEmitter();
@Output() onCipherTypeClicked = new EventEmitter<CipherType>();
@Output() onFolderClicked = new EventEmitter<FolderView>();
@Output() onAddFolder = new EventEmitter();
@Output() onEditFolder = new EventEmitter<FolderView>();
@Output() onCollectionClicked = new EventEmitter<CollectionView>();
@Output() onOrganizationClicked = new EventEmitter<Organization>();
@Output() onMyVaultClicked = new EventEmitter();
@Output() onAllVaultsClicked = new EventEmitter();
folders: FolderView[];
nestedFolders: TreeNode<FolderView>[];
collections: CollectionView[];
nestedCollections: TreeNode<CollectionView>[];
loaded = false;
cipherType = CipherType;
selectedAll = false;
selectedFavorites = false;
selectedTrash = false;
selectedType: CipherType = null;
selectedFolder = false;
selectedFolderId: string = null;
selectedCollectionId: string = null;
selectedOrganizationId: string = null;
organizations: Organization[];
myVaultOnly = false;
readonly vaultsGrouping: TopLevelGroupingView = {
id: "vaults",
name: "allVaults",
};
readonly typesGrouping: TopLevelGroupingView = {
id: "types",
name: "types",
};
readonly collectionsGrouping: TopLevelGroupingView = {
id: "collections",
name: "collections",
};
readonly foldersGrouping: TopLevelGroupingView = {
id: "folders",
name: "folders",
};
private collapsedGroupings: Set<string>;
constructor(
protected collectionService: CollectionService,
protected folderService: FolderService,
protected stateService: StateService,
protected organizationService: OrganizationService,
protected cipherService: CipherService
) {}
async load(setLoaded = true) {
const collapsedGroupings = await this.stateService.getCollapsedGroupings();
if (collapsedGroupings == null) {
this.collapsedGroupings = new Set<string>();
} else {
this.collapsedGroupings = new Set(collapsedGroupings);
}
await this.loadFolders();
await this.loadCollections();
await this.loadOrganizations();
if (setLoaded) {
this.loaded = true;
}
}
async loadCollections(organizationId?: string) {
if (!this.showCollections) {
return;
}
const collections = await this.collectionService.getAllDecrypted();
if (organizationId != null) {
this.collections = collections.filter((c) => c.organizationId === organizationId);
} else {
this.collections = collections;
}
this.nestedCollections = await this.collectionService.getAllNested(this.collections);
}
async loadFolders(organizationId?: string) {
if (!this.showFolders) {
return;
}
const folders = await this.folderService.getAllDecrypted();
if (organizationId != null) {
const ciphers = await this.cipherService.getAllDecrypted();
const orgCiphers = ciphers.filter((c) => c.organizationId == organizationId);
this.folders = folders.filter(
(f) =>
f.id != null &&
(orgCiphers.filter((oc) => oc.folderId == f.id).length > 0 ||
ciphers.filter((c) => c.folderId == f.id).length < 1)
);
} else {
this.folders = folders;
}
this.nestedFolders = await this.folderService.getAllNested(this.folders);
}
async loadOrganizations() {
this.showOrganizations = await this.organizationService.hasOrganizations();
if (!this.showOrganizations) {
return;
}
this.organizations = await this.organizationService.getAll();
}
selectAll() {
this.clearSelections();
this.selectedAll = true;
this.onAllClicked.emit();
}
selectFavorites() {
this.clearSelections();
this.selectedFavorites = true;
this.onFavoritesClicked.emit();
}
selectTrash() {
this.clearSelections();
this.selectedTrash = true;
this.onTrashClicked.emit();
}
selectType(type: CipherType) {
this.clearSelections();
this.selectedType = type;
this.onCipherTypeClicked.emit(type);
}
selectFolder(folder: FolderView) {
this.clearSelections();
this.selectedFolder = true;
this.selectedFolderId = folder.id;
this.onFolderClicked.emit(folder);
}
addFolder() {
this.onAddFolder.emit();
}
editFolder(folder: FolderView) {
this.onEditFolder.emit(folder);
}
selectCollection(collection: CollectionView) {
this.clearSelections();
this.selectedCollectionId = collection.id;
this.onCollectionClicked.emit(collection);
}
async selectOrganization(organization: Organization) {
this.clearSelectedOrganization();
this.selectedOrganizationId = organization.id;
await this.reloadCollectionsAndFolders(this.selectedOrganizationId);
this.onOrganizationClicked.emit(organization);
}
async selectMyVault() {
this.clearSelectedOrganization();
this.myVaultOnly = true;
await this.reloadCollectionsAndFolders(this.selectedOrganizationId);
this.onMyVaultClicked.emit();
}
async selectAllVaults() {
this.clearSelectedOrganization();
await this.reloadCollectionsAndFolders(this.selectedOrganizationId);
this.onAllVaultsClicked.emit();
}
clearSelections() {
this.selectedAll = false;
this.selectedFavorites = false;
this.selectedTrash = false;
this.selectedType = null;
this.selectedFolder = false;
this.selectedFolderId = null;
this.selectedCollectionId = null;
}
clearSelectedOrganization() {
this.selectedOrganizationId = null;
this.myVaultOnly = false;
const clearingFolderOrCollectionSelection =
this.selectedFolderId != null || this.selectedCollectionId != null;
if (clearingFolderOrCollectionSelection) {
this.selectedFolder = false;
this.selectedFolderId = null;
this.selectedCollectionId = null;
this.selectedAll = true;
}
}
async collapse(node: ITreeNodeObject, idPrefix = "") {
if (node.id == null) {
return;
}
const id = idPrefix + node.id;
if (this.isCollapsed(node, idPrefix)) {
this.collapsedGroupings.delete(id);
} else {
this.collapsedGroupings.add(id);
}
await this.stateService.setCollapsedGroupings(Array.from(this.collapsedGroupings));
}
isCollapsed(node: ITreeNodeObject, idPrefix = "") {
return this.collapsedGroupings.has(idPrefix + node.id);
}
private async reloadCollectionsAndFolders(organizationId?: string) {
await this.loadCollections(organizationId);
await this.loadFolders(organizationId);
}
}

View File

@@ -0,0 +1,51 @@
import { Directive, EventEmitter, Input, Output } from "@angular/core";
import { ITreeNodeObject } from "jslib-common/models/domain/treeNode";
import { CollectionView } from "jslib-common/models/view/collectionView";
import { DynamicTreeNode } from "../models/dynamic-tree-node.model";
import { TopLevelTreeNode } from "../models/top-level-tree-node.model";
import { VaultFilter } from "../models/vault-filter.model";
@Directive()
export class CollectionFilterComponent {
@Input() hide = false;
@Input() collapsedFilterNodes: Set<string>;
@Input() collectionNodes: DynamicTreeNode<CollectionView>;
@Input() activeFilter: VaultFilter;
@Output() onNodeCollapseStateChange: EventEmitter<ITreeNodeObject> =
new EventEmitter<ITreeNodeObject>();
@Output() onFilterChange: EventEmitter<VaultFilter> = new EventEmitter<VaultFilter>();
readonly collectionsGrouping: TopLevelTreeNode = {
id: "collections",
name: "collections",
};
get collections() {
return this.collectionNodes?.fullList;
}
get nestedCollections() {
return this.collectionNodes?.nestedList;
}
get show() {
return !this.hide && this.collections != null && this.collections.length > 0;
}
isCollapsed(node: ITreeNodeObject) {
return this.collapsedFilterNodes.has(node.id);
}
applyFilter(collection: CollectionView) {
this.activeFilter.resetFilter();
this.activeFilter.selectedCollectionId = collection.id;
this.onFilterChange.emit(this.activeFilter);
}
async toggleCollapse(node: ITreeNodeObject) {
this.onNodeCollapseStateChange.emit(node);
}
}

View File

@@ -0,0 +1,58 @@
import { Directive, EventEmitter, Input, Output } from "@angular/core";
import { ITreeNodeObject } from "jslib-common/models/domain/treeNode";
import { FolderView } from "jslib-common/models/view/folderView";
import { DynamicTreeNode } from "../models/dynamic-tree-node.model";
import { TopLevelTreeNode } from "../models/top-level-tree-node.model";
import { VaultFilter } from "../models/vault-filter.model";
@Directive()
export class FolderFilterComponent {
@Input() hide = false;
@Input() collapsedFilterNodes: Set<string>;
@Input() folderNodes: DynamicTreeNode<FolderView>;
@Input() activeFilter: VaultFilter;
@Output() onNodeCollapseStateChange: EventEmitter<ITreeNodeObject> =
new EventEmitter<ITreeNodeObject>();
@Output() onFilterChange: EventEmitter<VaultFilter> = new EventEmitter<VaultFilter>();
@Output() onAddFolder = new EventEmitter();
@Output() onEditFolder = new EventEmitter<FolderView>();
get folders() {
return this.folderNodes?.fullList;
}
get nestedFolders() {
return this.folderNodes?.nestedList;
}
readonly foldersGrouping: TopLevelTreeNode = {
id: "folders",
name: "folders",
};
applyFilter(folder: FolderView) {
this.activeFilter.resetFilter();
this.activeFilter.selectedFolder = true;
this.activeFilter.selectedFolderId = folder.id;
this.onFilterChange.emit(this.activeFilter);
}
addFolder() {
this.onAddFolder.emit();
}
editFolder(folder: FolderView) {
this.onEditFolder.emit(folder);
}
isCollapsed(node: ITreeNodeObject) {
return this.collapsedFilterNodes.has(node.id);
}
async toggleCollapse(node: ITreeNodeObject) {
this.onNodeCollapseStateChange.emit(node);
}
}

View File

@@ -0,0 +1,84 @@
import { Directive, EventEmitter, Input, Output } from "@angular/core";
import { Organization } from "jslib-common/models/domain/organization";
import { ITreeNodeObject } from "jslib-common/models/domain/treeNode";
import { TopLevelTreeNode } from "../models/top-level-tree-node.model";
import { VaultFilter } from "../models/vault-filter.model";
type DisplayMode =
| "noOrganizations"
| "organizationMember"
| "singleOrganizationPolicy"
| "personalOwnershipPolicy"
| "singleOrganizationAndPersonalOwnershipPolicies";
@Directive()
export class OrganizationFilterComponent {
@Input() hide = false;
@Input() collapsedFilterNodes: Set<string>;
@Input() organizations: Organization[];
@Input() activeFilter: VaultFilter;
@Input() activePersonalOwnershipPolicy: boolean;
@Input() activeSingleOrganizationPolicy: boolean;
@Output() onNodeCollapseStateChange: EventEmitter<ITreeNodeObject> =
new EventEmitter<ITreeNodeObject>();
@Output() onFilterChange: EventEmitter<VaultFilter> = new EventEmitter<VaultFilter>();
get displayMode(): DisplayMode {
let displayMode: DisplayMode = "organizationMember";
if (this.organizations == null || this.organizations.length < 1) {
displayMode = "noOrganizations";
} else if (this.activePersonalOwnershipPolicy && !this.activeSingleOrganizationPolicy) {
displayMode = "personalOwnershipPolicy";
} else if (!this.activePersonalOwnershipPolicy && this.activeSingleOrganizationPolicy) {
displayMode = "singleOrganizationPolicy";
} else if (this.activePersonalOwnershipPolicy && this.activeSingleOrganizationPolicy) {
displayMode = "singleOrganizationAndPersonalOwnershipPolicies";
}
return displayMode;
}
get hasActiveFilter() {
return this.activeFilter.myVaultOnly || this.activeFilter.selectedOrganizationId != null;
}
readonly organizationGrouping: TopLevelTreeNode = {
id: "vaults",
name: "allVaults",
};
async applyOrganizationFilter(organization: Organization) {
this.activeFilter.selectedOrganizationId = organization.id;
this.activeFilter.myVaultOnly = false;
this.activeFilter.refreshCollectionsAndFolders = true;
this.applyFilter(this.activeFilter);
}
async applyMyVaultFilter() {
this.activeFilter.selectedOrganizationId = null;
this.activeFilter.myVaultOnly = true;
this.activeFilter.refreshCollectionsAndFolders = true;
this.applyFilter(this.activeFilter);
}
clearFilter() {
this.activeFilter.myVaultOnly = false;
this.activeFilter.selectedOrganizationId = null;
this.applyFilter(new VaultFilter(this.activeFilter));
}
private applyFilter(filter: VaultFilter) {
this.onFilterChange.emit(filter);
}
async toggleCollapse() {
this.onNodeCollapseStateChange.emit(this.organizationGrouping);
}
get isCollapsed() {
return this.collapsedFilterNodes.has(this.organizationGrouping.id);
}
}

View File

@@ -0,0 +1,22 @@
import { Directive, EventEmitter, Input, Output } from "@angular/core";
import { CipherStatus } from "../models/cipher-status.model";
import { VaultFilter } from "../models/vault-filter.model";
@Directive()
export class StatusFilterComponent {
@Input() hideFavorites = false;
@Input() hideTrash = false;
@Output() onFilterChange: EventEmitter<VaultFilter> = new EventEmitter<VaultFilter>();
@Input() activeFilter: VaultFilter;
get show() {
return !this.hideFavorites && !this.hideTrash;
}
applyFilter(cipherStatus: CipherStatus) {
this.activeFilter.resetFilter();
this.activeFilter.status = cipherStatus;
this.onFilterChange.emit(this.activeFilter);
}
}

View File

@@ -0,0 +1,40 @@
import { Directive, EventEmitter, Input, Output } from "@angular/core";
import { CipherType } from "jslib-common/enums/cipherType";
import { ITreeNodeObject } from "jslib-common/models/domain/treeNode";
import { TopLevelTreeNode } from "../models/top-level-tree-node.model";
import { VaultFilter } from "../models/vault-filter.model";
@Directive()
export class TypeFilterComponent {
@Input() hide = false;
@Input() collapsedFilterNodes: Set<string>;
@Input() selectedCipherType: CipherType = null;
@Input() activeFilter: VaultFilter;
@Output() onNodeCollapseStateChange: EventEmitter<ITreeNodeObject> =
new EventEmitter<ITreeNodeObject>();
@Output() onFilterChange: EventEmitter<VaultFilter> = new EventEmitter<VaultFilter>();
readonly typesNode: TopLevelTreeNode = {
id: "types",
name: "types",
};
cipherTypeEnum = CipherType; // used in the template
get isCollapsed() {
return this.collapsedFilterNodes.has(this.typesNode.id);
}
applyFilter(cipherType: CipherType) {
this.activeFilter.resetFilter();
this.activeFilter.cipherType = cipherType;
this.onFilterChange.emit(this.activeFilter);
}
async toggleCollapse() {
this.onNodeCollapseStateChange.emit(this.typesNode);
}
}

View File

@@ -0,0 +1 @@
export type CipherStatus = "all" | "favorites" | "trash";

View File

@@ -0,0 +1,16 @@
import { TreeNode } from "jslib-common/models/domain/treeNode";
import { CollectionView } from "jslib-common/models/view/collectionView";
import { FolderView } from "jslib-common/models/view/folderView";
export class DynamicTreeNode<T extends CollectionView | FolderView> {
fullList: T[];
nestedList: TreeNode<T>[];
hasId(id: string): boolean {
return this.fullList != null && this.fullList.filter((i: T) => i.id === id).length > 0;
}
constructor(init?: Partial<DynamicTreeNode<T>>) {
Object.assign(this, init);
}
}

View File

@@ -0,0 +1,7 @@
import { ITreeNodeObject } from "jslib-common/models/domain/treeNode";
export type TopLevelTreeNodeId = "vaults" | "types" | "collections" | "folders";
export class TopLevelTreeNode implements ITreeNodeObject {
id: TopLevelTreeNodeId;
name: string; // localizationString
}

View File

@@ -0,0 +1,32 @@
import { CipherType } from "jslib-common/enums/cipherType";
import { CipherStatus } from "./cipher-status.model";
export class VaultFilter {
cipherType?: CipherType;
selectedCollectionId?: string;
status?: CipherStatus;
selectedFolder = false; // This is needed because of how the "No Folder" folder works. It has a null id.
selectedFolderId?: string;
selectedOrganizationId?: string;
myVaultOnly = false;
refreshCollectionsAndFolders = false;
constructor(init?: Partial<VaultFilter>) {
Object.assign(this, init);
}
resetFilter() {
this.cipherType = null;
this.status = null;
this.selectedCollectionId = null;
this.selectedFolder = false;
this.selectedFolderId = null;
}
resetOrganization() {
this.myVaultOnly = false;
this.selectedOrganizationId = null;
this.resetFilter();
}
}

View File

@@ -0,0 +1,95 @@
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { Organization } from "jslib-common/models/domain/organization";
import { ITreeNodeObject } from "jslib-common/models/domain/treeNode";
import { CollectionView } from "jslib-common/models/view/collectionView";
import { FolderView } from "jslib-common/models/view/folderView";
import { DynamicTreeNode } from "./models/dynamic-tree-node.model";
import { VaultFilter } from "./models/vault-filter.model";
import { VaultFilterService } from "./vault-filter.service";
@Directive()
export class VaultFilterComponent implements OnInit {
@Input() activeFilter: VaultFilter = new VaultFilter();
@Input() hideFolders = false;
@Input() hideCollections = false;
@Input() hideFavorites = false;
@Input() hideTrash = false;
@Input() hideOrganizations = false;
@Output() onFilterChange = new EventEmitter<VaultFilter>();
isLoaded = false;
collapsedFilterNodes: Set<string>;
organizations: Organization[];
activePersonalOwnershipPolicy: boolean;
activeSingleOrganizationPolicy: boolean;
collections: DynamicTreeNode<CollectionView>;
folders: DynamicTreeNode<FolderView>;
constructor(protected vaultFilterService: VaultFilterService) {}
get displayCollections() {
return this.collections?.fullList != null && this.collections.fullList.length > 0;
}
async ngOnInit(): Promise<void> {
this.collapsedFilterNodes = await this.vaultFilterService.buildCollapsedFilterNodes();
this.organizations = await this.vaultFilterService.buildOrganizations();
if (this.organizations != null && this.organizations.length > 0) {
this.activePersonalOwnershipPolicy =
await this.vaultFilterService.checkForPersonalOwnershipPolicy();
this.activeSingleOrganizationPolicy =
await this.vaultFilterService.checkForSingleOrganizationPolicy();
}
this.folders = await this.vaultFilterService.buildFolders();
this.collections = await this.vaultFilterService.buildCollections();
this.isLoaded = true;
}
async toggleFilterNodeCollapseState(node: ITreeNodeObject) {
if (this.collapsedFilterNodes.has(node.id)) {
this.collapsedFilterNodes.delete(node.id);
} else {
this.collapsedFilterNodes.add(node.id);
}
await this.vaultFilterService.storeCollapsedFilterNodes(this.collapsedFilterNodes);
}
async applyFilter(filter: VaultFilter) {
if (filter.refreshCollectionsAndFolders) {
await this.reloadCollectionsAndFolders(filter);
}
this.fixInvalidFilterSelections(filter);
this.onFilterChange.emit(filter);
}
async reloadCollectionsAndFolders(filter: VaultFilter) {
this.folders = await this.vaultFilterService.buildFolders(filter.selectedOrganizationId);
this.collections = filter.myVaultOnly
? null
: await this.vaultFilterService.buildCollections(filter.selectedOrganizationId);
}
protected fixInvalidFilterSelections(filter: VaultFilter) {
this.fixInvalidFolderSelection(filter);
this.fixInvalidCollectionSelection(filter);
}
protected fixInvalidFolderSelection(filter: VaultFilter) {
if (filter.selectedFolder && !this.folders.hasId(filter.selectedFolderId)) {
this.activeFilter.selectedFolder = false;
this.activeFilter.selectedFolderId = null;
}
}
protected fixInvalidCollectionSelection(filter: VaultFilter) {
if (
filter.selectedCollectionId != null &&
!this.collections.hasId(filter.selectedCollectionId)
) {
this.activeFilter.selectedCollectionId = null;
}
}
}

View File

@@ -0,0 +1,79 @@
import { CipherService } from "jslib-common/abstractions/cipher.service";
import { CollectionService } from "jslib-common/abstractions/collection.service";
import { FolderService } from "jslib-common/abstractions/folder.service";
import { OrganizationService } from "jslib-common/abstractions/organization.service";
import { PolicyService } from "jslib-common/abstractions/policy.service";
import { StateService } from "jslib-common/abstractions/state.service";
import { PolicyType } from "jslib-common/enums/policyType";
import { Organization } from "jslib-common/models/domain/organization";
import { CollectionView } from "jslib-common/models/view/collectionView";
import { FolderView } from "jslib-common/models/view/folderView";
import { DynamicTreeNode } from "./models/dynamic-tree-node.model";
export class VaultFilterService {
constructor(
protected stateService: StateService,
protected organizationService: OrganizationService,
protected folderService: FolderService,
protected cipherService: CipherService,
protected collectionService: CollectionService,
protected policyService: PolicyService
) {}
async storeCollapsedFilterNodes(collapsedFilterNodes: Set<string>): Promise<void> {
await this.stateService.setCollapsedGroupings(Array.from(collapsedFilterNodes));
}
async buildCollapsedFilterNodes(): Promise<Set<string>> {
return new Set(await this.stateService.getCollapsedGroupings());
}
async buildOrganizations(): Promise<Organization[]> {
return await this.organizationService.getAll();
}
async buildFolders(organizationId?: string): Promise<DynamicTreeNode<FolderView>> {
const storedFolders = await this.folderService.getAllDecrypted();
let folders: FolderView[];
if (organizationId != null) {
const ciphers = await this.cipherService.getAllDecrypted();
const orgCiphers = ciphers.filter((c) => c.organizationId == organizationId);
folders = storedFolders.filter(
(f) =>
orgCiphers.filter((oc) => oc.folderId == f.id).length > 0 ||
ciphers.filter((c) => c.folderId == f.id).length < 1
);
} else {
folders = storedFolders;
}
const nestedFolders = await this.folderService.getAllNested(folders);
return new DynamicTreeNode<FolderView>({
fullList: folders,
nestedList: nestedFolders,
});
}
async buildCollections(organizationId?: string): Promise<DynamicTreeNode<CollectionView>> {
const storedCollections = await this.collectionService.getAllDecrypted();
let collections: CollectionView[];
if (organizationId != null) {
collections = storedCollections.filter((c) => c.organizationId === organizationId);
} else {
collections = storedCollections;
}
const nestedCollections = await this.collectionService.getAllNested(collections);
return new DynamicTreeNode<CollectionView>({
fullList: collections,
nestedList: nestedCollections,
});
}
async checkForSingleOrganizationPolicy(): Promise<boolean> {
return await this.policyService.policyAppliesToUser(PolicyType.SingleOrg);
}
async checkForPersonalOwnershipPolicy(): Promise<boolean> {
return await this.policyService.policyAppliesToUser(PolicyType.PersonalOwnership);
}
}