diff --git a/angular/src/components/groupings.component.ts b/angular/src/components/groupings.component.ts deleted file mode 100644 index aa5a743f331..00000000000 --- a/angular/src/components/groupings.component.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { Directive, EventEmitter, Input, Output } from "@angular/core"; - -import { CollectionService } from "jslib-common/abstractions/collection.service"; -import { FolderService } from "jslib-common/abstractions/folder.service"; -import { StateService } from "jslib-common/abstractions/state.service"; -import { CipherType } from "jslib-common/enums/cipherType"; -import { TreeNode } from "jslib-common/models/domain/treeNode"; -import { CollectionView } from "jslib-common/models/view/collectionView"; -import { FolderView } from "jslib-common/models/view/folderView"; - -@Directive() -export class GroupingsComponent { - @Input() showFolders = true; - @Input() showCollections = true; - @Input() showFavorites = true; - @Input() showTrash = true; - - @Output() onAllClicked = new EventEmitter(); - @Output() onFavoritesClicked = new EventEmitter(); - @Output() onTrashClicked = new EventEmitter(); - @Output() onCipherTypeClicked = new EventEmitter(); - @Output() onFolderClicked = new EventEmitter(); - @Output() onAddFolder = new EventEmitter(); - @Output() onEditFolder = new EventEmitter(); - @Output() onCollectionClicked = new EventEmitter(); - - folders: FolderView[]; - nestedFolders: TreeNode[]; - collections: CollectionView[]; - nestedCollections: TreeNode[]; - loaded = false; - cipherType = CipherType; - selectedAll = false; - selectedFavorites = false; - selectedTrash = false; - selectedType: CipherType = null; - selectedFolder = false; - selectedFolderId: string = null; - selectedCollectionId: string = null; - - private collapsedGroupings: Set; - - constructor( - protected collectionService: CollectionService, - protected folderService: FolderService, - protected stateService: StateService - ) {} - - async load(setLoaded = true) { - const collapsedGroupings = await this.stateService.getCollapsedGroupings(); - if (collapsedGroupings == null) { - this.collapsedGroupings = new Set(); - } else { - this.collapsedGroupings = new Set(collapsedGroupings); - } - - await this.loadFolders(); - await this.loadCollections(); - - 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() { - if (!this.showFolders) { - return; - } - this.folders = await this.folderService.getAllDecrypted(); - this.nestedFolders = await this.folderService.getAllNested(); - } - - 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); - } - - clearSelections() { - this.selectedAll = false; - this.selectedFavorites = false; - this.selectedTrash = false; - this.selectedType = null; - this.selectedFolder = false; - this.selectedFolderId = null; - this.selectedCollectionId = null; - } - - async collapse(grouping: FolderView | CollectionView, idPrefix = "") { - if (grouping.id == null) { - return; - } - const id = idPrefix + grouping.id; - if (this.isCollapsed(grouping, idPrefix)) { - this.collapsedGroupings.delete(id); - } else { - this.collapsedGroupings.add(id); - } - await this.stateService.setCollapsedGroupings(Array.from(this.collapsedGroupings)); - } - - isCollapsed(grouping: FolderView | CollectionView, idPrefix = "") { - return this.collapsedGroupings.has(idPrefix + grouping.id); - } -} diff --git a/angular/src/services/auth-guard.service.ts b/angular/src/guards/auth.guard.ts similarity index 96% rename from angular/src/services/auth-guard.service.ts rename to angular/src/guards/auth.guard.ts index b9f8ebfdcfe..355f797bf3a 100644 --- a/angular/src/services/auth-guard.service.ts +++ b/angular/src/guards/auth.guard.ts @@ -7,7 +7,7 @@ import { MessagingService } from "jslib-common/abstractions/messaging.service"; import { AuthenticationStatus } from "jslib-common/enums/authenticationStatus"; @Injectable() -export class AuthGuardService implements CanActivate { +export class AuthGuard implements CanActivate { constructor( private authService: AuthService, private router: Router, diff --git a/angular/src/services/lock-guard.service.ts b/angular/src/guards/lock.guard.ts similarity index 86% rename from angular/src/services/lock-guard.service.ts rename to angular/src/guards/lock.guard.ts index 2a44db72039..05f2bac6e91 100644 --- a/angular/src/services/lock-guard.service.ts +++ b/angular/src/guards/lock.guard.ts @@ -5,7 +5,7 @@ import { AuthService } from "jslib-common/abstractions/auth.service"; import { AuthenticationStatus } from "jslib-common/enums/authenticationStatus"; @Injectable() -export class LockGuardService implements CanActivate { +export class LockGuard implements CanActivate { protected homepage = "vault"; protected loginpage = "login"; constructor(private authService: AuthService, private router: Router) {} @@ -20,7 +20,6 @@ export class LockGuardService implements CanActivate { const redirectUrl = authStatus === AuthenticationStatus.LoggedOut ? [this.loginpage] : [this.homepage]; - this.router.navigate(redirectUrl); - return false; + return this.router.createUrlTree([redirectUrl]); } } diff --git a/angular/src/services/unauth-guard.service.ts b/angular/src/guards/unauth.guard.ts similarity index 92% rename from angular/src/services/unauth-guard.service.ts rename to angular/src/guards/unauth.guard.ts index 9c309d9afdb..3335d636d4f 100644 --- a/angular/src/services/unauth-guard.service.ts +++ b/angular/src/guards/unauth.guard.ts @@ -5,7 +5,7 @@ import { AuthService } from "jslib-common/abstractions/auth.service"; import { AuthenticationStatus } from "jslib-common/enums/authenticationStatus"; @Injectable() -export class UnauthGuardService implements CanActivate { +export class UnauthGuard implements CanActivate { protected homepage = "vault"; constructor(private authService: AuthService, private router: Router) {} diff --git a/angular/src/modules/vault-filter/components/collection-filter.component.ts b/angular/src/modules/vault-filter/components/collection-filter.component.ts new file mode 100644 index 00000000000..67a21200a5b --- /dev/null +++ b/angular/src/modules/vault-filter/components/collection-filter.component.ts @@ -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; + @Input() collectionNodes: DynamicTreeNode; + @Input() activeFilter: VaultFilter; + + @Output() onNodeCollapseStateChange: EventEmitter = + new EventEmitter(); + @Output() onFilterChange: EventEmitter = new EventEmitter(); + + 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); + } +} diff --git a/angular/src/modules/vault-filter/components/folder-filter.component.ts b/angular/src/modules/vault-filter/components/folder-filter.component.ts new file mode 100644 index 00000000000..fe9537a7056 --- /dev/null +++ b/angular/src/modules/vault-filter/components/folder-filter.component.ts @@ -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; + @Input() folderNodes: DynamicTreeNode; + @Input() activeFilter: VaultFilter; + + @Output() onNodeCollapseStateChange: EventEmitter = + new EventEmitter(); + @Output() onFilterChange: EventEmitter = new EventEmitter(); + @Output() onAddFolder = new EventEmitter(); + @Output() onEditFolder = new EventEmitter(); + + 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); + } +} diff --git a/angular/src/modules/vault-filter/components/organization-filter.component.ts b/angular/src/modules/vault-filter/components/organization-filter.component.ts new file mode 100644 index 00000000000..79c2c955165 --- /dev/null +++ b/angular/src/modules/vault-filter/components/organization-filter.component.ts @@ -0,0 +1,78 @@ +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 { DisplayMode } from "../models/display-mode"; +import { TopLevelTreeNode } from "../models/top-level-tree-node.model"; +import { VaultFilter } from "../models/vault-filter.model"; + +@Directive() +export class OrganizationFilterComponent { + @Input() hide = false; + @Input() collapsedFilterNodes: Set; + @Input() organizations: Organization[]; + @Input() activeFilter: VaultFilter; + @Input() activePersonalOwnershipPolicy: boolean; + @Input() activeSingleOrganizationPolicy: boolean; + + @Output() onNodeCollapseStateChange: EventEmitter = + new EventEmitter(); + @Output() onFilterChange: EventEmitter = new EventEmitter(); + + 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); + } +} diff --git a/angular/src/modules/vault-filter/components/status-filter.component.ts b/angular/src/modules/vault-filter/components/status-filter.component.ts new file mode 100644 index 00000000000..fe182ad0771 --- /dev/null +++ b/angular/src/modules/vault-filter/components/status-filter.component.ts @@ -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 = new EventEmitter(); + @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); + } +} diff --git a/angular/src/modules/vault-filter/components/type-filter.component.ts b/angular/src/modules/vault-filter/components/type-filter.component.ts new file mode 100644 index 00000000000..49aeaa81ff0 --- /dev/null +++ b/angular/src/modules/vault-filter/components/type-filter.component.ts @@ -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; + @Input() selectedCipherType: CipherType = null; + @Input() activeFilter: VaultFilter; + + @Output() onNodeCollapseStateChange: EventEmitter = + new EventEmitter(); + @Output() onFilterChange: EventEmitter = new EventEmitter(); + + 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); + } +} diff --git a/angular/src/modules/vault-filter/models/cipher-status.model.ts b/angular/src/modules/vault-filter/models/cipher-status.model.ts new file mode 100644 index 00000000000..f93cd8b2107 --- /dev/null +++ b/angular/src/modules/vault-filter/models/cipher-status.model.ts @@ -0,0 +1 @@ +export type CipherStatus = "all" | "favorites" | "trash"; diff --git a/angular/src/modules/vault-filter/models/display-mode.ts b/angular/src/modules/vault-filter/models/display-mode.ts new file mode 100644 index 00000000000..3395df4fbe2 --- /dev/null +++ b/angular/src/modules/vault-filter/models/display-mode.ts @@ -0,0 +1,6 @@ +export type DisplayMode = + | "noOrganizations" + | "organizationMember" + | "singleOrganizationPolicy" + | "personalOwnershipPolicy" + | "singleOrganizationAndPersonalOwnershipPolicies"; diff --git a/angular/src/modules/vault-filter/models/dynamic-tree-node.model.ts b/angular/src/modules/vault-filter/models/dynamic-tree-node.model.ts new file mode 100644 index 00000000000..d63a518f7c0 --- /dev/null +++ b/angular/src/modules/vault-filter/models/dynamic-tree-node.model.ts @@ -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 { + fullList: T[]; + nestedList: TreeNode[]; + + hasId(id: string): boolean { + return this.fullList != null && this.fullList.filter((i: T) => i.id === id).length > 0; + } + + constructor(init?: Partial>) { + Object.assign(this, init); + } +} diff --git a/angular/src/modules/vault-filter/models/top-level-tree-node.model.ts b/angular/src/modules/vault-filter/models/top-level-tree-node.model.ts new file mode 100644 index 00000000000..3e9870de2d4 --- /dev/null +++ b/angular/src/modules/vault-filter/models/top-level-tree-node.model.ts @@ -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 +} diff --git a/angular/src/modules/vault-filter/models/vault-filter.model.ts b/angular/src/modules/vault-filter/models/vault-filter.model.ts new file mode 100644 index 00000000000..b65729c5830 --- /dev/null +++ b/angular/src/modules/vault-filter/models/vault-filter.model.ts @@ -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) { + 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(); + } +} diff --git a/angular/src/modules/vault-filter/vault-filter.component.ts b/angular/src/modules/vault-filter/vault-filter.component.ts new file mode 100644 index 00000000000..5d743fcb1cd --- /dev/null +++ b/angular/src/modules/vault-filter/vault-filter.component.ts @@ -0,0 +1,108 @@ +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(); + @Output() onAddFolder = new EventEmitter(); + @Output() onEditFolder = new EventEmitter(); + + isLoaded = false; + collapsedFilterNodes: Set; + organizations: Organization[]; + activePersonalOwnershipPolicy: boolean; + activeSingleOrganizationPolicy: boolean; + collections: DynamicTreeNode; + folders: DynamicTreeNode; + + constructor(protected vaultFilterService: VaultFilterService) {} + + get displayCollections() { + return this.collections?.fullList != null && this.collections.fullList.length > 0; + } + + async ngOnInit(): Promise { + 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); + filter = this.pruneInvalidatedFilterSelections(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); + } + + addFolder() { + this.onAddFolder.emit(); + } + + editFolder(folder: FolderView) { + this.onEditFolder.emit(folder); + } + + protected pruneInvalidatedFilterSelections(filter: VaultFilter): VaultFilter { + filter = this.pruneInvalidFolderSelection(filter); + filter = this.pruneInvalidCollectionSelection(filter); + return filter; + } + + protected pruneInvalidFolderSelection(filter: VaultFilter): VaultFilter { + if (filter.selectedFolder && !this.folders?.hasId(filter.selectedFolderId)) { + filter.selectedFolder = false; + filter.selectedFolderId = null; + } + return filter; + } + + protected pruneInvalidCollectionSelection(filter: VaultFilter): VaultFilter { + if ( + filter.selectedCollectionId != null && + !this.collections?.hasId(filter.selectedCollectionId) + ) { + filter.selectedCollectionId = null; + } + return filter; + } +} diff --git a/angular/src/modules/vault-filter/vault-filter.service.ts b/angular/src/modules/vault-filter/vault-filter.service.ts new file mode 100644 index 00000000000..b05cce1742f --- /dev/null +++ b/angular/src/modules/vault-filter/vault-filter.service.ts @@ -0,0 +1,82 @@ +import { Injectable } 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 { 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"; + +@Injectable() +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): Promise { + await this.stateService.setCollapsedGroupings(Array.from(collapsedFilterNodes)); + } + + async buildCollapsedFilterNodes(): Promise> { + return new Set(await this.stateService.getCollapsedGroupings()); + } + + async buildOrganizations(): Promise { + return await this.organizationService.getAll(); + } + + async buildFolders(organizationId?: string): Promise> { + 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({ + fullList: folders, + nestedList: nestedFolders, + }); + } + + async buildCollections(organizationId?: string): Promise> { + 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({ + fullList: collections, + nestedList: nestedCollections, + }); + } + + async checkForSingleOrganizationPolicy(): Promise { + return await this.policyService.policyAppliesToUser(PolicyType.SingleOrg); + } + + async checkForPersonalOwnershipPolicy(): Promise { + return await this.policyService.policyAppliesToUser(PolicyType.PersonalOwnership); + } +} diff --git a/angular/src/scss/bwicons/fonts/bwi-font.svg b/angular/src/scss/bwicons/fonts/bwi-font.svg index 8b37358b76c..8642f2d64aa 100644 --- a/angular/src/scss/bwicons/fonts/bwi-font.svg +++ b/angular/src/scss/bwicons/fonts/bwi-font.svg @@ -159,11 +159,15 @@ - + + + + + diff --git a/angular/src/scss/bwicons/fonts/bwi-font.ttf b/angular/src/scss/bwicons/fonts/bwi-font.ttf index 9b1cfde3ddc..f58764e334e 100644 Binary files a/angular/src/scss/bwicons/fonts/bwi-font.ttf and b/angular/src/scss/bwicons/fonts/bwi-font.ttf differ diff --git a/angular/src/scss/bwicons/fonts/bwi-font.woff b/angular/src/scss/bwicons/fonts/bwi-font.woff index fdf107376ec..94abd93dbf1 100644 Binary files a/angular/src/scss/bwicons/fonts/bwi-font.woff and b/angular/src/scss/bwicons/fonts/bwi-font.woff differ diff --git a/angular/src/scss/bwicons/fonts/bwi-font.woff2 b/angular/src/scss/bwicons/fonts/bwi-font.woff2 index 04cda6895e1..d3caba52858 100644 Binary files a/angular/src/scss/bwicons/fonts/bwi-font.woff2 and b/angular/src/scss/bwicons/fonts/bwi-font.woff2 differ diff --git a/angular/src/scss/bwicons/styles/style.scss b/angular/src/scss/bwicons/styles/style.scss index e8c05f230c0..671d9d850ba 100644 --- a/angular/src/scss/bwicons/styles/style.scss +++ b/angular/src/scss/bwicons/styles/style.scss @@ -241,6 +241,10 @@ $icons: ( "android": "\e944", "error": "\e981", "numbered-list": "\e989", + "billing": "\e98a", + "family": "\e98b", + "provider": "\e98c", + "business": "\e98d", ); @each $name, $glyph in $icons { diff --git a/angular/src/services/jslib-services.module.ts b/angular/src/services/jslib-services.module.ts index 3cd97d63362..900a38c0488 100644 --- a/angular/src/services/jslib-services.module.ts +++ b/angular/src/services/jslib-services.module.ts @@ -74,12 +74,13 @@ import { UsernameGenerationService } from "jslib-common/services/usernameGenerat import { VaultTimeoutService } from "jslib-common/services/vaultTimeout.service"; import { WebCryptoFunctionService } from "jslib-common/services/webCryptoFunction.service"; -import { AuthGuardService } from "./auth-guard.service"; +import { AuthGuard } from "../guards/auth.guard"; +import { LockGuard } from "../guards/lock.guard"; +import { UnauthGuard } from "../guards/unauth.guard"; + import { BroadcasterService } from "./broadcaster.service"; -import { LockGuardService } from "./lock-guard.service"; import { ModalService } from "./modal.service"; import { PasswordRepromptService } from "./passwordReprompt.service"; -import { UnauthGuardService } from "./unauth-guard.service"; import { ValidationService } from "./validation.service"; export const WINDOW = new InjectionToken("WINDOW"); @@ -98,9 +99,9 @@ export const SYSTEM_LANGUAGE = new InjectionToken("SYSTEM_LANGUAGE"); declarations: [], providers: [ ValidationService, - AuthGuardService, - UnauthGuardService, - LockGuardService, + AuthGuard, + UnauthGuard, + LockGuard, ModalService, { provide: WINDOW, useValue: window }, { diff --git a/common/src/abstractions/api.service.ts b/common/src/abstractions/api.service.ts index c9970ce81bd..364c9f0e551 100644 --- a/common/src/abstractions/api.service.ts +++ b/common/src/abstractions/api.service.ts @@ -1,3 +1,6 @@ +import { BillingHistoryResponse } from "jslib-common/models/response/billingHistoryResponse"; +import { BillingPaymentResponse } from "jslib-common/models/response/billingPaymentResponse"; + import { PolicyType } from "../enums/policyType"; import { SetKeyConnectorKeyRequest } from "../models/request/account/setKeyConnectorKeyRequest"; import { VerifyOTPRequest } from "../models/request/account/verifyOTPRequest"; @@ -174,7 +177,6 @@ export abstract class ApiService { refreshIdentityToken: () => Promise; getProfile: () => Promise; - getUserBilling: () => Promise; getUserSubscription: () => Promise; getTaxInfo: () => Promise; putProfile: (request: UpdateProfileRequest) => Promise; @@ -212,6 +214,9 @@ export abstract class ApiService { postAccountVerifyOTP: (request: VerifyOTPRequest) => Promise; postConvertToKeyConnector: () => Promise; + getUserBillingHistory: () => Promise; + getUserBillingPayment: () => Promise; + getFolder: (id: string) => Promise; postFolder: (request: FolderRequest) => Promise; putFolder: (id: string, request: FolderRequest) => Promise; diff --git a/common/src/abstractions/folder.service.ts b/common/src/abstractions/folder.service.ts index 574cebfcd7a..f848de90920 100644 --- a/common/src/abstractions/folder.service.ts +++ b/common/src/abstractions/folder.service.ts @@ -10,7 +10,7 @@ export abstract class FolderService { get: (id: string) => Promise; getAll: () => Promise; getAllDecrypted: () => Promise; - getAllNested: () => Promise[]>; + getAllNested: (folders?: FolderView[]) => Promise[]>; getNested: (id: string) => Promise>; saveWithServer: (folder: Folder) => Promise; upsert: (folder: FolderData | FolderData[]) => Promise; diff --git a/common/src/models/domain/organization.ts b/common/src/models/domain/organization.ts index fe38c3c8540..ed09167b3c4 100644 --- a/common/src/models/domain/organization.ts +++ b/common/src/models/domain/organization.ts @@ -1,5 +1,6 @@ import { OrganizationUserStatusType } from "../../enums/organizationUserStatusType"; import { OrganizationUserType } from "../../enums/organizationUserType"; +import { Permissions } from "../../enums/permissions"; import { ProductType } from "../../enums/productType"; import { PermissionsApi } from "../api/permissionsApi"; import { OrganizationData } from "../data/organizationData"; @@ -182,6 +183,28 @@ export class Organization { return this.canManagePolicies; } + hasAnyPermission(permissions: Permissions[]) { + const specifiedPermissions = + (permissions.includes(Permissions.AccessEventLogs) && this.canAccessEventLogs) || + (permissions.includes(Permissions.AccessImportExport) && this.canAccessImportExport) || + (permissions.includes(Permissions.AccessReports) && this.canAccessReports) || + (permissions.includes(Permissions.CreateNewCollections) && this.canCreateNewCollections) || + (permissions.includes(Permissions.EditAnyCollection) && this.canEditAnyCollection) || + (permissions.includes(Permissions.DeleteAnyCollection) && this.canDeleteAnyCollection) || + (permissions.includes(Permissions.EditAssignedCollections) && + this.canEditAssignedCollections) || + (permissions.includes(Permissions.DeleteAssignedCollections) && + this.canDeleteAssignedCollections) || + (permissions.includes(Permissions.ManageGroups) && this.canManageGroups) || + (permissions.includes(Permissions.ManageOrganization) && this.isOwner) || + (permissions.includes(Permissions.ManagePolicies) && this.canManagePolicies) || + (permissions.includes(Permissions.ManageUsers) && this.canManageUsers) || + (permissions.includes(Permissions.ManageUsersPassword) && this.canManageUsersPassword) || + (permissions.includes(Permissions.ManageSso) && this.canManageSso); + + return specifiedPermissions && (this.enabled || this.isOwner); + } + get canManageBilling() { return this.isOwner && (this.isProviderUser || !this.hasProvider); } diff --git a/common/src/models/response/billingHistoryResponse.ts b/common/src/models/response/billingHistoryResponse.ts new file mode 100644 index 00000000000..c7dead81f56 --- /dev/null +++ b/common/src/models/response/billingHistoryResponse.ts @@ -0,0 +1,23 @@ +import { BaseResponse } from "./baseResponse"; +import { BillingInvoiceResponse, BillingTransactionResponse } from "./billingResponse"; + +export class BillingHistoryResponse extends BaseResponse { + invoices: BillingInvoiceResponse[] = []; + transactions: BillingTransactionResponse[] = []; + + constructor(response: any) { + super(response); + const transactions = this.getResponseProperty("Transactions"); + const invoices = this.getResponseProperty("Invoices"); + if (transactions != null) { + this.transactions = transactions.map((t: any) => new BillingTransactionResponse(t)); + } + if (invoices != null) { + this.invoices = invoices.map((i: any) => new BillingInvoiceResponse(i)); + } + } + + get hasNoHistory() { + return this.invoices.length == 0 && this.transactions.length == 0; + } +} diff --git a/common/src/models/response/billingPaymentResponse.ts b/common/src/models/response/billingPaymentResponse.ts new file mode 100644 index 00000000000..869e5a0d3f7 --- /dev/null +++ b/common/src/models/response/billingPaymentResponse.ts @@ -0,0 +1,14 @@ +import { BaseResponse } from "./baseResponse"; +import { BillingSourceResponse } from "./billingResponse"; + +export class BillingPaymentResponse extends BaseResponse { + balance: number; + paymentSource: BillingSourceResponse; + + constructor(response: any) { + super(response); + this.balance = this.getResponseProperty("Balance"); + const paymentSource = this.getResponseProperty("PaymentSource"); + this.paymentSource = paymentSource == null ? null : new BillingSourceResponse(paymentSource); + } +} diff --git a/common/src/services/api.service.ts b/common/src/services/api.service.ts index da2fcd5d64e..afd388fbdc5 100644 --- a/common/src/services/api.service.ts +++ b/common/src/services/api.service.ts @@ -1,6 +1,8 @@ import { AppIdService } from "jslib-common/abstractions/appId.service"; import { DeviceRequest } from "jslib-common/models/request/deviceRequest"; import { TokenRequestTwoFactor } from "jslib-common/models/request/identityToken/tokenRequestTwoFactor"; +import { BillingHistoryResponse } from "jslib-common/models/response/billingHistoryResponse"; +import { BillingPaymentResponse } from "jslib-common/models/response/billingPaymentResponse"; import { ApiService as ApiServiceAbstraction } from "../abstractions/api.service"; import { EnvironmentService } from "../abstractions/environment.service"; @@ -280,11 +282,6 @@ export class ApiService implements ApiServiceAbstraction { return new ProfileResponse(r); } - async getUserBilling(): Promise { - const r = await this.send("GET", "/accounts/billing", null, true, true); - return new BillingResponse(r); - } - async getUserSubscription(): Promise { const r = await this.send("GET", "/accounts/subscription", null, true, true); return new SubscriptionResponse(r); @@ -467,6 +464,18 @@ export class ApiService implements ApiServiceAbstraction { return this.send("POST", "/accounts/convert-to-key-connector", null, true, false); } + // Account Billing APIs + + async getUserBillingHistory(): Promise { + const r = await this.send("GET", "/accounts/billing/history", null, true, true); + return new BillingHistoryResponse(r); + } + + async getUserBillingPayment(): Promise { + const r = await this.send("GET", "/accounts/billing/payment-method", null, true, true); + return new BillingPaymentResponse(r); + } + // Folder APIs async getFolder(id: string): Promise { diff --git a/common/src/services/folder.service.ts b/common/src/services/folder.service.ts index 47db0cb7357..f791c07f4d2 100644 --- a/common/src/services/folder.service.ts +++ b/common/src/services/folder.service.ts @@ -88,8 +88,8 @@ export class FolderService implements FolderServiceAbstraction { return decFolders; } - async getAllNested(): Promise[]> { - const folders = await this.getAllDecrypted(); + async getAllNested(folders?: FolderView[]): Promise[]> { + folders = folders ?? (await this.getAllDecrypted()); const nodes: TreeNode[] = []; folders.forEach((f) => { const folderCopy = new FolderView(); diff --git a/components/.storybook/preview.js b/components/.storybook/preview.js index 98d2d5b833b..8bc2510a082 100644 --- a/components/.storybook/preview.js +++ b/components/.storybook/preview.js @@ -15,10 +15,18 @@ export const parameters = { docs: { inlineStories: true }, }; +// ng-template is used to scope any template reference variables and isolate the previews const decorator = componentWrapperDecorator( (story) => ` -
${story}
-
${story}
+ +
${story}
+
+ +
${story}
+
+ + + ` ); diff --git a/components/package-lock.json b/components/package-lock.json index 5064c216fac..a817f8b29c5 100644 --- a/components/package-lock.json +++ b/components/package-lock.json @@ -18,6 +18,7 @@ "@angular/platform-browser-dynamic": "^12.2.13", "@bitwarden/jslib-angular": "file:../angular", "bootstrap": "4.6.0", + "rxjs": "^7.4.0", "tslib": "^2.3.0" }, "devDependencies": { diff --git a/components/package.json b/components/package.json index 42682325e55..a9a401f849f 100644 --- a/components/package.json +++ b/components/package.json @@ -24,7 +24,8 @@ "@angular/platform-browser-dynamic": "^12.2.13", "@bitwarden/jslib-angular": "file:../angular", "bootstrap": "4.6.0", - "tslib": "^2.3.0" + "tslib": "^2.3.0", + "rxjs": "^7.4.0" }, "devDependencies": { "@angular-devkit/build-angular": "^12.2.13", diff --git a/components/src/index.ts b/components/src/index.ts index f8fef9a94c3..ac56e57a09d 100644 --- a/components/src/index.ts +++ b/components/src/index.ts @@ -2,3 +2,4 @@ export * from "./badge"; export * from "./banner"; export * from "./button"; export * from "./callout"; +export * from "./menu"; diff --git a/components/src/menu/index.ts b/components/src/menu/index.ts new file mode 100644 index 00000000000..69b71b8ee24 --- /dev/null +++ b/components/src/menu/index.ts @@ -0,0 +1,5 @@ +export * from "./menu.module"; +export * from "./menu.component"; +export * from "./menu-trigger-for.directive"; +export * from "./menu-item.component"; +export * from "./menu-divider.component"; diff --git a/components/src/menu/menu-divider.component.html b/components/src/menu/menu-divider.component.html new file mode 100644 index 00000000000..7cc020ef50a --- /dev/null +++ b/components/src/menu/menu-divider.component.html @@ -0,0 +1,4 @@ + diff --git a/components/src/menu/menu-divider.component.ts b/components/src/menu/menu-divider.component.ts new file mode 100644 index 00000000000..194506ee50f --- /dev/null +++ b/components/src/menu/menu-divider.component.ts @@ -0,0 +1,7 @@ +import { Component } from "@angular/core"; + +@Component({ + selector: "bit-menu-divider", + templateUrl: "./menu-divider.component.html", +}) +export class MenuDividerComponent {} diff --git a/components/src/menu/menu-item.component.ts b/components/src/menu/menu-item.component.ts new file mode 100644 index 00000000000..9520b6483a7 --- /dev/null +++ b/components/src/menu/menu-item.component.ts @@ -0,0 +1,37 @@ +import { FocusableOption } from "@angular/cdk/a11y"; +import { Component, ElementRef, HostBinding } from "@angular/core"; + +@Component({ + selector: "[bit-menu-item]", + template: ``, +}) +export class MenuItemComponent implements FocusableOption { + @HostBinding("class") classList = [ + "tw-block", + "tw-py-1", + "tw-px-4", + "!tw-text-main", + "!tw-no-underline", + "tw-cursor-pointer", + "tw-border-none", + "tw-bg-background", + "tw-text-left", + "hover:tw-bg-secondary-100", + "focus:tw-bg-secondary-100", + "focus:tw-z-50", + "focus:tw-outline-none", + "focus:tw-ring", + "focus:tw-ring-offset-2", + "focus:tw-ring-primary-700", + "active:!tw-ring-0", + "active:!tw-ring-offset-0", + ].join(" "); + @HostBinding("attr.role") role = "menuitem"; + @HostBinding("tabIndex") tabIndex = "-1"; + + constructor(private elementRef: ElementRef) {} + + focus() { + this.elementRef.nativeElement.focus(); + } +} diff --git a/components/src/menu/menu-trigger-for.directive.ts b/components/src/menu/menu-trigger-for.directive.ts new file mode 100644 index 00000000000..059e6f812e6 --- /dev/null +++ b/components/src/menu/menu-trigger-for.directive.ts @@ -0,0 +1,119 @@ +import { Overlay, OverlayConfig, OverlayRef } from "@angular/cdk/overlay"; +import { TemplatePortal } from "@angular/cdk/portal"; +import { + Directive, + ElementRef, + HostBinding, + HostListener, + Input, + OnDestroy, + ViewContainerRef, +} from "@angular/core"; +import { Observable, Subscription } from "rxjs"; +import { filter, mergeWith } from "rxjs/operators"; + +import { MenuComponent } from "./menu.component"; + +@Directive({ + selector: "[bitMenuTriggerFor]", +}) +export class MenuTriggerForDirective implements OnDestroy { + @HostBinding("attr.aria-expanded") isOpen = false; + @HostBinding("attr.aria-haspopup") hasPopup = "menu"; + @HostBinding("attr.role") role = "button"; + + @Input("bitMenuTriggerFor") menu: MenuComponent; + + private overlayRef: OverlayRef; + private defaultMenuConfig: OverlayConfig = { + panelClass: "bit-menu-panel", + hasBackdrop: true, + backdropClass: "cdk-overlay-transparent-backdrop", + scrollStrategy: this.overlay.scrollStrategies.reposition(), + positionStrategy: this.overlay + .position() + .flexibleConnectedTo(this.elementRef) + .withPositions([ + { + originX: "start", + originY: "bottom", + overlayX: "start", + overlayY: "top", + }, + { + originX: "end", + originY: "bottom", + overlayX: "end", + overlayY: "top", + }, + ]) + .withLockedPosition(true) + .withFlexibleDimensions(false) + .withPush(false), + }; + private closedEventsSub: Subscription; + private keyDownEventsSub: Subscription; + + constructor( + private elementRef: ElementRef, + private viewContainerRef: ViewContainerRef, + private overlay: Overlay + ) {} + + @HostListener("click") toggleMenu() { + this.isOpen ? this.destroyMenu() : this.openMenu(); + } + + ngOnDestroy() { + this.disposeAll(); + } + + private openMenu() { + if (this.menu == null) { + throw new Error("Cannot find bit-menu element"); + } + + this.isOpen = true; + this.overlayRef = this.overlay.create(this.defaultMenuConfig); + + const templatePortal = new TemplatePortal(this.menu.templateRef, this.viewContainerRef); + this.overlayRef.attach(templatePortal); + + this.closedEventsSub = this.getClosedEvents().subscribe((event: KeyboardEvent | undefined) => { + if (event?.key === "Tab") { + // Required to ensure tab order resumes correctly + this.elementRef.nativeElement.focus(); + } + this.destroyMenu(); + }); + this.keyDownEventsSub = this.overlayRef + .keydownEvents() + .subscribe((event: KeyboardEvent) => this.menu.keyManager.onKeydown(event)); + } + + private destroyMenu() { + if (this.overlayRef == null || !this.isOpen) { + return; + } + + this.isOpen = false; + this.disposeAll(); + } + + private getClosedEvents(): Observable { + const detachments = this.overlayRef.detachments(); + const escKey = this.overlayRef + .keydownEvents() + .pipe(filter((event: KeyboardEvent) => event.key === "Escape" || event.key === "Tab")); + const backdrop = this.overlayRef.backdropClick(); + const menuClosed = this.menu.closed; + + return detachments.pipe(mergeWith(escKey, backdrop, menuClosed)); + } + + private disposeAll() { + this.closedEventsSub?.unsubscribe(); + this.overlayRef?.dispose(); + this.keyDownEventsSub?.unsubscribe(); + } +} diff --git a/components/src/menu/menu.component.html b/components/src/menu/menu.component.html new file mode 100644 index 00000000000..8123c7be408 --- /dev/null +++ b/components/src/menu/menu.component.html @@ -0,0 +1,9 @@ + + + diff --git a/components/src/menu/menu.component.spec.ts b/components/src/menu/menu.component.spec.ts new file mode 100644 index 00000000000..ca412e33990 --- /dev/null +++ b/components/src/menu/menu.component.spec.ts @@ -0,0 +1,77 @@ +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; + +import { MenuTriggerForDirective } from "./menu-trigger-for.directive"; + +import { MenuModule } from "./index"; + +describe("Menu", () => { + let fixture: ComponentFixture; + const getMenuTriggerDirective = () => { + const buttonDebugElement = fixture.debugElement.query(By.directive(MenuTriggerForDirective)); + return buttonDebugElement.injector.get(MenuTriggerForDirective); + }; + + // The overlay is created outside the root debugElement, so we need to query its parent + const getBitMenuPanel = () => fixture.debugElement.parent.query(By.css(".bit-menu-panel")); + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [MenuModule], + declarations: [TestApp], + }); + + TestBed.compileComponents(); + + fixture = TestBed.createComponent(TestApp); + fixture.detectChanges(); + }) + ); + + it("should open when the trigger is clicked", () => { + const buttonDebugElement = fixture.debugElement.query(By.directive(MenuTriggerForDirective)); + (buttonDebugElement.nativeElement as HTMLButtonElement).click(); + + expect(getBitMenuPanel()).toBeTruthy(); + }); + + it("should close when the trigger is clicked", () => { + getMenuTriggerDirective().toggleMenu(); + + const buttonDebugElement = fixture.debugElement.query(By.directive(MenuTriggerForDirective)); + (buttonDebugElement.nativeElement as HTMLButtonElement).click(); + + expect(getBitMenuPanel()).toBeFalsy(); + }); + + it("should close when a menu item is clicked", () => { + getMenuTriggerDirective().toggleMenu(); + + fixture.debugElement.parent.query(By.css("#item1")).nativeElement.click(); + + expect(getBitMenuPanel()).toBeFalsy(); + }); + + it("should close when the backdrop is clicked", () => { + getMenuTriggerDirective().toggleMenu(); + + fixture.debugElement.parent.query(By.css(".cdk-overlay-backdrop")).nativeElement.click(); + + expect(getBitMenuPanel()).toBeFalsy(); + }); +}); + +@Component({ + selector: "test-app", + template: ` + + + + Item 1 + Item 2 + + `, +}) +class TestApp {} diff --git a/components/src/menu/menu.component.ts b/components/src/menu/menu.component.ts new file mode 100644 index 00000000000..29a9862f72f --- /dev/null +++ b/components/src/menu/menu.component.ts @@ -0,0 +1,30 @@ +import { FocusKeyManager } from "@angular/cdk/a11y"; +import { + Component, + Output, + TemplateRef, + ViewChild, + EventEmitter, + ContentChildren, + QueryList, + AfterContentInit, +} from "@angular/core"; + +import { MenuItemComponent } from "./menu-item.component"; + +@Component({ + selector: "bit-menu", + templateUrl: "./menu.component.html", + exportAs: "menuComponent", +}) +export class MenuComponent implements AfterContentInit { + @ViewChild(TemplateRef) templateRef: TemplateRef; + @Output() closed = new EventEmitter(); + @ContentChildren(MenuItemComponent, { descendants: true }) + menuItems: QueryList; + keyManager: FocusKeyManager; + + ngAfterContentInit() { + this.keyManager = new FocusKeyManager(this.menuItems).withWrap(); + } +} diff --git a/components/src/menu/menu.module.ts b/components/src/menu/menu.module.ts new file mode 100644 index 00000000000..53575a35354 --- /dev/null +++ b/components/src/menu/menu.module.ts @@ -0,0 +1,15 @@ +import { OverlayModule } from "@angular/cdk/overlay"; +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; + +import { MenuDividerComponent } from "./menu-divider.component"; +import { MenuItemComponent } from "./menu-item.component"; +import { MenuTriggerForDirective } from "./menu-trigger-for.directive"; +import { MenuComponent } from "./menu.component"; + +@NgModule({ + imports: [CommonModule, OverlayModule], + declarations: [MenuComponent, MenuTriggerForDirective, MenuItemComponent, MenuDividerComponent], + exports: [MenuComponent, MenuTriggerForDirective, MenuItemComponent, MenuDividerComponent], +}) +export class MenuModule {} diff --git a/components/src/menu/menu.stories.ts b/components/src/menu/menu.stories.ts new file mode 100644 index 00000000000..3f296ee8b02 --- /dev/null +++ b/components/src/menu/menu.stories.ts @@ -0,0 +1,69 @@ +import { OverlayModule } from "@angular/cdk/overlay"; +import { Meta, moduleMetadata, Story } from "@storybook/angular"; + +import { ButtonModule } from "../button/button.module"; + +import { MenuDividerComponent } from "./menu-divider.component"; +import { MenuItemComponent } from "./menu-item.component"; +import { MenuTriggerForDirective } from "./menu-trigger-for.directive"; +import { MenuComponent } from "./menu.component"; + +export default { + title: "Jslib/Menu", + component: MenuTriggerForDirective, + decorators: [ + moduleMetadata({ + declarations: [ + MenuTriggerForDirective, + MenuComponent, + MenuItemComponent, + MenuDividerComponent, + ], + imports: [OverlayModule, ButtonModule], + }), + ], + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/file/f32LSg3jaegICkMu7rPARm/Tailwind-Component-Library-Update?node-id=1881%3A17952", + }, + }, +} as Meta; + +const Template: Story = (args: MenuTriggerForDirective) => ({ + props: args, + template: ` + + Anchor link + Another link + + + + + +
+
+ +
+
+ `, +}); + +const TemplateWithButton: Story = (args: MenuTriggerForDirective) => ({ + props: args, + template: ` +
+ +
+ + + Anchor link + Another link + + + + `, +}); + +export const OpenMenu = Template.bind({}); +export const ClosedMenu = TemplateWithButton.bind({}); diff --git a/components/src/styles.scss b/components/src/styles.scss index 8729b921b8a..401b801e0e1 100644 --- a/components/src/styles.scss +++ b/components/src/styles.scss @@ -3,6 +3,8 @@ @import "../../angular/src/scss/bwicons/styles/style.scss"; @import "../../angular/src/scss/icons.scss"; +@import "@angular/cdk/overlay-prebuilt.css"; + @import "~bootstrap/scss/_functions"; @import "~bootstrap/scss/_variables"; @import "~bootstrap/scss/_mixins";