From d776886c9d3a0a6ee510b4b45407672069ca1882 Mon Sep 17 00:00:00 2001 From: Georgii Rychko Date: Sun, 19 Nov 2017 23:28:14 +0200 Subject: [PATCH] feat(node-menu): bring custom menu items to the node menu. Closes #48, closes #53, closes #25, closes #161 (#170) --- README.md | 7 +++ index.ts | 10 ++++- package.json | 10 ++--- src/demo/app/app.component.ts | 18 +++++++- src/menu/menu.events.ts | 4 +- src/menu/node-menu.component.ts | 14 ++++-- src/tree-internal.component.ts | 35 ++++++++++++--- src/tree.component.ts | 13 ++++-- src/tree.events.ts | 6 +++ src/tree.service.ts | 8 +++- src/tree.ts | 16 +++++++ src/tree.types.ts | 8 ++++ test/menu/node-menu.component.spec.ts | 2 +- test/tree.service.spec.ts | 31 ++++++++++--- test/tree.spec.ts | 63 ++++++++++++++++++++++++++- tslint.json | 5 +-- umd-bundler.js | 4 +- 17 files changed, 216 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index b0f0d6b5..6bdf0908 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,12 @@ Here is an example of its usage: 'node': '', 'leaf': '', 'leftMenu': '' + }, + 'menuItems': [ + { action: NodeMenuItemAction.Custom, name: 'Foo', cssClass: 'fa fa-arrow-right' }, + { action: NodeMenuItemAction.Custom, name: 'Bar', cssClass: 'fa fa-arrow-right' }, + { action: NodeMenuItemAction.Custom, name: 'Baz', cssClass: 'fa fa-arrow-right'} + ] } }, children: [ @@ -358,6 +364,7 @@ Here is an example of its usage: * `node` - String - It specifies a html template which will be included to the left of the node's value. * `leaf` - String - It specifies a html template which will be included to the left of the leaf's value. * `leftMenu` - String - It specifies a html template to the right of the node's value. This template becomes clickable and shows a menu on node's click. +* `menuItems` - here you can specify your custom menu items. You should feed an array of NodeMenuItem instances to this setting. Once done - setup a subscription to `MenuItemSelectedEvent`s by listening to `(menuItemSelected)="onMenuItemSelected($event)"` on the tree. All options that are defined on a `parent` are automatically applied to children. If you want you can override them by `settings` of the child node. diff --git a/index.ts b/index.ts index 466c1b7a..166a12ba 100644 --- a/index.ts +++ b/index.ts @@ -9,6 +9,9 @@ import { import { Tree } from './src/tree'; +import { NodeMenuItemAction } from './src/menu/menu.events'; +import { NodeMenuItem } from './src/menu/node-menu.component'; + import { NodeEvent, NodeCreatedEvent, @@ -18,6 +21,7 @@ import { NodeSelectedEvent, NodeExpandedEvent, NodeCollapsedEvent, + MenuItemSelectedEvent, NodeDestructiveEvent } from './src/tree.events'; @@ -41,5 +45,9 @@ export { NodeCollapsedEvent, NodeDestructiveEvent, TreeComponent, - TreeModule + TreeModule, + NodeMenuItemAction, + NodeMenuItem, + ChildrenLoadingFunction, + MenuItemSelectedEvent }; diff --git a/package.json b/package.json index 0693578b..b9f02ef4 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "@types/jasmine": "2.8.2", "@types/node": "8.0.53", "alertifyjs": "1.10.0", - "codelyzer": "3.0.1", + "codelyzer": "4.0.1", "conventional-changelog": "1.1.7", "conventional-changelog-cli": "1.3.5", "conventional-github-releaser": "2.0.0", @@ -88,13 +88,11 @@ "shelljs": "0.7.8", "systemjs-builder": "0.16.12", "ts-node": "3.3.0", - "tslint": "5.4.3", - "tslint-config-valorsoft": "2.0.1", + "tslint": "5.8.0", + "tslint-config-valorsoft": "2.1.1", "typescript": "2.4.2", + "uuid": "3.1.0", "webpack": "3.8.1", "zone.js": "0.8.18" - }, - "dependencies": { - "uuid": "3.1.0" } } diff --git a/src/demo/app/app.component.ts b/src/demo/app/app.component.ts index 3d81c755..e335df66 100644 --- a/src/demo/app/app.component.ts +++ b/src/demo/app/app.component.ts @@ -1,5 +1,7 @@ import { Component, OnInit, ViewChild } from '@angular/core'; -import { NodeEvent, TreeModel, RenamableNode, Ng2TreeSettings } from '../../../index'; +import { Ng2TreeSettings, NodeEvent, RenamableNode, TreeModel } from '../../../index'; +import { NodeMenuItemAction } from '../../menu/menu.events'; +import { MenuItemSelectedEvent } from '../../tree.events'; declare const alertify: any; @@ -12,6 +14,7 @@ declare const alertify: any;
= new EventEmitter(); + @Input() + public menuItems: NodeMenuItem[]; + @ViewChild('menuContainer') public menuContainer: any; public availableMenuItems: NodeMenuItem[] = [ @@ -55,6 +58,7 @@ export class NodeMenuComponent implements OnInit, OnDestroy { } public ngOnInit(): void { + this.availableMenuItems = this.menuItems || this.availableMenuItems; this.disposersForGlobalListeners.push(this.renderer.listen('document', 'keyup', this.closeMenu.bind(this))); this.disposersForGlobalListeners.push(this.renderer.listen('document', 'mousedown', this.closeMenu.bind(this))); } @@ -65,7 +69,11 @@ export class NodeMenuComponent implements OnInit, OnDestroy { public onMenuItemSelected(e: MouseEvent, selectedMenuItem: NodeMenuItem): void { if (isLeftButtonClicked(e)) { - this.menuItemSelected.emit({nodeMenuItemAction: selectedMenuItem.action}); + this.menuItemSelected.emit({ + nodeMenuItemAction: selectedMenuItem.action, + nodeMenuItemSelected: selectedMenuItem.name + }); + this.nodeMenuService.fireMenuEvent(e.target as HTMLElement, NodeMenuAction.Close); } } @@ -84,5 +92,5 @@ export class NodeMenuComponent implements OnInit, OnDestroy { export interface NodeMenuItem { name: string; action: NodeMenuItemAction; - cssClass: string; + cssClass?: string; } diff --git a/src/tree-internal.component.ts b/src/tree-internal.component.ts index 98d9ee8b..874c73fe 100644 --- a/src/tree-internal.component.ts +++ b/src/tree-internal.component.ts @@ -1,4 +1,13 @@ -import { Component, ElementRef, TemplateRef, Inject, Input, OnDestroy, OnInit, OnChanges, SimpleChanges } from '@angular/core'; +import { + Component, + ElementRef, + Input, + OnChanges, + OnDestroy, + OnInit, + SimpleChanges, + TemplateRef +} from '@angular/core'; import * as TreeTypes from './tree.types'; import { Tree } from './tree'; import { TreeController } from './tree-controller'; @@ -41,13 +50,19 @@ import { get } from './utils/fn.utils';
-
- + + + + @@ -72,9 +87,9 @@ export class TreeInternalComponent implements OnInit, OnChanges, OnDestroy { private subscriptions: Subscription[] = []; - public constructor(@Inject(NodeMenuService) private nodeMenuService: NodeMenuService, - @Inject(TreeService) public treeService: TreeService, - @Inject(ElementRef) public element: ElementRef) { + public constructor(private nodeMenuService: NodeMenuService, + public treeService: TreeService, + public element: ElementRef) { } public ngOnInit(): void { @@ -84,7 +99,6 @@ export class TreeInternalComponent implements OnInit, OnChanges, OnDestroy { } this.settings = this.settings || { rootIsVisible: true }; - this.subscriptions.push(this.nodeMenuService.hideMenuStream(this.element) .subscribe(() => { this.isRightMenuVisible = false; @@ -182,6 +196,9 @@ export class TreeInternalComponent implements OnInit, OnChanges, OnDestroy { case NodeMenuItemAction.Remove: this.onRemoveSelected(); break; + case NodeMenuItemAction.Custom: + this.treeService.fireMenuItemSelected(this.tree, e.nodeMenuItemSelected); + break; default: throw new Error(`Chosen menu item doesn't exist`); } @@ -235,4 +252,8 @@ export class TreeInternalComponent implements OnInit, OnChanges, OnDestroy { public isRootHidden(): boolean { return this.tree.isRoot() && !this.settings.rootIsVisible; } + + public hasCustomMenu(): boolean { + return this.tree.hasCustomMenu(); + } } diff --git a/src/tree.component.ts b/src/tree.component.ts index 58409faa..c5540830 100644 --- a/src/tree.component.ts +++ b/src/tree.component.ts @@ -4,7 +4,7 @@ import { } from '@angular/core'; import { TreeService } from './tree.service'; import * as TreeTypes from './tree.types'; -import { NodeEvent } from './tree.events'; +import { NodeEvent, MenuItemSelectedEvent } from './tree.events'; import { Tree } from './tree'; import { TreeController } from './tree-controller'; import { Subscription } from 'rxjs/Subscription'; @@ -46,8 +46,11 @@ export class TreeComponent implements OnInit, OnChanges, OnDestroy { @Output() public nodeCollapsed: EventEmitter = new EventEmitter(); -@Output() -public loadNextLevel: EventEmitter = new EventEmitter(); + @Output() + public menuItemSelected: EventEmitter = new EventEmitter(); + + @Output() + public loadNextLevel: EventEmitter = new EventEmitter(); public tree: Tree; @ViewChild('rootComponent') public rootComponent; @@ -96,6 +99,10 @@ public loadNextLevel: EventEmitter = new EventEmitter(); this.nodeCollapsed.emit(e); })); + this.subscriptions.push(this.treeService.menuItemSelected$.subscribe((e: MenuItemSelectedEvent) => { + this.menuItemSelected.emit(e); + })); + this.subscriptions.push(this.treeService.loadNextLevel$.subscribe((e: NodeEvent) => { this.loadNextLevel.emit(e); })); diff --git a/src/tree.events.ts b/src/tree.events.ts index ac546b68..63b3e73f 100644 --- a/src/tree.events.ts +++ b/src/tree.events.ts @@ -55,6 +55,12 @@ export class NodeCollapsedEvent extends NodeEvent { } } +export class MenuItemSelectedEvent extends NodeEvent { + public constructor(node: Tree, public selectedItem: string) { + super(node); + } +} + export class LoadNextLevelEvent extends NodeEvent { public constructor(node: Tree) { super(node); diff --git a/src/tree.service.ts b/src/tree.service.ts index 5b3f30cb..1802844c 100644 --- a/src/tree.service.ts +++ b/src/tree.service.ts @@ -6,6 +6,7 @@ import { NodeRemovedEvent, NodeRenamedEvent, NodeSelectedEvent, + MenuItemSelectedEvent, LoadNextLevelEvent } from './tree.events'; import { RenamableNode } from './tree.types'; @@ -15,7 +16,7 @@ import { Observable, Subject } from 'rxjs/Rx'; import { ElementRef, Inject, Injectable } from '@angular/core'; import { NodeDraggableService } from './draggable/node-draggable.service'; import { NodeDraggableEvent } from './draggable/draggable.events'; -import {isEmpty} from './utils/fn.utils'; +import { isEmpty } from './utils/fn.utils'; @Injectable() export class TreeService { @@ -26,6 +27,7 @@ export class TreeService { public nodeSelected$: Subject = new Subject(); public nodeExpanded$: Subject = new Subject(); public nodeCollapsed$: Subject = new Subject(); + public menuItemSelected$: Subject = new Subject(); public loadNextLevel$: Subject = new Subject(); private controllers: Map = new Map(); @@ -58,6 +60,10 @@ export class TreeService { this.nodeMoved$.next(new NodeMovedEvent(tree, parent)); } + public fireMenuItemSelected(tree: Tree, selectedItem: string): void { + this.menuItemSelected$.next(new MenuItemSelectedEvent(tree, selectedItem)); + } + public fireNodeSwitchFoldingType(tree: Tree): void { if (tree.isNodeExpanded()) { this.fireNodeExpanded(tree); diff --git a/src/tree.ts b/src/tree.ts index ff925fcc..21b3bb4c 100644 --- a/src/tree.ts +++ b/src/tree.ts @@ -14,6 +14,7 @@ import { import { Observable, Observer } from 'rxjs/Rx'; import { TreeModel, RenamableNode, FoldingType, TreeStatus, TreeModelSettings, ChildrenLoadingFunction } from './tree.types'; +import { NodeMenuItem } from './menu/node-menu.component'; import * as uuidv4 from 'uuid/v4'; @@ -343,6 +344,21 @@ export class Tree { return !this.isBranch(); } + /** + * Get menu items of the current tree. + * @returns {NodeMenuItem[]} The menu items of the current tree. + */ + public get menuItems(): NodeMenuItem[] { + return get(this.node.settings, 'menuItems'); + } + + /** + * Check whether or not this tree has a custom menu. + * @returns {boolean} A flag indicating whether or not this tree has a custom menu. + */ + public hasCustomMenu(): boolean { + return !this.isStatic() && !!get(this.node.settings, 'menuItems', false); + } /** * Check whether this tree is "Branch" or not. "Branch" is a node that has children. * @returns {boolean} A flag indicating whether or not this tree is a "Branch". diff --git a/src/tree.types.ts b/src/tree.types.ts index febd5501..3633afeb 100644 --- a/src/tree.types.ts +++ b/src/tree.types.ts @@ -1,4 +1,5 @@ import { get, defaultsDeep } from './utils/fn.utils'; +import { NodeMenuItem } from './menu/node-menu.component'; export class FoldingType { public static Expanded: FoldingType = new FoldingType('node-expanded'); @@ -76,6 +77,13 @@ export class TreeModelSettings { */ public rightMenu?: boolean; + /** + * "menu" property when set will be available as custom context menu. + * @name TreeModelSettings#MenuItems + * @type NodeMenuItem + */ + public menuItems?: NodeMenuItem[]; + /** * "static" property when set to true makes it impossible to drag'n'drop tree or call a menu on it. * @name TreeModelSettings#static diff --git a/test/menu/node-menu.component.spec.ts b/test/menu/node-menu.component.spec.ts index fffbc399..2f0e52ea 100644 --- a/test/menu/node-menu.component.spec.ts +++ b/test/menu/node-menu.component.spec.ts @@ -106,7 +106,7 @@ describe('NodeMenuComponent', () => { menuItem.triggerEventHandler('click', event); - expect(componentInstance.menuItemSelected.emit).toHaveBeenCalledWith({nodeMenuItemAction: NodeMenuItemAction.NewTag}); + expect(componentInstance.menuItemSelected.emit).toHaveBeenCalledWith({nodeMenuItemAction: NodeMenuItemAction.NewTag, nodeMenuItemSelected: 'New tag'}); }); it('should close menu on any click outside of it', () => { diff --git a/test/tree.service.spec.ts b/test/tree.service.spec.ts index d16f2455..f7c0a71f 100644 --- a/test/tree.service.spec.ts +++ b/test/tree.service.spec.ts @@ -3,16 +3,15 @@ import { TreeService } from '../src/tree.service'; import { Subject } from 'rxjs/Rx'; import { NodeDraggableService } from '../src/draggable/node-draggable.service'; import { Tree } from '../src/tree'; -import { TreeController } from '../src/tree-controller'; -import { TreeInternalComponent } from '../src/tree-internal.component'; import { - NodeRemovedEvent, - NodeMovedEvent, + MenuItemSelectedEvent, + NodeCollapsedEvent, NodeCreatedEvent, - NodeSelectedEvent, - NodeRenamedEvent, NodeExpandedEvent, - NodeCollapsedEvent + NodeMovedEvent, + NodeRemovedEvent, + NodeRenamedEvent, + NodeSelectedEvent } from '../src/tree.events'; import { ElementRef } from '@angular/core'; import { NodeDraggableEvent } from '../src/draggable/draggable.events'; @@ -378,4 +377,22 @@ describe('TreeService', () => { expect(treeService.loadNextLevel$.next).not.toHaveBeenCalled(); }); + + it('not fires "loadNextLevel" event if "emitLoadNextLevel" is false', () => { + const masterTree = new Tree({ + value: 'Master', + }); + + spyOn(treeService.menuItemSelected$, 'next'); + + treeService.fireMenuItemSelected(masterTree, 'CustomMenu'); + + expect(treeService.menuItemSelected$.next).toHaveBeenCalledWith(new MenuItemSelectedEvent(masterTree, 'CustomMenu')); + }); + + it('return null if there is not controller for the given id', () => { + const controller = treeService.getController('#2'); + + expect(controller).toBeNull(); + }); }); diff --git a/test/tree.spec.ts b/test/tree.spec.ts index 4165387b..30a68a95 100644 --- a/test/tree.spec.ts +++ b/test/tree.spec.ts @@ -1,5 +1,6 @@ import { Tree } from '../src/tree'; -import { TreeModel, TreeModelSettings, FoldingType, CssClasses, ChildrenLoadingFunction } from '../src/tree.types'; +import { FoldingType, TreeModel, TreeModelSettings } from '../src/tree.types'; +import { NodeMenuItemAction } from '../src/menu/menu.events'; describe('Tree', () => { it('should detect empty string', () => { @@ -1153,4 +1154,64 @@ describe('Tree', () => { expect(tree.toTreeModel()).toEqual(model); }); + + it('has an access to menu items', () => { + + const model: TreeModel = { + id: 42, + value: 'root', + settings: { + menuItems: [ + { + action: NodeMenuItemAction.Custom, + name: 'FooMenuItem', + cssClass: 'fooMenuItemCss' + } + ] + } + }; + + const tree: Tree = new Tree(model); + + expect(tree.hasCustomMenu()).toBe(true); + expect(tree.menuItems).toEqual([{ + action: NodeMenuItemAction.Custom, + name: 'FooMenuItem', + cssClass: 'fooMenuItemCss' + }]); + }); + + it('static nodes cannot have custom menu', () => { + + const model: TreeModel = { + id: 42, + value: 'root', + settings: { + static: true, + menuItems: [ + { + action: NodeMenuItemAction.Custom, + name: 'FooMenuItem', + cssClass: 'fooMenuItemCss' + } + ] + } + }; + + const tree: Tree = new Tree(model); + + expect(tree.hasCustomMenu()).toBe(false); + }); + + it('does not have custom menu without menu items', () => { + + const model: TreeModel = { + id: 42, + value: 'root' + }; + + const tree: Tree = new Tree(model); + + expect(tree.hasCustomMenu()).toBe(false); + }); }); diff --git a/tslint.json b/tslint.json index f5160145..d78d28cc 100644 --- a/tslint.json +++ b/tslint.json @@ -110,9 +110,6 @@ "use-life-cycle-interface": true, "use-pipe-transform-interface": true, "component-class-suffix": true, - "directive-class-suffix": true, - "no-access-missing-member": true, - "templates-use-public": true, - "invoke-injectable": true + "directive-class-suffix": true } } diff --git a/umd-bundler.js b/umd-bundler.js index a389b187..80002559 100644 --- a/umd-bundler.js +++ b/umd-bundler.js @@ -53,7 +53,9 @@ function getSystemJsBundleConfig() { map: { typescript: './node_modules/typescript/lib/typescript.js', '@angular': './node_modules/@angular', - rxjs: './node_modules/rxjs/bundles' + rxjs: './node_modules/rxjs/bundles', + uuid: './node_modules/uuid', + crypto: '@empty' }, paths: { '*': '*.js'