├── src ├── demo │ ├── assets │ │ └── .gitkeep │ ├── favicon.ico │ ├── environments │ │ ├── environment.prod.ts │ │ └── environment.ts │ ├── typings.d.ts │ ├── tsconfig.app.json │ ├── index.html │ ├── main.ts │ ├── app │ │ ├── app.module.ts │ │ └── button.ts │ └── polyfills.ts ├── draggable │ ├── draggable.events.ts │ ├── captured-node.ts │ ├── node-draggable.service.ts │ └── node-draggable.directive.ts ├── rxjs-imports.ts ├── editable │ ├── editable.events.ts │ └── node-editable.directive.ts ├── menu │ ├── menu.events.ts │ ├── node-menu.service.ts │ └── node-menu.component.ts ├── utils │ ├── safe-html.pipe.ts │ ├── event.utils.ts │ └── fn.utils.ts ├── .eslintrc.json ├── public_api.ts ├── tree.module.ts ├── tree.events.ts ├── tree.types.ts ├── tree-controller.ts ├── styles.css ├── tree.component.ts ├── tree.service.ts └── tree-internal.component.ts ├── .npmrc ├── .prettierrc ├── media └── tree-events-hierarchy.png ├── .vimrc ├── e2e ├── tslint.json ├── tsconfig.e2e.json ├── app.po.ts └── app.e2e-spec.ts ├── .editorconfig ├── .travis.yml ├── .gitignore ├── ng-package.json ├── test ├── tsconfig.spec.json ├── tree.types.spec.ts ├── utils │ ├── safe-html.pipe.spec.ts │ ├── event.utils.spec.ts │ └── fn.utils.spec.ts ├── test.ts ├── menu │ ├── node-menu.service.spec.ts │ └── node-menu.component.spec.ts ├── tree.components.spec.ts ├── template.tree-internal.component.spec.ts ├── editable │ └── node-editable.directive.spec.ts ├── draggable │ ├── node-draggable.service.spec.ts │ ├── captured-node.spec.ts │ └── node-draggable.directive.spec.ts ├── settings.tree-internal.component.spec.ts ├── data-provider │ └── tree.data-provider.ts ├── tree.service.spec.ts └── tree-controller.spec.ts ├── tsconfig-aot.json ├── tsconfig.json ├── publish.js ├── TODO.md ├── protractor.conf.js ├── LICENSE ├── karma.conf.js ├── index.ts ├── umd-bundler.js ├── tslint.json ├── package.json ├── angular.json └── CHANGELOG.md /src/demo/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-dependecnies=true 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | printWidth: 120 2 | singleQuote: true 3 | bracketSpacing: true 4 | -------------------------------------------------------------------------------- /src/demo/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valor-software/ng2-tree/HEAD/src/demo/favicon.ico -------------------------------------------------------------------------------- /src/demo/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /media/tree-events-hierarchy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valor-software/ng2-tree/HEAD/media/tree-events-hierarchy.png -------------------------------------------------------------------------------- /.vimrc: -------------------------------------------------------------------------------- 1 | set tabstop=2 2 | set shiftwidth=2 3 | set softtabstop=2 4 | set expandtab 5 | set nosmarttab 6 | 7 | set textwidth=80 8 | -------------------------------------------------------------------------------- /e2e/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../tslint.json"], 3 | "rules": { 4 | "rxjs-add": { "severity": "off" } 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/demo/typings.d.ts: -------------------------------------------------------------------------------- 1 | /* SystemJS module definition */ 2 | declare let module: NodeModule; 3 | interface NodeModule { 4 | id: string; 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | 7 | charset = utf-8 8 | 9 | indent_style = space 10 | indent_size = 2 11 | trim_trailing_whitespace=true 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | install: 5 | - npm i -g codecov 6 | script: 7 | - npm i 8 | - npm run lint && npm run test:cov 9 | after_success: 10 | - codecov -f coverage/lcov.info 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.angular/cache 2 | .idea 3 | .vscode 4 | .publish 5 | dist 6 | dist-demo 7 | build 8 | coverage 9 | bundles 10 | factories 11 | node_modules 12 | 13 | .DS_Store 14 | *.swp 15 | npm-debug.log 16 | yarn-error.log 17 | -------------------------------------------------------------------------------- /e2e/tsconfig.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/e2e", 5 | "module": "commonjs", 6 | "target": "es2015", 7 | "types": ["jasmine", "node"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "./dist/ng2-tree", 4 | "lib": { 5 | "entryFile": "src/public_api.ts" 6 | }, 7 | "allowedNonPeerDependencies": ["karma-webpack"] 8 | } 9 | -------------------------------------------------------------------------------- /src/draggable/draggable.events.ts: -------------------------------------------------------------------------------- 1 | import { ElementRef } from '@angular/core'; 2 | import { CapturedNode } from './captured-node'; 3 | 4 | export class NodeDraggableEvent { 5 | public constructor(public captured: CapturedNode, public target: ElementRef) {} 6 | } 7 | -------------------------------------------------------------------------------- /src/rxjs-imports.ts: -------------------------------------------------------------------------------- 1 | import { filter, merge } from 'rxjs/operators'; 2 | import { of } from 'rxjs'; 3 | 4 | // This forces angular compiler to generate a "rxjs-imports.metadata.json" 5 | // with a valid metadata instead of "[null]" 6 | export const noop = () => {}; 7 | -------------------------------------------------------------------------------- /src/demo/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "baseUrl": "", 6 | "types": [] 7 | }, 8 | "files": ["main.ts", "polyfills.ts"], 9 | "include": ["src/demo/**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/editable/editable.events.ts: -------------------------------------------------------------------------------- 1 | export type NodeEditableEventType = 'blur' | 'keyup'; 2 | 3 | export enum NodeEditableEventAction { 4 | Cancel 5 | } 6 | 7 | export interface NodeEditableEvent { 8 | value: string; 9 | type: NodeEditableEventType; 10 | action?: NodeEditableEventAction; 11 | } 12 | -------------------------------------------------------------------------------- /test/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "baseUrl": "", 6 | "types": ["jasmine", "node"] 7 | }, 8 | "files": ["../src/demo/main.ts", "../src/demo/polyfills.ts"], 9 | "include": ["**/*.spec.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Angular Tree Component 6 | 11 | 12 | 13 | Loading... 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/demo/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule); 12 | -------------------------------------------------------------------------------- /src/demo/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // The file contents for the current environment will overwrite these during build. 2 | // The build system defaults to the dev environment which uses `environment.ts`, but if you do 3 | // `ng build --env=prod` then `environment.prod.ts` will be used instead. 4 | // The list of which env maps to which file can be found in `.angular-cli.json`. 5 | 6 | export const environment = { 7 | production: false 8 | }; 9 | -------------------------------------------------------------------------------- /src/menu/menu.events.ts: -------------------------------------------------------------------------------- 1 | export enum NodeMenuItemAction { 2 | NewFolder, 3 | NewTag, 4 | Rename, 5 | Remove, 6 | Custom 7 | } 8 | 9 | export enum NodeMenuAction { 10 | Close 11 | } 12 | 13 | export interface NodeMenuEvent { 14 | sender: HTMLElement; 15 | action: NodeMenuAction; 16 | } 17 | 18 | export interface NodeMenuItemSelectedEvent { 19 | nodeMenuItemAction: NodeMenuItemAction; 20 | nodeMenuItemSelected?: string; 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/safe-html.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; 3 | 4 | @Pipe({ name: 'safeHtml' }) 5 | export class SafeHtmlPipe implements PipeTransform { 6 | public constructor(private sanitizer: DomSanitizer) {} 7 | 8 | public transform(value: string): SafeHtml { 9 | // return value; 10 | return this.sanitizer.bypassSecurityTrustHtml(value); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 7 | "parser": "@typescript-eslint/parser", 8 | "parserOptions": { 9 | "ecmaVersion": "latest", 10 | "sourceType": "module" 11 | }, 12 | "plugins": ["@typescript-eslint", "html"], 13 | "rules": { 14 | "@typescript-eslint/no-explicit-any": ["off"], 15 | "@typescript-eslint/no-unused-vars": ["off"] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/event.utils.ts: -------------------------------------------------------------------------------- 1 | export enum Keys { 2 | Escape = 27 3 | } 4 | 5 | export enum MouseButtons { 6 | Left = 0, 7 | Right = 2 8 | } 9 | 10 | export function isLeftButtonClicked(e: MouseEvent): boolean { 11 | return e.button === MouseButtons.Left; 12 | } 13 | 14 | export function isRightButtonClicked(e: MouseEvent): boolean { 15 | return e.button === MouseButtons.Right; 16 | } 17 | 18 | export function isEscapePressed(e: KeyboardEvent): boolean { 19 | return e.keyCode === Keys.Escape; 20 | } 21 | -------------------------------------------------------------------------------- /src/demo/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { AppComponent } from './app.component'; 3 | import { BrowserModule } from '@angular/platform-browser'; 4 | import { TreeModule } from '../../../index'; 5 | import { ButtonDirective } from './button'; 6 | import { CommonModule } from '@angular/common'; 7 | 8 | @NgModule({ 9 | declarations: [AppComponent, ButtonDirective], 10 | imports: [BrowserModule, CommonModule, TreeModule], 11 | bootstrap: [AppComponent] 12 | }) 13 | export class AppModule {} 14 | -------------------------------------------------------------------------------- /src/demo/app/button.ts: -------------------------------------------------------------------------------- 1 | import { Directive, ElementRef } from '@angular/core'; 2 | 3 | @Directive({ 4 | selector: '[button]' 5 | }) 6 | export class ButtonDirective { 7 | constructor(private el: ElementRef) { 8 | el.nativeElement.classList.add('button'); 9 | 10 | el.nativeElement.addEventListener('mousedown', e => { 11 | el.nativeElement.classList.add('button-pressed'); 12 | }); 13 | 14 | el.nativeElement.addEventListener('mouseup', e => { 15 | el.nativeElement.classList.remove('button-pressed'); 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig-aot.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noImplicitAny": false, 5 | "declaration": true, 6 | "stripInternal": true, 7 | "outDir": "dist", 8 | "suppressImplicitAnyIndexErrors": true 9 | }, 10 | "exclude": [ 11 | "src/demo", 12 | "node_modules", 13 | "factories", 14 | "**/*-aot.ts" 15 | ], 16 | "files": [ 17 | "./index.ts" 18 | ], 19 | "angularCompilerOptions": { 20 | "strictMetadataEmit" : true, 21 | "skipMetadataEmit" : false, 22 | "preserveWhitespaces": false 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "outDir": "./dist/out-tsc", 5 | "baseUrl": "src", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "moduleResolution": "node", 9 | "experimentalDecorators": true, 10 | "skipLibCheck": true, 11 | "target": "ES2022", 12 | "typeRoots": ["node_modules/@types"], 13 | "lib": ["es2015", "dom"], 14 | "module": "es2020", 15 | "useDefineForClassFields": false 16 | }, 17 | "files": ["src/demo/main.ts", "src/demo/polyfills.ts"], 18 | "include": ["src/demo/**/*.d.ts"] 19 | } 20 | -------------------------------------------------------------------------------- /publish.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/node 2 | 3 | const fs = require('fs'); 4 | const shell = require('shelljs'); 5 | const pkg = require('./package.json'); 6 | 7 | shell.exec('npm run clean'); 8 | shell.exec('npm run test'); 9 | shell.exec('npm run pre:publish'); 10 | 11 | fs.writeFileSync('dist/package.json', JSON.stringify(omit(pkg, 'private'), null, 2), { encoding: 'utf-8' }); 12 | 13 | shell.exec('npm publish dist'); 14 | shell.exec('npm run post:publish'); 15 | 16 | function omit(obj, key) { 17 | return Object.keys(obj).reduce((result, prop) => { 18 | if (prop === key) return result; 19 | return Object.assign(result, { [prop]: obj[prop] }); 20 | }, {}); 21 | } 22 | -------------------------------------------------------------------------------- /src/draggable/captured-node.ts: -------------------------------------------------------------------------------- 1 | import { Tree } from '../tree'; 2 | import { ElementRef } from '@angular/core'; 3 | 4 | export class CapturedNode { 5 | public constructor(private anElement: ElementRef, private aTree: Tree) {} 6 | 7 | public canBeDroppedAt(element: ElementRef): boolean { 8 | return !this.sameAs(element) && !this.contains(element); 9 | } 10 | 11 | public contains(other: ElementRef): boolean { 12 | return this.element.nativeElement.contains(other.nativeElement); 13 | } 14 | 15 | public sameAs(other: ElementRef): boolean { 16 | return this.element === other; 17 | } 18 | 19 | public get element(): ElementRef { 20 | return this.anElement; 21 | } 22 | 23 | public get tree(): Tree { 24 | return this.aTree; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /e2e/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, element, by, promise, ElementFinder } from 'protractor'; 2 | 3 | export class TreePage { 4 | public navigateTo(): promise.Promise { 5 | return browser.get('/'); 6 | } 7 | 8 | public getFirstNodeValueText(): promise.Promise { 9 | return element(by.css('.node-value')).getText(); 10 | } 11 | 12 | public getFirstAsyncChild(): ElementFinder { 13 | return element.all(by.css('.node-value')).get(19); 14 | } 15 | 16 | public getLastAsyncChild(): ElementFinder { 17 | return element.all(by.css('.node-value')).get(25); 18 | } 19 | 20 | public getAsyncChildrenNodeFolding(): ElementFinder { 21 | return element.all(by.css('.folding')).get(18); 22 | } 23 | 24 | public getAntiquaNode(): ElementFinder { 25 | return element(by.id('antiqua')); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/public_api.ts: -------------------------------------------------------------------------------- 1 | export * from './tree.service'; 2 | export * from './tree.module'; 3 | export * from './tree.types'; 4 | export * from './tree'; 5 | export * from './tree.events'; 6 | export * from './tree.component'; 7 | export * from './tree-internal.component'; 8 | export * from './tree-controller'; 9 | export * from './draggable/captured-node'; 10 | export * from './draggable/draggable.events'; 11 | export * from './draggable/node-draggable.directive'; 12 | export * from './draggable/node-draggable.service'; 13 | export * from './editable/editable.events'; 14 | export * from './editable/node-editable.directive'; 15 | export * from './menu/menu.events'; 16 | export * from './menu/node-menu.component'; 17 | export * from './menu/node-menu.service'; 18 | export * from './utils/event.utils'; 19 | export * from './utils/fn.utils'; 20 | export * from './utils/safe-html.pipe'; 21 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | - JSDoc should be generated on every publish and accessible. 2 | - Add ability for searching nodes in the tree. 3 | - Add ability of tree filtering. 4 | - Expand, Collapse etc. should also generate events. 5 | - It should be possible to override menu icons and items, remove/hide existing menu items, add user handlers for particular menu items. 6 | - It should be possible to override tree node styles, icons, collapse-expand on a node level. 7 | - Support mobile devices: 8 | - Styling on mobile. 9 | - Detect current issues and pain using ng2-tree on mobile devices. 10 | - Expose API for managing tree programmatically (expand, collapse, new node, etc.). 11 | - Move tests and demo to angular-cli. 12 | - Add hooks before tree detructive actions (prevent deletion if hook doesn't apply for example. It is needed to confirm an action before executing it - for example node removal). 13 | -------------------------------------------------------------------------------- /protractor.conf.js: -------------------------------------------------------------------------------- 1 | // Protractor configuration file, see link for more information 2 | // https://github.com/angular/protractor/blob/master/lib/config.ts 3 | 4 | /*global jasmine */ 5 | const { SpecReporter } = require('jasmine-spec-reporter'); 6 | 7 | exports.config = { 8 | allScriptsTimeout: 11000, 9 | specs: [ 10 | './e2e/**/*.e2e-spec.ts' 11 | ], 12 | capabilities: { 13 | 'browserName': 'chrome' 14 | }, 15 | directConnect: true, 16 | baseUrl: 'http://localhost:4200/', 17 | framework: 'jasmine', 18 | jasmineNodeOpts: { 19 | showColors: true, 20 | defaultTimeoutInterval: 30000, 21 | print: function() {} 22 | }, 23 | beforeLaunch: function() { 24 | require('ts-node').register({ 25 | project: 'e2e/tsconfig.e2e.json' 26 | }); 27 | }, 28 | onPrepare() { 29 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /test/tree.types.spec.ts: -------------------------------------------------------------------------------- 1 | import { TreeModelSettings, TreeModel, FoldingType } from '../src/tree.types'; 2 | import { TreeDataProvider } from './data-provider/tree.data-provider'; 3 | 4 | const using = require('jasmine-data-provider'); 5 | 6 | describe('TreeModelSettings', () => { 7 | describe('Merge TreeModelSettings', () => { 8 | using(TreeDataProvider.treeModelSettings, (data: any, description: string) => { 9 | it(description, () => { 10 | expect(TreeModelSettings.merge(data.treeModelA, data.treeModelB)).toEqual(data.result); 11 | }); 12 | }); 13 | }); 14 | }); 15 | 16 | describe('FoldingType', () => { 17 | it('should have correct cssClass per folding type', () => { 18 | expect(FoldingType.Expanded.cssClass).toEqual('node-expanded'); 19 | expect(FoldingType.Collapsed.cssClass).toEqual('node-collapsed'); 20 | expect(FoldingType.Empty.cssClass).toEqual('node-empty'); 21 | expect(FoldingType.Leaf.cssClass).toEqual('node-leaf'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/draggable/node-draggable.service.ts: -------------------------------------------------------------------------------- 1 | import { ElementRef, Injectable } from '@angular/core'; 2 | import { CapturedNode } from './captured-node'; 3 | import { NodeDraggableEvent } from './draggable.events'; 4 | import { Subject } from 'rxjs'; 5 | 6 | @Injectable() 7 | export class NodeDraggableService { 8 | public draggableNodeEvents$: Subject = new Subject(); 9 | 10 | private capturedNode: CapturedNode; 11 | 12 | public fireNodeDragged(captured: CapturedNode, target: ElementRef): void { 13 | if (!captured.tree || captured.tree.isStatic()) { 14 | return; 15 | } 16 | 17 | this.draggableNodeEvents$.next(new NodeDraggableEvent(captured, target)); 18 | } 19 | 20 | public captureNode(node: CapturedNode): void { 21 | this.capturedNode = node; 22 | } 23 | 24 | public getCapturedNode(): CapturedNode { 25 | return this.capturedNode; 26 | } 27 | 28 | public releaseCapturedNode(): void { 29 | this.capturedNode = null; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/utils/safe-html.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { SafeHtmlPipe } from '../../src/utils/safe-html.pipe'; 2 | import { PipeTransform } from '@angular/core'; 3 | import { SafeHtml } from '@angular/platform-browser'; 4 | 5 | let sanitizerSpy; 6 | let safeHtmlPipe: PipeTransform; 7 | 8 | describe('SafeHtmlPipe', () => { 9 | beforeEach(() => { 10 | sanitizerSpy = jasmine.createSpyObj('DomSanitizer', ['bypassSecurityTrustHtml']); 11 | safeHtmlPipe = new SafeHtmlPipe(sanitizerSpy); 12 | }); 13 | 14 | it('should transform html to the SafeHtml', () => { 15 | const html = 'foo'; 16 | const sanitized = 'sanitized'; 17 | 18 | sanitizerSpy.bypassSecurityTrustHtml.and.returnValue(sanitized); 19 | 20 | const safeHtml: SafeHtml = safeHtmlPipe.transform(html); 21 | 22 | expect(safeHtml).toEqual(sanitized); 23 | 24 | expect(sanitizerSpy.bypassSecurityTrustHtml).toHaveBeenCalledTimes(1); 25 | expect(sanitizerSpy.bypassSecurityTrustHtml).toHaveBeenCalledWith(html); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /test/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import '../src/rxjs-imports'; 4 | 5 | import 'zone.js/dist/long-stack-trace-zone'; 6 | import 'zone.js/dist/proxy.js'; 7 | import 'zone.js/dist/sync-test'; 8 | import 'zone.js/dist/jasmine-patch'; 9 | import 'zone.js/dist/async-test'; 10 | import 'zone.js/dist/fake-async-test'; 11 | import { getTestBed } from '@angular/core/testing'; 12 | import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; 13 | 14 | // Unfortunately there's no typing for the `__karma__` variable. Just declare it as any. 15 | declare var __karma__: any; 16 | 17 | // Prevent Karma from running prematurely. 18 | __karma__.loaded = function() {}; 19 | 20 | // First, initialize the Angular testing environment. 21 | getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), { 22 | teardown: { destroyAfterEach: false } 23 | }); 24 | // Finally, start Karma to run the tests. 25 | __karma__.start(); 26 | -------------------------------------------------------------------------------- /src/menu/node-menu.service.ts: -------------------------------------------------------------------------------- 1 | import { ElementRef, Injectable } from '@angular/core'; 2 | import { NodeMenuAction, NodeMenuEvent } from './menu.events'; 3 | import { Observable, Subject } from 'rxjs'; 4 | import { filter } from 'rxjs/operators'; 5 | 6 | @Injectable() 7 | export class NodeMenuService { 8 | public nodeMenuEvents$: Subject = new Subject(); 9 | 10 | public fireMenuEvent(sender: HTMLElement, action: NodeMenuAction): void { 11 | const nodeMenuEvent: NodeMenuEvent = { sender, action }; 12 | this.nodeMenuEvents$.next(nodeMenuEvent); 13 | } 14 | 15 | public hideMenuStream(treeElementRef: ElementRef): Observable { 16 | return this.nodeMenuEvents$.pipe( 17 | filter((e: NodeMenuEvent) => treeElementRef.nativeElement !== e.sender), 18 | filter((e: NodeMenuEvent) => e.action === NodeMenuAction.Close) 19 | ); 20 | } 21 | 22 | public hideMenuForAllNodesExcept(treeElementRef: ElementRef): void { 23 | this.nodeMenuEvents$.next({ 24 | sender: treeElementRef.nativeElement, 25 | action: NodeMenuAction.Close 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/tree.module.ts: -------------------------------------------------------------------------------- 1 | import './rxjs-imports'; 2 | 3 | import { NgModule } from '@angular/core'; 4 | import { TreeComponent } from './tree.component'; 5 | import { TreeInternalComponent } from './tree-internal.component'; 6 | import { CommonModule } from '@angular/common'; 7 | import { NodeDraggableDirective } from './draggable/node-draggable.directive'; 8 | import { NodeDraggableService } from './draggable/node-draggable.service'; 9 | import { NodeEditableDirective } from './editable/node-editable.directive'; 10 | import { NodeMenuComponent } from './menu/node-menu.component'; 11 | import { NodeMenuService } from './menu/node-menu.service'; 12 | import { TreeService } from './tree.service'; 13 | import { SafeHtmlPipe } from './utils/safe-html.pipe'; 14 | 15 | @NgModule({ 16 | imports: [CommonModule], 17 | declarations: [ 18 | NodeDraggableDirective, 19 | TreeComponent, 20 | NodeEditableDirective, 21 | NodeMenuComponent, 22 | TreeInternalComponent, 23 | SafeHtmlPipe 24 | ], 25 | exports: [TreeComponent], 26 | providers: [NodeDraggableService, NodeMenuService, TreeService] 27 | }) 28 | export class TreeModule {} 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Georgii Rychko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/0.13/config/configuration-file.html 3 | 4 | module.exports = function(config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, 'coverage'), 20 | reports: ['html', 'lcovonly'], 21 | fixWebpackSourcePaths: true 22 | }, 23 | 24 | reporters: 25 | config.angularCli && config.angularCli.codeCoverage ? ['progress', 'coverage-istanbul'] : ['progress', 'kjhtml'], 26 | port: 9876, 27 | colors: true, 28 | logLevel: config.LOG_INFO, 29 | autoWatch: true, 30 | browsers: ['ChromeHeadless'], 31 | singleRun: false 32 | }); 33 | }; 34 | -------------------------------------------------------------------------------- /test/menu/node-menu.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { NodeMenuService } from '../../src/menu/node-menu.service'; 3 | import { ElementRef } from '@angular/core'; 4 | import { NodeMenuEvent, NodeMenuAction } from '../../src/menu/menu.events'; 5 | import { Subject } from 'rxjs'; 6 | 7 | let nodeMenuService; 8 | 9 | describe('NodeMenuService', () => { 10 | beforeEach(() => { 11 | TestBed.configureTestingModule({ 12 | providers: [NodeMenuService] 13 | }); 14 | 15 | nodeMenuService = TestBed.inject(NodeMenuService); 16 | }); 17 | 18 | it('should be created by angular', () => { 19 | expect(nodeMenuService).not.toBeNull(); 20 | expect(nodeMenuService.nodeMenuEvents$ instanceof Subject).toBe(true); 21 | }); 22 | 23 | it('should fire close menu events', done => { 24 | const elementRef = new ElementRef(document.createElement('div')); 25 | const initiatorElementRef = new ElementRef(document.createElement('div')); 26 | 27 | nodeMenuService.hideMenuStream(elementRef).subscribe((e: NodeMenuEvent) => { 28 | expect(e.sender).toBe(initiatorElementRef.nativeElement); 29 | expect(e.action).toBe(NodeMenuAction.Close); 30 | done(); 31 | }); 32 | 33 | nodeMenuService.hideMenuForAllNodesExcept(initiatorElementRef); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/utils/event.utils.spec.ts: -------------------------------------------------------------------------------- 1 | import * as EventUtils from '../../src/utils/event.utils'; 2 | 3 | describe('EventUtils', () => { 4 | it('should detect whether escape was pressed', () => { 5 | const escEvent: KeyboardEvent = { 6 | keyCode: EventUtils.Keys.Escape 7 | } as KeyboardEvent; 8 | 9 | const notEscEvent: KeyboardEvent = { 10 | keyCode: 42 11 | } as KeyboardEvent; 12 | 13 | expect(EventUtils.isEscapePressed(escEvent)).toBe(true); 14 | expect(EventUtils.isEscapePressed(notEscEvent)).toBe(false); 15 | }); 16 | 17 | it('should detect mouse right and left button clicks', () => { 18 | const leftEvent: MouseEvent = { 19 | button: EventUtils.MouseButtons.Left 20 | } as MouseEvent; 21 | 22 | const rightEvent: MouseEvent = { 23 | button: EventUtils.MouseButtons.Right 24 | } as MouseEvent; 25 | 26 | expect(EventUtils.isLeftButtonClicked(leftEvent)).toBe(true); 27 | expect(EventUtils.isLeftButtonClicked(rightEvent)).toBe(false); 28 | 29 | expect(EventUtils.isRightButtonClicked(rightEvent)).toBe(true); 30 | expect(EventUtils.isRightButtonClicked(leftEvent)).toBe(false); 31 | }); 32 | 33 | it('should have correct Keys bindings', () => { 34 | expect(EventUtils.Keys.Escape).toEqual(27); 35 | }); 36 | 37 | it('should have correct MouseButtons bindings', () => { 38 | expect(EventUtils.MouseButtons.Left).toEqual(0); 39 | expect(EventUtils.MouseButtons.Right).toEqual(2); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /e2e/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { TreePage } from './app.po'; 2 | import { browser, ExpectedConditions as EC } from 'protractor'; 3 | 4 | describe('Tree App', () => { 5 | let page: TreePage; 6 | 7 | beforeEach(() => { 8 | page = new TreePage(); 9 | }); 10 | 11 | it('should have a tree where first node value is "Fonts"', async () => { 12 | page.navigateTo(); 13 | expect(await page.getFirstNodeValueText()).toEqual('Fonts'); 14 | }); 15 | 16 | it('should load node children asynchronously', async () => { 17 | page.navigateTo(); 18 | 19 | page.getAsyncChildrenNodeFolding().click(); 20 | 21 | const firstAsyncChild = page.getFirstAsyncChild(); 22 | expect(await browser.isElementPresent(firstAsyncChild)).toBe(true); 23 | expect(await firstAsyncChild.getText()).toEqual('Input Mono'); 24 | expect(await page.getLastAsyncChild().getText()).toEqual('Source Code Pro'); 25 | }); 26 | 27 | it('Should render tree node with HTML tags', async () => { 28 | page.navigateTo(); 29 | 30 | const antiquaNode = page.getAntiquaNode(); 31 | expect(browser.isElementPresent(antiquaNode)).toBeTruthy(); 32 | expect(await antiquaNode.getText()).toEqual('Antiqua'); 33 | 34 | const attrs = { id: 'antiqua', class: 'test' }; 35 | 36 | const expectations = Object.keys(attrs).map((key: string) => { 37 | return antiquaNode.getAttribute(key).then((value: string) => expect(value).toEqual(attrs[key])); 38 | }); 39 | 40 | return Promise.all(expectations as any); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TreeModel, 3 | TreeModelSettings, 4 | Ng2TreeSettings, 5 | RenamableNode, 6 | FoldingType, 7 | ChildrenLoadingFunction 8 | } from './src/tree.types'; 9 | 10 | import { Tree } from './src/tree'; 11 | 12 | import { NodeMenuItemAction, NodeMenuEvent } from './src/menu/menu.events'; 13 | import { NodeMenuItem } from './src/menu/node-menu.component'; 14 | 15 | import { 16 | NodeEvent, 17 | NodeCreatedEvent, 18 | NodeRemovedEvent, 19 | NodeRenamedEvent, 20 | NodeMovedEvent, 21 | NodeSelectedEvent, 22 | NodeExpandedEvent, 23 | NodeCollapsedEvent, 24 | MenuItemSelectedEvent, 25 | NodeDestructiveEvent, 26 | NodeUncheckedEvent, 27 | NodeCheckedEvent, 28 | NodeIndeterminedEvent, 29 | NodeUnselectedEvent 30 | } from './src/tree.events'; 31 | 32 | import { TreeComponent } from './src/tree.component'; 33 | import { TreeController } from './src/tree-controller'; 34 | import { TreeModule } from './src/tree.module'; 35 | 36 | export { 37 | Tree, 38 | TreeModel, 39 | TreeModelSettings, 40 | Ng2TreeSettings, 41 | RenamableNode, 42 | FoldingType, 43 | NodeEvent, 44 | NodeCreatedEvent, 45 | NodeRemovedEvent, 46 | NodeRenamedEvent, 47 | NodeMovedEvent, 48 | NodeSelectedEvent, 49 | NodeExpandedEvent, 50 | NodeCollapsedEvent, 51 | NodeDestructiveEvent, 52 | NodeMenuEvent, 53 | NodeUncheckedEvent, 54 | NodeCheckedEvent, 55 | NodeIndeterminedEvent, 56 | NodeUnselectedEvent, 57 | TreeComponent, 58 | TreeModule, 59 | NodeMenuItemAction, 60 | NodeMenuItem, 61 | ChildrenLoadingFunction, 62 | MenuItemSelectedEvent, 63 | TreeController 64 | }; 65 | -------------------------------------------------------------------------------- /src/editable/node-editable.directive.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Directive, 3 | ElementRef, 4 | EventEmitter, 5 | HostListener, 6 | Inject, 7 | Input, 8 | OnInit, 9 | Output, 10 | Renderer2 11 | } from '@angular/core'; 12 | import { NodeEditableEvent, NodeEditableEventAction } from './editable.events'; 13 | 14 | @Directive({ 15 | selector: '[nodeEditable]' 16 | }) 17 | export class NodeEditableDirective implements OnInit { 18 | /* tslint:disable:no-input-rename */ 19 | @Input('nodeEditable') public nodeValue: string; 20 | /* tslint:enable:no-input-rename */ 21 | 22 | @Output() public valueChanged: EventEmitter = new EventEmitter(false); 23 | 24 | public constructor( 25 | @Inject(Renderer2) private renderer: Renderer2, 26 | @Inject(ElementRef) private elementRef: ElementRef 27 | ) {} 28 | 29 | public ngOnInit(): void { 30 | const nativeElement = this.elementRef.nativeElement; 31 | 32 | if (nativeElement) { 33 | nativeElement.focus(); 34 | } 35 | 36 | this.renderer.setProperty(nativeElement, 'value', this.nodeValue); 37 | } 38 | 39 | @HostListener('keyup.enter', ['$event.target.value']) 40 | public applyNewValue(newNodeValue: string): void { 41 | this.valueChanged.emit({ type: 'keyup', value: newNodeValue }); 42 | } 43 | 44 | @HostListener('blur', ['$event.target.value']) 45 | public applyNewValueByLoosingFocus(newNodeValue: string): void { 46 | this.valueChanged.emit({ type: 'blur', value: newNodeValue }); 47 | } 48 | 49 | @HostListener('keyup.esc') 50 | public cancelEditing(): void { 51 | this.valueChanged.emit({ 52 | type: 'keyup', 53 | value: this.nodeValue, 54 | action: NodeEditableEventAction.Cancel 55 | }); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /test/utils/fn.utils.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fn from '../../src/utils/fn.utils'; 2 | 3 | describe('fn.utils - multipurpose functions', () => { 4 | describe('trim', () => { 5 | it('returns empty string when input value is null or undefined', () => { 6 | expect(fn.trim(null)).toEqual(''); 7 | expect(fn.trim(undefined)).toEqual(''); 8 | }); 9 | 10 | it('uses native trim method under the hood', () => { 11 | const stringSpy = jasmine.createSpyObj('string', ['trim']); 12 | stringSpy.trim.and.returnValue('Boo!'); 13 | expect(fn.trim(stringSpy as string)).toEqual('Boo!'); 14 | }); 15 | }); 16 | 17 | describe('once', () => { 18 | it('executes function only ones', () => { 19 | const onceTargetSpy = jasmine.createSpy('once'); 20 | const onceExecutableFn = fn.once(onceTargetSpy); 21 | 22 | onceExecutableFn('Hello', ', ', 'World'); 23 | onceExecutableFn('Hello'); 24 | 25 | expect(onceTargetSpy).toHaveBeenCalledTimes(1); 26 | expect(onceTargetSpy).toHaveBeenCalledWith('Hello', ', ', 'World'); 27 | expect(onceTargetSpy).not.toHaveBeenCalledWith('Hello'); 28 | }); 29 | }); 30 | 31 | describe('defaultsDeep', () => { 32 | it('uses empty array if there were no sources given', () => { 33 | const options = fn.defaultsDeep({ msg: 'Boo!' }); 34 | 35 | expect(options).toEqual({ msg: 'Boo!' }); 36 | }); 37 | }); 38 | 39 | describe('defaultsDeep', () => { 40 | it('uses empty array if there were no sources given', () => { 41 | const options = fn.defaultsDeep({ msg: 'Boo!' }); 42 | 43 | expect(options).toEqual({ msg: 'Boo!' }); 44 | }); 45 | }); 46 | 47 | describe('includes', () => { 48 | it('works with strings', () => { 49 | expect(fn.includes('world', 'rl')).toEqual(true); 50 | expect(fn.includes('world', 'rrl')).toEqual(false); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /umd-bundler.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | const del = require('del'); 6 | const path = require('path'); 7 | const Builder = require('systemjs-builder'); 8 | 9 | const pkg = require('./package.json'); 10 | const targetFolder = path.resolve('./dist/bundles'); 11 | 12 | del(targetFolder) 13 | .then(paths => { 14 | console.log('Deleted files and folders:\n', paths.join('\n')); 15 | }) 16 | .then(() => { 17 | return Promise.all([ 18 | buildSystemJs(), 19 | buildSystemJs({minify: true}) 20 | ]); 21 | }) 22 | .catch(e => console.log(e)); 23 | 24 | function buildSystemJs(options = {}) { 25 | const minPostFix = options && options.minify ? '.umd.min' : '.umd'; 26 | const fileName = `${pkg.name}${minPostFix}.js`; 27 | const dest = path.resolve(__dirname, targetFolder, fileName); 28 | const builder = new Builder(); 29 | 30 | console.log('Bundling system.js file:', fileName, options); 31 | builder.config(getSystemJsBundleConfig()); 32 | 33 | return builder 34 | .buildStatic('dist/index', dest, Object.assign({ 35 | format: 'umd', 36 | minify: false, 37 | sourceMaps: true, 38 | mangle: false, 39 | noEmitHelpers: false, 40 | declaration: false 41 | }, options)) 42 | .then((b) => { 43 | console.log(`Build complete: ${minPostFix}`); 44 | }) 45 | .catch(err => { 46 | console.log('Error', err); 47 | }); 48 | } 49 | 50 | function getSystemJsBundleConfig() { 51 | return { 52 | baseURL: '.', 53 | map: { 54 | typescript: './node_modules/typescript/lib/typescript.js', 55 | '@angular': './node_modules/@angular', 56 | rxjs: './node_modules/rxjs/bundles', 57 | uuid: './node_modules/uuid', 58 | crypto: '@empty' 59 | }, 60 | paths: { 61 | '*': '*.js' 62 | }, 63 | meta: { 64 | './node_modules/@angular/*': { build: false }, 65 | './node_modules/rxjs/*': { build: false } 66 | } 67 | }; 68 | } 69 | -------------------------------------------------------------------------------- /src/demo/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE9, IE10 and IE11 requires all of the following polyfills. **/ 22 | // import 'core-js/es6/symbol'; 23 | import 'core-js/es6/object'; 24 | // import 'core-js/es6/function'; 25 | // import 'core-js/es6/parse-int'; 26 | // import 'core-js/es6/parse-float'; 27 | // import 'core-js/es6/number'; 28 | // import 'core-js/es6/math'; 29 | import 'core-js/es6/string'; 30 | // import 'core-js/es6/date'; 31 | import 'core-js/es6/array'; 32 | /** Evergreen browsers require these. **/ 33 | import 'core-js/es6/reflect'; 34 | import 'core-js/es7/reflect'; 35 | /*************************************************************************************************** 36 | * Zone JS is required by Angular itself. 37 | */ 38 | import 'zone.js'; // Included with Angular CLI. 39 | // import 'core-js/es6/regexp'; 40 | // import 'core-js/es6/map'; 41 | // import 'core-js/es6/set'; 42 | 43 | /*************************************************************************************************** 44 | * APPLICATION IMPORTS 45 | */ 46 | 47 | /** 48 | * Date, currency, decimal and percent pipes. 49 | * Needed for: All but Chrome, Firefox, Edge, IE11 and Safari 10 50 | */ 51 | // import 'intl'; // Run `npm install --save intl`. 52 | (window as any).global = window; 53 | -------------------------------------------------------------------------------- /src/tree.events.ts: -------------------------------------------------------------------------------- 1 | import { Tree } from './tree'; 2 | import { RenamableNode } from './tree.types'; 3 | 4 | export class NodeEvent { 5 | public constructor(public node: Tree) {} 6 | } 7 | 8 | export class NodeSelectedEvent extends NodeEvent { 9 | public constructor(node: Tree) { 10 | super(node); 11 | } 12 | } 13 | 14 | export class NodeUnselectedEvent extends NodeEvent { 15 | public constructor(node: Tree) { 16 | super(node); 17 | } 18 | } 19 | 20 | export class NodeDestructiveEvent extends NodeEvent { 21 | public constructor(node: Tree) { 22 | super(node); 23 | } 24 | } 25 | 26 | export class NodeMovedEvent extends NodeDestructiveEvent { 27 | public constructor(node: Tree, public previousParent: Tree) { 28 | super(node); 29 | } 30 | } 31 | 32 | export class NodeRemovedEvent extends NodeDestructiveEvent { 33 | public constructor(node: Tree, public lastIndex: number) { 34 | super(node); 35 | } 36 | } 37 | 38 | export class NodeCreatedEvent extends NodeDestructiveEvent { 39 | public constructor(node: Tree) { 40 | super(node); 41 | } 42 | } 43 | 44 | export class NodeRenamedEvent extends NodeDestructiveEvent { 45 | public constructor(node: Tree, public oldValue: string | RenamableNode, public newValue: string | RenamableNode) { 46 | super(node); 47 | } 48 | } 49 | 50 | export class NodeExpandedEvent extends NodeEvent { 51 | public constructor(node: Tree) { 52 | super(node); 53 | } 54 | } 55 | 56 | export class NodeCollapsedEvent extends NodeEvent { 57 | public constructor(node: Tree) { 58 | super(node); 59 | } 60 | } 61 | 62 | export class MenuItemSelectedEvent extends NodeEvent { 63 | public constructor(node: Tree, public selectedItem: string) { 64 | super(node); 65 | } 66 | } 67 | 68 | export class LoadNextLevelEvent extends NodeEvent { 69 | public constructor(node: Tree) { 70 | super(node); 71 | } 72 | } 73 | 74 | export class NodeCheckedEvent extends NodeEvent { 75 | public constructor(node: Tree) { 76 | super(node); 77 | } 78 | } 79 | 80 | export class NodeUncheckedEvent extends NodeEvent { 81 | public constructor(node: Tree) { 82 | super(node); 83 | } 84 | } 85 | 86 | export class NodeIndeterminedEvent extends NodeEvent { 87 | public constructor(node: Tree) { 88 | super(node); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /test/tree.components.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, ComponentFixture } from '@angular/core/testing'; 2 | import { By } from '@angular/platform-browser'; 3 | import { Component, DebugElement } from '@angular/core'; 4 | import { TreeInternalComponent } from '../src/tree-internal.component'; 5 | import { TreeComponent } from '../src/tree.component'; 6 | import { TreeModel } from '../src/tree.types'; 7 | import { TreeService } from '../src/tree.service'; 8 | import { NodeMenuService } from '../src/menu/node-menu.service'; 9 | import { NodeMenuComponent } from '../src/menu/node-menu.component'; 10 | import { NodeDraggableService } from '../src/draggable/node-draggable.service'; 11 | import { NodeDraggableDirective } from '../src/draggable/node-draggable.directive'; 12 | import { NodeEditableDirective } from '../src/editable/node-editable.directive'; 13 | import { SafeHtmlPipe } from '../src/utils/safe-html.pipe'; 14 | 15 | let fixture: ComponentFixture; 16 | let componentInstance: TreeComponent; 17 | let componentEl: DebugElement; 18 | 19 | @Component({ 20 | template: ` 21 |
22 | ` 23 | }) 24 | class TestComponent { 25 | public model: TreeModel; 26 | } 27 | 28 | describe('TreeComponent (the one that wraps TreeInternalComponent)', () => { 29 | beforeEach(() => { 30 | TestBed.configureTestingModule({ 31 | declarations: [ 32 | TestComponent, 33 | TreeInternalComponent, 34 | TreeComponent, 35 | NodeEditableDirective, 36 | NodeMenuComponent, 37 | NodeDraggableDirective, 38 | SafeHtmlPipe 39 | ], 40 | providers: [NodeMenuService, NodeDraggableService, TreeService, SafeHtmlPipe] 41 | }); 42 | 43 | fixture = TestBed.createComponent(TestComponent); 44 | componentEl = fixture.debugElement.query(By.directive(TreeComponent)); 45 | componentInstance = componentEl.componentInstance; 46 | 47 | fixture.detectChanges(); 48 | }); 49 | 50 | it('should be initialized', () => { 51 | expect(fixture).not.toBeNull(); 52 | expect(componentInstance.tree).not.toBeFalsy(); 53 | }); 54 | 55 | it('should have default empty tree if none was given via input', () => { 56 | expect(componentInstance.tree.value).toEqual(''); 57 | expect(componentInstance.tree.isRoot()).toEqual(true); 58 | expect(componentInstance.treeModel).toBeFalsy(); 59 | expect(componentInstance.tree.children).toBeFalsy(); 60 | }); 61 | 62 | it('should use given model if it is not falsy', () => { 63 | fixture.debugElement.componentInstance.model = { 64 | value: '42' 65 | }; 66 | 67 | fixture.detectChanges(); 68 | 69 | expect(componentInstance.tree.value).toEqual('42'); 70 | expect(componentInstance.treeModel.value).toEqual('42'); 71 | expect(componentInstance.rootComponent.controller.tree.value).toEqual('42'); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/utils/fn.utils.ts: -------------------------------------------------------------------------------- 1 | export function isEmpty(value: any[] | string): boolean { 2 | if (typeof value === 'string') { 3 | return !/\S/.test(value); 4 | } 5 | 6 | if (Array.isArray(value)) { 7 | return value.length === 0; 8 | } 9 | 10 | return isNil(value); 11 | } 12 | 13 | export function trim(value: string): string { 14 | return isNil(value) ? '' : value.trim(); 15 | } 16 | 17 | export function has(value: any, prop: string): boolean { 18 | return value && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, prop); 19 | } 20 | 21 | export function isFunction(value: any) { 22 | return typeof value === 'function'; 23 | } 24 | 25 | export function get(value: any, path: string, defaultValue?: any) { 26 | let result = value; 27 | 28 | for (const prop of path.split('.')) { 29 | if (!result || !Reflect.has(result, prop)) { 30 | return defaultValue; 31 | } 32 | 33 | result = result[prop]; 34 | } 35 | 36 | return isNil(result) || result === value ? defaultValue : result; 37 | } 38 | 39 | export function omit(value: any, propsToSkip: string | string[]): any { 40 | if (!value) { 41 | return value; 42 | } 43 | 44 | const normalizedPropsToSkip = typeof propsToSkip === 'string' ? [propsToSkip] : propsToSkip; 45 | 46 | return Object.keys(value).reduce((result, prop) => { 47 | if (includes(normalizedPropsToSkip, prop)) { 48 | return result; 49 | } 50 | return Object.assign(result, { [prop]: value[prop] }); 51 | }, {}); 52 | } 53 | 54 | export function size(value: any[]): number { 55 | return isEmpty(value) ? 0 : value.length; 56 | } 57 | 58 | export function once(fn: Once): Once { 59 | let result; 60 | 61 | return (...args: any[]) => { 62 | if (fn) { 63 | result = fn(...args); 64 | fn = null; 65 | } 66 | return result; 67 | }; 68 | } 69 | 70 | export function defaultsDeep(target: any, ...sources: any[]): any { 71 | return [target].concat(sources).reduce((result: any, source: any) => { 72 | if (!source) { 73 | return result; 74 | } 75 | 76 | Object.keys(source).forEach(prop => { 77 | if (isNil(result[prop])) { 78 | result[prop] = source[prop]; 79 | return; 80 | } 81 | 82 | if (typeof result[prop] === 'object' && !Array.isArray(result[prop])) { 83 | result[prop] = defaultsDeep(result[prop], source[prop]); 84 | return; 85 | } 86 | }); 87 | 88 | return result; 89 | }, {}); 90 | } 91 | 92 | export function includes(target: string | any[], value: any): boolean { 93 | if (isNil(target)) { 94 | return false; 95 | } 96 | 97 | const index = typeof target === 'string' ? target.indexOf(value as string) : target.indexOf(value); 98 | return index > -1; 99 | } 100 | 101 | export function isNil(value: any): boolean { 102 | return value === undefined || value === null; 103 | } 104 | 105 | export type Once = (...args: any[]) => any; 106 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": ["node_modules/codelyzer"], 3 | "extends": ["rxjs-tslint-rules"], 4 | "rules": { 5 | "callable-types": true, 6 | "class-name": true, 7 | "comment-format": [true, "check-space"], 8 | "curly": true, 9 | "eofline": true, 10 | "forin": true, 11 | "deprecation": { 12 | "severity": "warning" 13 | }, 14 | "import-blacklist": [true, "lodash"], 15 | "import-spacing": true, 16 | "indent": [true, "spaces"], 17 | "interface-over-type-literal": true, 18 | "label-position": true, 19 | "max-line-length": [false, 140], 20 | "member-access": false, 21 | "no-arg": true, 22 | "no-bitwise": true, 23 | "no-console": [true, "debug", "info", "time", "timeEnd", "trace"], 24 | "no-construct": true, 25 | "no-debugger": true, 26 | "no-duplicate-variable": true, 27 | "no-empty": false, 28 | "no-empty-interface": true, 29 | "no-eval": true, 30 | "no-inferrable-types": [true, "ignore-params"], 31 | "no-shadowed-variable": true, 32 | "no-string-literal": false, 33 | "no-string-throw": true, 34 | "no-switch-case-fall-through": true, 35 | "no-trailing-whitespace": true, 36 | "no-unused-expression": true, 37 | "no-var-keyword": true, 38 | "object-literal-sort-keys": false, 39 | "one-line": [true, "check-open-brace", "check-catch", "check-else", "check-whitespace"], 40 | "prefer-const": true, 41 | "quotemark": [true, "single", "avoid-escape"], 42 | "radix": true, 43 | "semicolon": true, 44 | "triple-equals": [true, "allow-null-check"], 45 | "typedef-whitespace": [ 46 | true, 47 | { 48 | "call-signature": "nospace", 49 | "index-signature": "nospace", 50 | "parameter": "nospace", 51 | "property-declaration": "nospace", 52 | "variable-declaration": "nospace" 53 | } 54 | ], 55 | "typeof-compare": true, 56 | "unified-signatures": true, 57 | "variable-name": false, 58 | "whitespace": [ 59 | true, 60 | "check-branch", 61 | "check-decl", 62 | "check-operator", 63 | "check-separator", 64 | "check-type", 65 | "check-module", 66 | "check-type", 67 | "check-typecast", 68 | "check-preblock" 69 | ], 70 | 71 | "rxjs-add": { 72 | "options": [ 73 | { 74 | "allowElsewhere": false, 75 | "allowUnused": false, 76 | "file": "./src/rxjs-imports.ts" 77 | } 78 | ], 79 | "severity": "error" 80 | }, 81 | 82 | "directive-selector": [true, "attribute", "", "camelCase"], 83 | "component-selector": [false, "element", "", "kebab-case"], 84 | "use-input-property-decorator": true, 85 | "use-output-property-decorator": true, 86 | "use-host-property-decorator": true, 87 | "no-input-rename": true, 88 | "no-output-rename": true, 89 | "use-life-cycle-interface": true, 90 | "use-pipe-transform-interface": true, 91 | "component-class-suffix": true, 92 | "directive-class-suffix": true 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /test/template.tree-internal.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { By } from '@angular/platform-browser'; 3 | import { Component, ElementRef, DebugElement } from '@angular/core'; 4 | import { TreeInternalComponent } from '../src/tree-internal.component'; 5 | import { TreeComponent } from '../src/tree.component'; 6 | import { TreeModel, Ng2TreeSettings } from '../src/tree.types'; 7 | import { TreeService } from '../src/tree.service'; 8 | import { NodeMenuService } from '../src/menu/node-menu.service'; 9 | import { NodeMenuComponent } from '../src/menu/node-menu.component'; 10 | import { NodeDraggableService } from '../src/draggable/node-draggable.service'; 11 | import { NodeDraggableDirective } from '../src/draggable/node-draggable.directive'; 12 | import { NodeEditableDirective } from '../src/editable/node-editable.directive'; 13 | import { NodeMenuAction } from '../src/menu/menu.events'; 14 | import * as EventUtils from '../src/utils/event.utils'; 15 | import { CapturedNode } from '../src/draggable/captured-node'; 16 | import { SafeHtmlPipe } from '../src/utils/safe-html.pipe'; 17 | 18 | let fixture; 19 | let masterInternalTreeEl; 20 | let masterComponentInstance; 21 | 22 | const tree: TreeModel = { 23 | value: 'Master', 24 | icon: 'icon0', 25 | children: [{ value: 'Servant#1', icon: 'icon1' }, { value: 'Servant#2', icon: 'icon2' }] 26 | }; 27 | 28 | @Component({ 29 | template: `
{{node.icon}}{{node.value}}
` 30 | }) 31 | class TestComponent { 32 | public tree: TreeModel = tree; 33 | public constructor(public treeHolder: ElementRef) {} 34 | } 35 | 36 | describe('template for tree', () => { 37 | beforeEach(() => { 38 | TestBed.configureTestingModule({ 39 | declarations: [ 40 | TestComponent, 41 | TreeInternalComponent, 42 | TreeComponent, 43 | NodeEditableDirective, 44 | NodeMenuComponent, 45 | NodeDraggableDirective, 46 | SafeHtmlPipe 47 | ], 48 | providers: [NodeMenuService, NodeDraggableService, TreeService] 49 | }); 50 | 51 | fixture = TestBed.createComponent(TestComponent); 52 | 53 | masterInternalTreeEl = fixture.debugElement.query(By.css('#master')).query(By.directive(TreeInternalComponent)); 54 | masterComponentInstance = masterInternalTreeEl.componentInstance; 55 | 56 | fixture.detectChanges(); 57 | }); 58 | 59 | it('should not render default node', () => { 60 | const foldingEl: DebugElement[] = masterInternalTreeEl.queryAll(By.css('.node-name')); 61 | expect(foldingEl.length).toEqual(0); 62 | }); 63 | 64 | it('should render the template', () => { 65 | const icons: DebugElement[] = masterInternalTreeEl.queryAll(By.css('.icon')); 66 | expect(icons.length).toEqual(3); 67 | expect(icons[0].nativeElement.innerHTML).toEqual('icon0'); 68 | expect(icons[1].nativeElement.innerHTML).toEqual('icon1'); 69 | expect(icons[2].nativeElement.innerHTML).toEqual('icon2'); 70 | 71 | const values: DebugElement[] = masterInternalTreeEl.queryAll(By.css('.value')); 72 | expect(values.length).toEqual(3); 73 | expect(values[0].nativeElement.innerHTML).toEqual('Master'); 74 | expect(values[1].nativeElement.innerHTML).toEqual('Servant#1'); 75 | expect(values[2].nativeElement.innerHTML).toEqual('Servant#2'); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /src/menu/node-menu.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Inject, Input, OnDestroy, OnInit, Output, Renderer2, ViewChild } from '@angular/core'; 2 | import { NodeMenuService } from './node-menu.service'; 3 | import { NodeMenuAction, NodeMenuItemAction, NodeMenuItemSelectedEvent } from './menu.events'; 4 | import { isEscapePressed, isLeftButtonClicked } from '../utils/event.utils'; 5 | 6 | @Component({ 7 | selector: 'node-menu', 8 | template: ` 9 |
10 |
    11 |
  • 13 |
    14 | {{menuItem.name}} 15 |
  • 16 |
17 |
18 | ` 19 | }) 20 | export class NodeMenuComponent implements OnInit, OnDestroy { 21 | @Output() 22 | public menuItemSelected: EventEmitter = new EventEmitter(); 23 | 24 | @Input() public menuItems: NodeMenuItem[]; 25 | 26 | @ViewChild('menuContainer', { static: false }) 27 | public menuContainer: any; 28 | 29 | public availableMenuItems: NodeMenuItem[] = [ 30 | { 31 | name: 'New tag', 32 | action: NodeMenuItemAction.NewTag, 33 | cssClass: 'new-tag' 34 | }, 35 | { 36 | name: 'New folder', 37 | action: NodeMenuItemAction.NewFolder, 38 | cssClass: 'new-folder' 39 | }, 40 | { 41 | name: 'Rename', 42 | action: NodeMenuItemAction.Rename, 43 | cssClass: 'rename' 44 | }, 45 | { 46 | name: 'Remove', 47 | action: NodeMenuItemAction.Remove, 48 | cssClass: 'remove' 49 | } 50 | ]; 51 | 52 | private disposersForGlobalListeners: (() => void)[] = []; 53 | 54 | public constructor( 55 | @Inject(Renderer2) private renderer: Renderer2, 56 | @Inject(NodeMenuService) private nodeMenuService: NodeMenuService 57 | ) {} 58 | 59 | public ngOnInit(): void { 60 | this.availableMenuItems = this.menuItems || this.availableMenuItems; 61 | this.disposersForGlobalListeners.push(this.renderer.listen('document', 'keyup', this.closeMenu.bind(this))); 62 | this.disposersForGlobalListeners.push(this.renderer.listen('document', 'mousedown', this.closeMenu.bind(this))); 63 | } 64 | 65 | public ngOnDestroy(): void { 66 | this.disposersForGlobalListeners.forEach((dispose: () => void) => dispose()); 67 | } 68 | 69 | public onMenuItemSelected(e: MouseEvent, selectedMenuItem: NodeMenuItem): void { 70 | if (isLeftButtonClicked(e)) { 71 | this.menuItemSelected.emit({ 72 | nodeMenuItemAction: selectedMenuItem.action, 73 | nodeMenuItemSelected: selectedMenuItem.name 74 | }); 75 | 76 | this.nodeMenuService.fireMenuEvent(e.target as HTMLElement, NodeMenuAction.Close); 77 | } 78 | } 79 | 80 | private closeMenu(e: MouseEvent | KeyboardEvent): void { 81 | const mouseClicked = e instanceof MouseEvent; 82 | // Check if the click is fired on an element inside a menu 83 | const containingTarget = 84 | this.menuContainer.nativeElement !== e.target && this.menuContainer.nativeElement.contains(e.target); 85 | 86 | if ((mouseClicked && !containingTarget) || isEscapePressed(e as KeyboardEvent)) { 87 | this.nodeMenuService.fireMenuEvent(e.target as HTMLElement, NodeMenuAction.Close); 88 | } 89 | } 90 | } 91 | 92 | export interface NodeMenuItem { 93 | name: string; 94 | action: NodeMenuItemAction; 95 | cssClass?: string; 96 | } 97 | -------------------------------------------------------------------------------- /test/editable/node-editable.directive.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { By } from '@angular/platform-browser'; 3 | import { Component, EventEmitter } from '@angular/core'; 4 | import { NodeEditableDirective } from '../../src/editable/node-editable.directive'; 5 | import { NodeEditableEvent, NodeEditableEventAction } from '../../src/editable/editable.events'; 6 | import { TreeModel } from '../../src/tree.types'; 7 | 8 | let fixture; 9 | let directiveEl; 10 | let directiveInstance; 11 | 12 | @Component({ 13 | template: '
' 14 | }) 15 | class TestComponent { 16 | public tree: TreeModel = { 17 | value: '42' 18 | }; 19 | } 20 | 21 | describe('NodeEditableDirective', () => { 22 | beforeEach(() => { 23 | TestBed.configureTestingModule({ 24 | declarations: [NodeEditableDirective, TestComponent] 25 | }); 26 | 27 | fixture = TestBed.createComponent(TestComponent); 28 | directiveEl = fixture.debugElement.query(By.directive(NodeEditableDirective)); 29 | directiveInstance = directiveEl.injector.get(NodeEditableDirective); 30 | }); 31 | 32 | it('should have correctly set "nodeValue" property', () => { 33 | fixture.detectChanges(); 34 | 35 | expect(directiveInstance).not.toBeNull(); 36 | expect(directiveInstance.nodeValue).toEqual('42'); 37 | }); 38 | 39 | it('should have correctly set "valueChanged" event emitter', () => { 40 | expect(directiveInstance.valueChanged instanceof EventEmitter).toBe(true); 41 | }); 42 | 43 | it('should set focus on the host element', () => { 44 | spyOn(directiveEl.nativeElement, 'focus'); 45 | 46 | fixture.detectChanges(); 47 | 48 | expect(directiveEl.nativeElement.focus).toHaveBeenCalledTimes(1); 49 | }); 50 | 51 | it('should set value the host element', () => { 52 | fixture.detectChanges(); 53 | 54 | expect(directiveEl.nativeElement.value).toEqual('42'); 55 | }); 56 | 57 | it('should apply new value once user pressed enter', () => { 58 | fixture.detectChanges(); 59 | 60 | const expectedNewValue = '12'; 61 | const event = { target: { value: expectedNewValue } }; 62 | 63 | spyOn(directiveInstance.valueChanged, 'emit'); 64 | directiveEl.triggerEventHandler('keyup.enter', event); 65 | 66 | expect(directiveInstance.valueChanged.emit).toHaveBeenCalledWith({ type: 'keyup', value: expectedNewValue }); 67 | expect(directiveInstance.valueChanged.emit).toHaveBeenCalledTimes(1); 68 | }); 69 | 70 | it('should apply new value once element under edit looses focus', () => { 71 | fixture.detectChanges(); 72 | 73 | const expectedNewValue = '12'; 74 | const event = { target: { value: expectedNewValue } }; 75 | 76 | spyOn(directiveInstance.valueChanged, 'emit'); 77 | directiveEl.triggerEventHandler('blur', event); 78 | 79 | expect(directiveInstance.valueChanged.emit).toHaveBeenCalledWith({ type: 'blur', value: expectedNewValue }); 80 | expect(directiveInstance.valueChanged.emit).toHaveBeenCalledTimes(1); 81 | }); 82 | 83 | it('should cancel editing once escape was pressed during edit', () => { 84 | fixture.detectChanges(); 85 | 86 | spyOn(directiveInstance.valueChanged, 'emit'); 87 | directiveEl.triggerEventHandler('keyup.esc'); 88 | 89 | const event: NodeEditableEvent = { 90 | type: 'keyup', 91 | value: directiveInstance.nodeValue, 92 | action: NodeEditableEventAction.Cancel 93 | }; 94 | expect(directiveInstance.valueChanged.emit).toHaveBeenCalledWith(event); 95 | expect(directiveInstance.valueChanged.emit).toHaveBeenCalledTimes(1); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /test/draggable/node-draggable.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { inject, TestBed } from '@angular/core/testing'; 2 | import { NodeDraggableService } from '../../src/draggable/node-draggable.service'; 3 | import { CapturedNode } from '../../src/draggable/captured-node'; 4 | import { ElementRef } from '@angular/core'; 5 | import { NodeDraggableEvent } from '../../src/draggable/draggable.events'; 6 | import { Tree } from '../../src/tree'; 7 | import { Subject } from 'rxjs'; 8 | 9 | describe('NodeDraggableService', function() { 10 | beforeEach(() => { 11 | TestBed.configureTestingModule({ 12 | providers: [NodeDraggableService] 13 | }); 14 | }); 15 | 16 | it( 17 | 'should have draggable event bus set up', 18 | inject([NodeDraggableService], nodeDraggableService => { 19 | expect(nodeDraggableService).not.toBeNull(); 20 | expect(nodeDraggableService.draggableNodeEvents$).not.toBeNull(); 21 | expect(nodeDraggableService.draggableNodeEvents$ instanceof Subject).toBe(true); 22 | }) 23 | ); 24 | 25 | it( 26 | 'should have captured node undefined right after creation', 27 | inject([NodeDraggableService], nodeDraggableService => { 28 | const capturedNode = nodeDraggableService.getCapturedNode(); 29 | expect(capturedNode).toBeUndefined(); 30 | }) 31 | ); 32 | 33 | it( 34 | 'should fire node dragged event', 35 | inject([NodeDraggableService], nodeDraggableService => { 36 | spyOn(nodeDraggableService.draggableNodeEvents$, 'next'); 37 | 38 | const stubCapturedNode = new CapturedNode(null, new Tree({ value: 'Master' })); 39 | const target = new ElementRef({}); 40 | 41 | nodeDraggableService.fireNodeDragged(stubCapturedNode, target); 42 | 43 | expect(nodeDraggableService.draggableNodeEvents$.next).toHaveBeenCalledTimes(1); 44 | 45 | const event: NodeDraggableEvent = nodeDraggableService.draggableNodeEvents$.next.calls.argsFor(0)[0]; 46 | expect(event.target).toBe(target); 47 | expect(event.captured).toBe(stubCapturedNode); 48 | }) 49 | ); 50 | 51 | it( 52 | 'should not fire event if node is static', 53 | inject([NodeDraggableService], nodeDraggableService => { 54 | const masterTree = new Tree({ 55 | value: 'Master', 56 | settings: { 57 | static: true 58 | } 59 | }); 60 | 61 | spyOn(nodeDraggableService.draggableNodeEvents$, 'next'); 62 | 63 | const elementRef = new ElementRef(null); 64 | nodeDraggableService.fireNodeDragged(new CapturedNode(elementRef, masterTree), elementRef); 65 | expect(nodeDraggableService.draggableNodeEvents$.next).not.toHaveBeenCalled(); 66 | }) 67 | ); 68 | 69 | it( 70 | 'should not fire event if there is no tree in captured node', 71 | inject([NodeDraggableService], nodeDraggableService => { 72 | spyOn(nodeDraggableService.draggableNodeEvents$, 'next'); 73 | 74 | const elementRef = new ElementRef(null); 75 | nodeDraggableService.fireNodeDragged(new CapturedNode(elementRef, null), elementRef); 76 | expect(nodeDraggableService.draggableNodeEvents$.next).not.toHaveBeenCalled(); 77 | }) 78 | ); 79 | 80 | it( 81 | 'should capture node', 82 | inject([NodeDraggableService], nodeDraggableService => { 83 | const stubCapturedNode = new CapturedNode(null, null); 84 | 85 | nodeDraggableService.captureNode(stubCapturedNode); 86 | const actualCapturedNode = nodeDraggableService.getCapturedNode(stubCapturedNode); 87 | 88 | expect(actualCapturedNode).toBe(stubCapturedNode); 89 | }) 90 | ); 91 | 92 | it( 93 | 'should release captured node', 94 | inject([NodeDraggableService], nodeDraggableService => { 95 | const stubCapturedNode = new CapturedNode(null, null); 96 | 97 | nodeDraggableService.captureNode(stubCapturedNode); 98 | expect(nodeDraggableService.getCapturedNode(stubCapturedNode)).toBe(stubCapturedNode); 99 | 100 | nodeDraggableService.releaseCapturedNode(); 101 | expect(nodeDraggableService.getCapturedNode(stubCapturedNode)).toBeNull(); 102 | }) 103 | ); 104 | }); 105 | -------------------------------------------------------------------------------- /test/draggable/captured-node.spec.ts: -------------------------------------------------------------------------------- 1 | import { ElementRef } from '@angular/core'; 2 | import { CapturedNode } from '../../src/draggable/captured-node'; 3 | import { Tree } from '../../src/tree'; 4 | 5 | describe('Captured Node', () => { 6 | it('should be created with element and tree', () => { 7 | const element: ElementRef = {} as ElementRef; 8 | const tree: Tree = new Tree({ value: '42' }); 9 | 10 | const capturedNode = new CapturedNode(element, tree); 11 | 12 | expect(capturedNode.element).toBe(element); 13 | expect(capturedNode.tree).toBe(tree); 14 | }); 15 | 16 | it('should know how to compare elements', () => { 17 | const element: ElementRef = {} as ElementRef; 18 | const element2: ElementRef = {} as ElementRef; 19 | const tree: Tree = null; 20 | 21 | const capturedNode = new CapturedNode(element, tree); 22 | 23 | expect(capturedNode.sameAs(element)).toBe(true); 24 | expect(capturedNode.sameAs(element2)).toBe(false); 25 | }); 26 | 27 | it('should know whether another element is not a child of current element', () => { 28 | const contains = jasmine.createSpy('contains').and.returnValue(false); 29 | const thisNativeElement = { 30 | contains 31 | }; 32 | 33 | const element: ElementRef = { 34 | nativeElement: thisNativeElement 35 | } as ElementRef; 36 | 37 | const element2: ElementRef = { 38 | nativeElement: {} 39 | } as ElementRef; 40 | 41 | const capturedNode = new CapturedNode(element, null); 42 | 43 | expect(capturedNode.contains(element2)).toBe(false); 44 | expect(contains).toHaveBeenCalledWith(element2.nativeElement); 45 | }); 46 | 47 | it('should know whether another element is a child of current element', () => { 48 | const contains = jasmine.createSpy('contains').and.returnValue(true); 49 | const thisNativeElement = { 50 | contains 51 | }; 52 | 53 | const element: ElementRef = { 54 | nativeElement: thisNativeElement 55 | } as ElementRef; 56 | 57 | const element2: ElementRef = { 58 | nativeElement: {} 59 | } as ElementRef; 60 | 61 | const capturedNode = new CapturedNode(element, null); 62 | 63 | expect(capturedNode.contains(element2)).toBe(true); 64 | expect(contains).toHaveBeenCalledWith(element2.nativeElement); 65 | }); 66 | 67 | it('should be possible to drop node on element that is not element of current node', () => { 68 | const contains = jasmine.createSpy('contains').and.returnValue(false); 69 | const thisNativeElement = { 70 | contains 71 | }; 72 | 73 | const element: ElementRef = { 74 | nativeElement: thisNativeElement 75 | } as ElementRef; 76 | 77 | const element2: ElementRef = { 78 | nativeElement: {} 79 | } as ElementRef; 80 | 81 | const capturedNode = new CapturedNode(element, null); 82 | expect(capturedNode.canBeDroppedAt(element2)).toBe(true); 83 | expect(contains).toHaveBeenCalledWith(element2.nativeElement); 84 | }); 85 | 86 | it('should not be possible to drop node on itself', () => { 87 | const contains = jasmine.createSpy('contains').and.returnValue(true); 88 | const thisNativeElement = { 89 | contains 90 | }; 91 | 92 | const element: ElementRef = { 93 | nativeElement: thisNativeElement 94 | } as ElementRef; 95 | 96 | const capturedNode = new CapturedNode(element, null); 97 | 98 | expect(capturedNode.canBeDroppedAt(element)).toBe(false); 99 | expect(contains).not.toHaveBeenCalled(); 100 | }); 101 | 102 | it('should not be possible to drop node on its child', () => { 103 | const contains = jasmine.createSpy('contains').and.returnValue(true); 104 | const thisNativeElement = { 105 | contains 106 | }; 107 | 108 | const element: ElementRef = { 109 | nativeElement: thisNativeElement 110 | } as ElementRef; 111 | 112 | const element2: ElementRef = { 113 | nativeElement: {} 114 | } as ElementRef; 115 | 116 | const capturedNode = new CapturedNode(element, null); 117 | 118 | expect(capturedNode.canBeDroppedAt(element2)).toBe(false); 119 | expect(thisNativeElement.contains).toHaveBeenCalledWith(element2.nativeElement); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /test/settings.tree-internal.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { By } from '@angular/platform-browser'; 3 | import { Component, ElementRef, DebugElement } from '@angular/core'; 4 | import { TreeInternalComponent } from '../src/tree-internal.component'; 5 | import { TreeComponent } from '../src/tree.component'; 6 | import { TreeModel, Ng2TreeSettings } from '../src/tree.types'; 7 | import { TreeService } from '../src/tree.service'; 8 | import { NodeMenuService } from '../src/menu/node-menu.service'; 9 | import { NodeMenuComponent } from '../src/menu/node-menu.component'; 10 | import { NodeDraggableService } from '../src/draggable/node-draggable.service'; 11 | import { NodeDraggableDirective } from '../src/draggable/node-draggable.directive'; 12 | import { NodeEditableDirective } from '../src/editable/node-editable.directive'; 13 | import { NodeMenuAction } from '../src/menu/menu.events'; 14 | import * as EventUtils from '../src/utils/event.utils'; 15 | import { CapturedNode } from '../src/draggable/captured-node'; 16 | import { SafeHtmlPipe } from '../src/utils/safe-html.pipe'; 17 | 18 | let fixture; 19 | let masterInternalTreeEl; 20 | let masterComponentInstance; 21 | 22 | const tree: TreeModel = { 23 | value: 'Master', 24 | settings: { 25 | cssClasses: { 26 | expanded: 'fa fa-caret-down', 27 | collapsed: 'fa fa-caret-right', 28 | leaf: 'fa' 29 | }, 30 | templates: { 31 | node: '', 32 | leaf: '' 33 | } 34 | }, 35 | children: [ 36 | { value: 'Servant#1' }, 37 | { 38 | value: 'Servant#2', 39 | settings: { 40 | templates: { 41 | leaf: '' 42 | } 43 | } 44 | } 45 | ] 46 | }; 47 | 48 | @Component({ 49 | template: `
` 50 | }) 51 | class TestComponent { 52 | public tree: TreeModel = tree; 53 | public constructor(public treeHolder: ElementRef) {} 54 | } 55 | 56 | describe('settings on tree model', () => { 57 | beforeEach(() => { 58 | TestBed.configureTestingModule({ 59 | declarations: [ 60 | TestComponent, 61 | TreeInternalComponent, 62 | TreeComponent, 63 | NodeEditableDirective, 64 | NodeMenuComponent, 65 | NodeDraggableDirective, 66 | SafeHtmlPipe 67 | ], 68 | providers: [NodeMenuService, NodeDraggableService, TreeService] 69 | }); 70 | 71 | fixture = TestBed.createComponent(TestComponent); 72 | 73 | masterInternalTreeEl = fixture.debugElement.query(By.css('#master')).query(By.directive(TreeInternalComponent)); 74 | masterComponentInstance = masterInternalTreeEl.componentInstance; 75 | 76 | fixture.detectChanges(); 77 | }); 78 | 79 | describe('cssClasses setting in tree', () => { 80 | it('adds appropriate css classes for a expanded node', () => { 81 | const foldingEl: DebugElement = masterInternalTreeEl.query(By.css('.folding')); 82 | expect(foldingEl.classes).toEqual({ folding: true, fa: true, 'fa-caret-down': true }); 83 | }); 84 | 85 | it('adds appropriate css classes for a collapsed node', () => { 86 | const foldingEl: DebugElement = masterInternalTreeEl.query(By.css('.folding')); 87 | 88 | foldingEl.nativeElement.click(); 89 | fixture.detectChanges(); 90 | 91 | expect(foldingEl.classes).toEqual({ folding: true, fa: true, 'fa-caret-down': false, 'fa-caret-right': true }); 92 | }); 93 | 94 | it('adds appropriate css classes for a leaf', () => { 95 | const foldingEl: DebugElement = masterInternalTreeEl.queryAll(By.css('.folding'))[1]; 96 | expect(foldingEl.classes).toEqual({ folding: true, fa: true }); 97 | }); 98 | }); 99 | 100 | describe('templates setting in tree', () => { 101 | it(`puts node templates content to the left of the node's value`, () => { 102 | const [ 103 | masterNodeTemplate, 104 | servant1NodeTemplate, 105 | servant2NodeTemplate 106 | ]: DebugElement[] = masterInternalTreeEl.queryAll(By.css('.node-template')); 107 | 108 | expect(masterNodeTemplate.nativeElement.innerHTML).toEqual(tree.settings.templates.node); 109 | expect(servant1NodeTemplate.nativeElement.innerHTML).toEqual(tree.settings.templates.leaf); 110 | expect(servant2NodeTemplate.nativeElement.innerHTML).toEqual(tree.children[1].settings.templates.leaf); 111 | }); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ng2-tree", 3 | "version": "3.0.0", 4 | "description": "angular2 component for visualizing data that can be naturally represented as a tree", 5 | "main": "index.js", 6 | "license": "MIT", 7 | "author": { 8 | "name": "Georgii Rychko", 9 | "email": "rychko.georgiy@gmail.com" 10 | }, 11 | "homepage": "https://github.com/valor-software/ng2-tree", 12 | "repository": { 13 | "type": "git", 14 | "url": "git@github.com:valor-software/ng2-tree.git" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/valor-software/ng2-tree/issues" 18 | }, 19 | "private": true, 20 | "keywords": [ 21 | "tree", 22 | "ng2", 23 | "angular2", 24 | "ng2-tree", 25 | "branch", 26 | "tree-view", 27 | "branchy", 28 | "angular2-tree-view", 29 | "expand", 30 | "collapse", 31 | "recursive" 32 | ], 33 | "scripts": { 34 | "compile": "npx ng-packagr -p ng-package.json", 35 | "clean": "rimraf coverage build dist dist-demo bundles factories .publish", 36 | "pre:publish": "npm run lint && npm run compile && cp -R src/styles.css README.md media dist/ng2-tree", 37 | "post:publish": "npm run build:demo && gh-pages -d dist-demo", 38 | "start": "ng serve", 39 | "build:demo": "ng build", 40 | "test": "ng test --watch=false", 41 | "test:w": "ng test --watch=true", 42 | "test:cov": "ng test -sr -cc", 43 | "lint": "ng lint ng2-tree", 44 | "e2e": "ng e2e", 45 | "changelog": "conventional-changelog -i CHANGELOG.md -s -p angular", 46 | "github-release": "conventional-github-releaser -p angular", 47 | "version": "npm run changelog && git add CHANGELOG.md", 48 | "postversion": "git push origin master && git push --tags && npm run github-release && node publish.js", 49 | "webdriver-update": "node ./node_modules/protractor/bin/webdriver-manager update", 50 | "precommit": "pretty-quick --staged", 51 | "prettier": "prettier --write '{.,src,test}/**/*.ts'" 52 | }, 53 | "husky": { 54 | "hooks": { 55 | "pre-commit": "npm run lint && pretty-quick --staged" 56 | } 57 | }, 58 | "devDependencies": { 59 | "@angular-devkit/build-angular": "^16.2.11", 60 | "@angular-eslint/builder": "16.3.1", 61 | "@angular-eslint/eslint-plugin": "16.3.1", 62 | "@angular-eslint/eslint-plugin-template": "16.3.1", 63 | "@angular-eslint/schematics": "16.3.1", 64 | "@angular-eslint/template-parser": "16.3.1", 65 | "@angular/cli": "^16.2.11", 66 | "@angular/common": "16.2.12", 67 | "@angular/compiler": "16.2.12", 68 | "@angular/compiler-cli": "16.2.12", 69 | "@angular/core": "16.2.12", 70 | "@angular/forms": "16.2.12", 71 | "@angular/language-service": "16.2.12", 72 | "@angular/platform-browser": "16.2.12", 73 | "@angular/platform-browser-dynamic": "16.2.12", 74 | "@angular/router": "16.2.12", 75 | "@types/jasmine": "^3.10.18", 76 | "@types/jest": "^29.5.11", 77 | "@types/node": "^12.20.55", 78 | "@typescript-eslint/eslint-plugin": "^6.19.0", 79 | "@typescript-eslint/parser": "^6.19.0", 80 | "alertifyjs": "1.10.0", 81 | "codelyzer": "^6.0.0", 82 | "conventional-changelog": "1.1.7", 83 | "conventional-changelog-cli": "1.3.5", 84 | "conventional-github-releaser": "2.0.0", 85 | "core-js": "2.5.1", 86 | "eslint": "^8.51.0", 87 | "eslint-plugin-html": "^7.1.0", 88 | "font-awesome": "4.7.0", 89 | "gh-pages": "1.1.0", 90 | "husky": "0.14.3", 91 | "jasmine-core": "^3.99.1", 92 | "jasmine-data-provider": "2.2.0", 93 | "jasmine-spec-reporter": "^5.0.2", 94 | "karma": "~6.4.2", 95 | "karma-chrome-launcher": "^3.2.0", 96 | "karma-cli": "^2.0.0", 97 | "karma-coverage-istanbul-reporter": "~3.0.2", 98 | "karma-jasmine": "^4.0.2", 99 | "karma-jasmine-html-reporter": "^1.7.0", 100 | "karma-phantomjs-launcher": "1.0.4", 101 | "ng-packagr": "^16.2.3", 102 | "phantomjs-polyfill": "0.0.2", 103 | "phantomjs-prebuilt": "2.1.16", 104 | "prettier": "1.10.2", 105 | "pretty-quick": "1.4.1", 106 | "protractor": "~7.0.0", 107 | "puppeteer": "^1.20.0", 108 | "rimraf": "2.6.2", 109 | "rxjs": "^6.6.7", 110 | "rxjs-tslint-rules": "4.34.8", 111 | "shelljs": "0.7.8", 112 | "systemjs-builder": "0.16.12", 113 | "ts-node": "3.3.0", 114 | "tslint": "~6.1.0", 115 | "typescript": "^4.9.5", 116 | "uuid": "^3.1.0", 117 | "webpack": "^5.75.0", 118 | "zone.js": "^0.13.3" 119 | }, 120 | "dependencies": { 121 | "karma-webpack": "^5.0.0", 122 | "tslib": "^2.0.0" 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/tree.types.ts: -------------------------------------------------------------------------------- 1 | import { defaultsDeep, get, omit } from './utils/fn.utils'; 2 | import { NodeMenuItem } from './menu/node-menu.component'; 3 | 4 | export class FoldingType { 5 | public static Expanded: FoldingType = new FoldingType('node-expanded'); 6 | public static Collapsed: FoldingType = new FoldingType('node-collapsed'); 7 | public static Empty: FoldingType = new FoldingType('node-empty'); 8 | public static Leaf: FoldingType = new FoldingType('node-leaf'); 9 | 10 | public constructor(private _cssClass: string) {} 11 | 12 | public get cssClass(): string { 13 | return this._cssClass; 14 | } 15 | } 16 | 17 | export type ChildrenLoadingFunction = (callback: (children: TreeModel[]) => void) => void; 18 | 19 | export interface TreeModel { 20 | value: string | RenamableNode; 21 | id?: string | number; 22 | children?: TreeModel[]; 23 | loadChildren?: ChildrenLoadingFunction; 24 | settings?: TreeModelSettings; 25 | emitLoadNextLevel?: boolean; 26 | _status?: TreeStatus; 27 | _foldingType?: FoldingType; 28 | [additionalData: string]: any; 29 | } 30 | 31 | export interface CssClasses { 32 | /* The class or classes that should be added to the expanded node */ 33 | expanded?: string; 34 | 35 | /* The class or classes that should be added to the collapsed node */ 36 | collapsed?: string; 37 | 38 | /* The class or classes that should be added to the empty node */ 39 | empty?: string; 40 | 41 | /* The class or classes that should be added to the expanded to the leaf */ 42 | leaf?: string; 43 | } 44 | 45 | export interface Templates { 46 | /* A template for a node */ 47 | node?: string; 48 | 49 | /* A template for a leaf node */ 50 | leaf?: string; 51 | 52 | /* A template for left menu html element */ 53 | leftMenu?: string; 54 | } 55 | 56 | export class TreeModelSettings { 57 | /* cssClasses - set custom css classes which will be used for a tree */ 58 | public cssClasses?: CssClasses; 59 | 60 | /* Templates - set custom html templates to be used in a tree */ 61 | public templates?: Templates; 62 | 63 | /** 64 | * "leftMenu" property when set to true makes left menu available. 65 | * @name TreeModelSettings#leftMenu 66 | * @type boolean 67 | * @default false 68 | */ 69 | public leftMenu?: boolean; 70 | 71 | /** 72 | * "rightMenu" property when set to true makes right menu available. 73 | * @name TreeModelSettings#rightMenu 74 | * @type boolean 75 | * @default true 76 | */ 77 | public rightMenu?: boolean; 78 | 79 | /** 80 | * "menu" property when set will be available as custom context menu. 81 | * @name TreeModelSettings#MenuItems 82 | * @type NodeMenuItem 83 | */ 84 | public menuItems?: NodeMenuItem[]; 85 | 86 | /** 87 | * "static" property when set to true makes it impossible to drag'n'drop tree or call a menu on it. 88 | * @name TreeModelSettings#static 89 | * @type boolean 90 | * @default false 91 | */ 92 | public static?: boolean; 93 | 94 | public isCollapsedOnInit?: boolean; 95 | 96 | public checked?: boolean; 97 | 98 | public selectionAllowed?: boolean; 99 | 100 | public keepNodesInDOM?: boolean; 101 | 102 | public static readonly NOT_CASCADING_SETTINGS = ['selectionAllowed']; 103 | 104 | public static merge(child: TreeModel, parent: TreeModel): TreeModelSettings { 105 | const parentCascadingSettings = omit(get(parent, 'settings'), TreeModelSettings.NOT_CASCADING_SETTINGS); 106 | return defaultsDeep({}, get(child, 'settings'), parentCascadingSettings, { 107 | static: false, 108 | leftMenu: false, 109 | rightMenu: true, 110 | isCollapsedOnInit: false, 111 | checked: false, 112 | keepNodesInDOM: false, 113 | selectionAllowed: true 114 | }); 115 | } 116 | } 117 | 118 | export class Ng2TreeSettings { 119 | /** 120 | * Indicates root visibility in the tree. When true - root is invisible. 121 | * @name Ng2TreeSettings#rootIsVisible 122 | * @type boolean 123 | */ 124 | rootIsVisible? = true; 125 | showCheckboxes? = false; 126 | enableCheckboxes? = true; 127 | } 128 | 129 | export enum TreeStatus { 130 | New, 131 | Modified, 132 | IsBeingRenamed 133 | } 134 | 135 | export interface RenamableNode { 136 | /** 137 | * Set new value of the renamable node. Implementation of this method is up to user. 138 | * @param {string} name - A new value of the node. 139 | */ 140 | setName(name: string): void; 141 | 142 | /** 143 | * Get string representation of the node. Implementation of this method is up to user. 144 | * @returns {string} - A node string representation. 145 | */ 146 | toString(): string; 147 | } 148 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "ng2-tree": { 7 | "root": "src", 8 | "sourceRoot": "src/demo", 9 | "projectType": "application", 10 | "architect": { 11 | "build": { 12 | "builder": "@angular-devkit/build-angular:browser", 13 | "options": { 14 | "outputPath": "dist-demo", 15 | "index": "src/demo/index.html", 16 | "main": "src/demo/main.ts", 17 | "tsConfig": "src/demo/tsconfig.app.json", 18 | "polyfills": "src/demo/polyfills.ts", 19 | "assets": ["src/demo/assets", "src/demo/favicon.ico"], 20 | "styles": [ 21 | "node_modules/alertifyjs/build/css/alertify.css", 22 | "node_modules/alertifyjs/build/css/themes/default.css", 23 | "node_modules/font-awesome/css/font-awesome.min.css", 24 | "src/styles.css" 25 | ], 26 | "scripts": ["node_modules/alertifyjs/build/alertify.js"], 27 | "vendorChunk": true, 28 | "extractLicenses": false, 29 | "buildOptimizer": false, 30 | "sourceMap": true, 31 | "optimization": false, 32 | "namedChunks": true 33 | }, 34 | "configurations": { 35 | "production": { 36 | "budgets": [ 37 | { 38 | "type": "anyComponentStyle", 39 | "maximumWarning": "6kb" 40 | } 41 | ], 42 | "optimization": true, 43 | "outputHashing": "all", 44 | "sourceMap": false, 45 | "namedChunks": false, 46 | "extractLicenses": true, 47 | "vendorChunk": false, 48 | "buildOptimizer": true, 49 | "fileReplacements": [ 50 | { 51 | "replace": "src/demo/environments/environment.ts", 52 | "with": "src/demo/environments/environment.prod.ts" 53 | } 54 | ] 55 | }, 56 | "development": {} 57 | }, 58 | "defaultConfiguration": "production" 59 | }, 60 | "serve": { 61 | "builder": "@angular-devkit/build-angular:dev-server", 62 | "options": {}, 63 | "configurations": { 64 | "production": { 65 | "browserTarget": "ng2-tree:build:production" 66 | }, 67 | "development": { 68 | "browserTarget": "ng2-tree:build:development" 69 | } 70 | }, 71 | "defaultConfiguration": "development" 72 | }, 73 | "extract-i18n": { 74 | "builder": "@angular-devkit/build-angular:extract-i18n", 75 | "options": { 76 | "browserTarget": "ng2-tree:build" 77 | } 78 | }, 79 | "test": { 80 | "builder": "@angular-devkit/build-angular:karma", 81 | "options": { 82 | "karmaConfig": "./karma.conf.js", 83 | "polyfills": ["zone.js", "zone.js/testing"], 84 | "tsConfig": "src/demo/../../test/tsconfig.spec.json", 85 | "scripts": ["node_modules/alertifyjs/build/alertify.js"], 86 | "styles": [ 87 | "node_modules/alertifyjs/build/css/alertify.css", 88 | "node_modules/alertifyjs/build/css/themes/default.css", 89 | "node_modules/font-awesome/css/font-awesome.min.css", 90 | "src/styles.css" 91 | ], 92 | "assets": ["src/demo/assets", "src/demo/favicon.ico"] 93 | } 94 | }, 95 | "lint": { 96 | "builder": "@angular-eslint/builder:lint", 97 | "options": { 98 | "lintFilePatterns": ["src/**/*.ts", "src/**/*.html"] 99 | } 100 | } 101 | } 102 | }, 103 | "ng2-tree-e2e": { 104 | "root": "src/e2e", 105 | "sourceRoot": "src/e2e", 106 | "projectType": "application", 107 | "architect": { 108 | "e2e": { 109 | "builder": "@angular-devkit/build-angular:protractor", 110 | "options": { 111 | "protractorConfig": "./protractor.conf.js", 112 | "devServerTarget": "ng2-tree:serve" 113 | }, 114 | "configurations": {} 115 | } 116 | } 117 | } 118 | }, 119 | "schematics": { 120 | "@schematics/angular:component": { 121 | "prefix": "app", 122 | "style": "css" 123 | }, 124 | "@schematics/angular:directive": { 125 | "prefix": "app" 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/tree-controller.ts: -------------------------------------------------------------------------------- 1 | import { TreeService } from './tree.service'; 2 | import { Tree } from './tree'; 3 | import { TreeModel } from './tree.types'; 4 | import { NodeMenuItemAction } from './menu/menu.events'; 5 | import { TreeInternalComponent } from './tree-internal.component'; 6 | import { MouseButtons } from './utils/event.utils'; 7 | import { get } from './utils/fn.utils'; 8 | 9 | export class TreeController { 10 | private tree: Tree; 11 | private treeService: TreeService; 12 | 13 | constructor(private component: TreeInternalComponent) { 14 | this.tree = this.component.tree; 15 | this.treeService = this.component.treeService; 16 | } 17 | 18 | public select(): void { 19 | if (!this.isSelected()) { 20 | this.component.onNodeSelected({ button: MouseButtons.Left }); 21 | } 22 | } 23 | 24 | public unselect(): void { 25 | if (this.isSelected()) { 26 | this.component.onNodeUnselected({ button: MouseButtons.Left }); 27 | } 28 | } 29 | 30 | public isSelected(): boolean { 31 | return this.component.isSelected; 32 | } 33 | 34 | public expand(): void { 35 | if (this.isCollapsed()) { 36 | this.component.onSwitchFoldingType(); 37 | } 38 | } 39 | 40 | public expandToParent(tree: any = this.tree): void { 41 | if (tree) { 42 | const controller = this.treeService.getController(tree.id); 43 | if (controller) { 44 | requestAnimationFrame(() => { 45 | controller.expand(); 46 | this.expandToParent(tree.parent); 47 | }); 48 | } 49 | } 50 | } 51 | 52 | public isExpanded(): boolean { 53 | return this.tree.isNodeExpanded(); 54 | } 55 | 56 | public collapse(): void { 57 | if (this.isExpanded()) { 58 | this.component.onSwitchFoldingType(); 59 | } 60 | } 61 | 62 | public isCollapsed(): boolean { 63 | return this.tree.isNodeCollapsed(); 64 | } 65 | 66 | public toTreeModel(): TreeModel { 67 | return this.tree.toTreeModel(); 68 | } 69 | 70 | public rename(newValue: string): void { 71 | this.tree.markAsBeingRenamed(); 72 | this.component.applyNewValue({ type: 'keyup', value: newValue }); 73 | } 74 | 75 | public remove(): void { 76 | this.component.onMenuItemSelected({ nodeMenuItemAction: NodeMenuItemAction.Remove }); 77 | } 78 | 79 | public addChild(newNode: TreeModel): void { 80 | if (this.tree.hasDeferredChildren() && !this.tree.childrenWereLoaded()) { 81 | return; 82 | } 83 | 84 | const newTree = this.tree.createNode(Array.isArray(newNode.children), newNode); 85 | this.treeService.fireNodeCreated(newTree); 86 | } 87 | 88 | public addChildAsync(newNode: TreeModel): Promise { 89 | if (this.tree.hasDeferredChildren() && !this.tree.childrenWereLoaded()) { 90 | return Promise.reject( 91 | new Error('This node loads its children asynchronously, hence child cannot be added this way') 92 | ); 93 | } 94 | 95 | const newTree = this.tree.createNode(Array.isArray(newNode.children), newNode); 96 | this.treeService.fireNodeCreated(newTree); 97 | 98 | // This will give TreeInternalComponent to set up a controller for the node 99 | return new Promise(resolve => { 100 | setTimeout(() => { 101 | resolve(newTree); 102 | }); 103 | }); 104 | } 105 | 106 | public changeNodeId(id: string | number) { 107 | if (!id) { 108 | throw Error('You should supply an id!'); 109 | } 110 | 111 | if (this.treeService.hasController(id)) { 112 | throw Error(`Controller already exists for the given id: ${id}`); 113 | } 114 | 115 | this.treeService.deleteController(this.tree.id); 116 | this.tree.id = id; 117 | this.treeService.setController(this.tree.id, this); 118 | } 119 | 120 | public reloadChildren(): void { 121 | this.tree.reloadChildren(); 122 | } 123 | 124 | public setChildren(children: TreeModel[]): void { 125 | if (!this.tree.isLeaf()) { 126 | this.tree.setChildren(children); 127 | } 128 | } 129 | 130 | public startRenaming(): void { 131 | this.tree.markAsBeingRenamed(); 132 | } 133 | 134 | public check(): void { 135 | this.component.onNodeChecked(); 136 | } 137 | 138 | public uncheck(): void { 139 | this.component.onNodeUnchecked(); 140 | } 141 | 142 | public isChecked(): boolean { 143 | return this.tree.checked; 144 | } 145 | 146 | public isIndetermined(): boolean { 147 | return get(this.component, 'checkboxElementRef.nativeElement.indeterminate'); 148 | } 149 | 150 | public allowSelection() { 151 | this.tree.selectionAllowed = true; 152 | } 153 | 154 | public forbidSelection() { 155 | this.tree.selectionAllowed = false; 156 | } 157 | 158 | public isSelectionAllowed(): boolean { 159 | return this.tree.selectionAllowed; 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/draggable/node-draggable.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, ElementRef, Inject, Input, OnDestroy, OnInit, Renderer2 } from '@angular/core'; 2 | import { NodeDraggableService } from './node-draggable.service'; 3 | import { CapturedNode } from './captured-node'; 4 | import { Tree } from '../tree'; 5 | 6 | @Directive({ 7 | selector: '[nodeDraggable]' 8 | }) 9 | export class NodeDraggableDirective implements OnDestroy, OnInit { 10 | public static DATA_TRANSFER_STUB_DATA = 'some browsers enable drag-n-drop only when dataTransfer has data'; 11 | 12 | @Input() public nodeDraggable: ElementRef; 13 | 14 | @Input() public tree: Tree; 15 | 16 | private nodeNativeElement: HTMLElement; 17 | private disposersForDragListeners: (() => void)[] = []; 18 | 19 | public constructor( 20 | @Inject(ElementRef) public element: ElementRef, 21 | @Inject(NodeDraggableService) private nodeDraggableService: NodeDraggableService, 22 | @Inject(Renderer2) private renderer: Renderer2 23 | ) { 24 | this.nodeNativeElement = element.nativeElement; 25 | } 26 | 27 | public ngOnInit(): void { 28 | if (!this.tree.isStatic()) { 29 | this.renderer.setAttribute(this.nodeNativeElement, 'draggable', 'true'); 30 | this.disposersForDragListeners.push( 31 | this.renderer.listen(this.nodeNativeElement, 'dragenter', this.handleDragEnter.bind(this)) 32 | ); 33 | this.disposersForDragListeners.push( 34 | this.renderer.listen(this.nodeNativeElement, 'dragover', this.handleDragOver.bind(this)) 35 | ); 36 | this.disposersForDragListeners.push( 37 | this.renderer.listen(this.nodeNativeElement, 'dragstart', this.handleDragStart.bind(this)) 38 | ); 39 | this.disposersForDragListeners.push( 40 | this.renderer.listen(this.nodeNativeElement, 'dragleave', this.handleDragLeave.bind(this)) 41 | ); 42 | this.disposersForDragListeners.push( 43 | this.renderer.listen(this.nodeNativeElement, 'drop', this.handleDrop.bind(this)) 44 | ); 45 | this.disposersForDragListeners.push( 46 | this.renderer.listen(this.nodeNativeElement, 'dragend', this.handleDragEnd.bind(this)) 47 | ); 48 | } 49 | } 50 | 51 | public ngOnDestroy(): void { 52 | /* tslint:disable:typedef */ 53 | this.disposersForDragListeners.forEach(dispose => dispose()); 54 | /* tslint:enable:typedef */ 55 | } 56 | 57 | private handleDragStart(e: DragEvent): any { 58 | if (e.stopPropagation) { 59 | e.stopPropagation(); 60 | } 61 | 62 | this.nodeDraggableService.captureNode(new CapturedNode(this.nodeDraggable, this.tree)); 63 | 64 | e.dataTransfer.setData('text', NodeDraggableDirective.DATA_TRANSFER_STUB_DATA); 65 | e.dataTransfer.effectAllowed = 'move'; 66 | } 67 | 68 | private handleDragOver(e: DragEvent): any { 69 | e.preventDefault(); 70 | e.dataTransfer.dropEffect = 'move'; 71 | } 72 | 73 | private handleDragEnter(e: DragEvent): any { 74 | e.preventDefault(); 75 | if (this.containsElementAt(e)) { 76 | this.addClass('over-drop-target'); 77 | } 78 | } 79 | 80 | private handleDragLeave(e: DragEvent): any { 81 | if (!this.containsElementAt(e)) { 82 | this.removeClass('over-drop-target'); 83 | } 84 | } 85 | 86 | private handleDrop(e: DragEvent): any { 87 | e.preventDefault(); 88 | if (e.stopPropagation) { 89 | e.stopPropagation(); 90 | } 91 | 92 | this.removeClass('over-drop-target'); 93 | 94 | if (!this.isDropPossible(e)) { 95 | return false; 96 | } 97 | 98 | if (this.nodeDraggableService.getCapturedNode()) { 99 | return this.notifyThatNodeWasDropped(); 100 | } 101 | } 102 | 103 | private isDropPossible(e: DragEvent): boolean { 104 | const capturedNode = this.nodeDraggableService.getCapturedNode(); 105 | return capturedNode && capturedNode.canBeDroppedAt(this.nodeDraggable) && this.containsElementAt(e); 106 | } 107 | 108 | private handleDragEnd(e: DragEvent): any { 109 | this.removeClass('over-drop-target'); 110 | this.nodeDraggableService.releaseCapturedNode(); 111 | } 112 | 113 | private containsElementAt(e: DragEvent): boolean { 114 | const { x = e.clientX, y = e.clientY } = e; 115 | return this.nodeNativeElement.contains(document.elementFromPoint(x, y)); 116 | } 117 | 118 | private addClass(className: string): void { 119 | const classList: DOMTokenList = this.nodeNativeElement.classList; 120 | classList.add(className); 121 | } 122 | 123 | private removeClass(className: string): void { 124 | const classList: DOMTokenList = this.nodeNativeElement.classList; 125 | classList.remove(className); 126 | } 127 | 128 | private notifyThatNodeWasDropped(): void { 129 | this.nodeDraggableService.fireNodeDragged(this.nodeDraggableService.getCapturedNode(), this.nodeDraggable); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | .node-menu { 2 | position: relative; 3 | width: 150px; 4 | } 5 | 6 | .node-menu .node-menu-content { 7 | width: 100%; 8 | padding: 5px; 9 | position: absolute; 10 | border: 1px solid #bdbdbd; 11 | border-radius: 5%; 12 | box-shadow: 0 0 5px #bdbdbd; 13 | background-color: #eee; 14 | color: #212121; 15 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 16 | z-index: 999; 17 | } 18 | 19 | .node-menu .node-menu-content li.node-menu-item { 20 | list-style: none; 21 | padding: 3px; 22 | } 23 | 24 | .node-menu .node-menu-content .node-menu-item:hover { 25 | border-radius: 5%; 26 | opacity: unset; 27 | cursor: pointer; 28 | background-color: #bdbdbd; 29 | transition: background-color 0.2s ease-out; 30 | } 31 | 32 | .node-menu .node-menu-content .node-menu-item .node-menu-item-icon { 33 | display: inline-block; 34 | width: 16px; 35 | } 36 | 37 | .node-menu .node-menu-content .node-menu-item .node-menu-item-icon.new-tag:before { 38 | content: '\25CF'; 39 | } 40 | .node-menu .node-menu-content .node-menu-item .node-menu-item-icon.new-folder:before { 41 | content: '\25B6'; 42 | } 43 | 44 | .node-menu .node-menu-content .node-menu-item .node-menu-item-icon.rename:before { 45 | content: '\270E'; 46 | } 47 | 48 | .node-menu .node-menu-content .node-menu-item .node-menu-item-icon.remove:before { 49 | content: '\2716'; 50 | } 51 | 52 | .node-menu .node-menu-content .node-menu-item .node-menu-item-value { 53 | margin-left: 5px; 54 | } 55 | 56 | tree-internal ul { 57 | padding: 3px 0 3px 25px; 58 | } 59 | 60 | tree-internal li { 61 | padding: 0; 62 | margin: 0; 63 | list-style: none; 64 | } 65 | 66 | tree-internal .over-drop-target { 67 | border: 4px solid #757575; 68 | transition: padding 0.2s ease-out; 69 | padding: 5px; 70 | border-radius: 5%; 71 | } 72 | 73 | tree-internal .tree { 74 | box-sizing: border-box; 75 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 76 | } 77 | 78 | tree-internal .tree li { 79 | list-style: none; 80 | cursor: default; 81 | } 82 | 83 | tree-internal .tree li div { 84 | display: inline-block; 85 | box-sizing: border-box; 86 | } 87 | 88 | tree-internal .tree .node-value { 89 | display: inline-block; 90 | color: #212121; 91 | } 92 | 93 | tree-internal .tree .node-value:after { 94 | display: block; 95 | padding-top: -3px; 96 | width: 0; 97 | height: 2px; 98 | background-color: #212121; 99 | content: ''; 100 | transition: width 0.3s; 101 | } 102 | 103 | tree-internal .tree .node-value:hover:after { 104 | width: 100%; 105 | } 106 | 107 | tree-internal .tree .node-left-menu { 108 | display: inline-block; 109 | height: 100%; 110 | width: auto; 111 | } 112 | 113 | tree-internal .tree .node-left-menu span:before { 114 | content: '\2026'; 115 | color: #757575; 116 | } 117 | 118 | tree-internal .tree .node-selected:after { 119 | width: 100%; 120 | } 121 | 122 | tree-internal .tree .folding { 123 | width: 25px; 124 | display: inline-block; 125 | line-height: 1px; 126 | padding: 0 5px; 127 | font-weight: bold; 128 | } 129 | 130 | tree-internal .tree .folding.node-collapsed { 131 | cursor: pointer; 132 | } 133 | 134 | tree-internal .tree .folding.node-collapsed:before { 135 | content: '\25B6'; 136 | color: #757575; 137 | } 138 | 139 | tree-internal .tree .folding.node-expanded { 140 | cursor: pointer; 141 | } 142 | 143 | tree-internal .tree .folding.node-expanded:before { 144 | content: '\25BC'; 145 | color: #757575; 146 | } 147 | 148 | tree-internal .tree .folding.node-empty { 149 | color: #212121; 150 | text-align: center; 151 | font-size: 0.89em; 152 | } 153 | 154 | tree-internal .tree .folding.node-empty:before { 155 | content: '\25B6'; 156 | color: #757575; 157 | } 158 | 159 | tree-internal .tree .folding.node-leaf { 160 | color: #212121; 161 | text-align: center; 162 | font-size: 0.89em; 163 | } 164 | 165 | tree-internal .tree .folding.node-leaf:before { 166 | content: '\25CF'; 167 | color: #757575; 168 | } 169 | 170 | tree-internal ul.rootless { 171 | padding: 0; 172 | } 173 | 174 | tree-internal div.rootless { 175 | display: none !important; 176 | } 177 | 178 | tree-internal .loading-children:after { 179 | content: ' loading ...'; 180 | color: #6a1b9a; 181 | font-style: italic; 182 | font-size: 0.9em; 183 | animation-name: loading-children; 184 | animation-duration: 2s; 185 | animation-timing-function: ease-in-out; 186 | animation-iteration-count: infinite; 187 | } 188 | 189 | @keyframes loading-children { 190 | 0% { color: #f3e5f5; } 191 | 12.5% { color: #e1bee7; } 192 | 25% { color: #ce93d8; } 193 | 37.5% { color: #ba68c8; } 194 | 50% { color: #ab47bc; } 195 | 62.5% { color: #9c27b0; } 196 | 75% { color: #8e24aa; } 197 | 87.5% { color: #7b1fa2; } 198 | 100% { color: #6a1b9a; } 199 | } 200 | 201 | 202 | 203 | -------------------------------------------------------------------------------- /src/tree.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | ContentChild, 4 | EventEmitter, 5 | Inject, 6 | Input, 7 | OnChanges, 8 | OnDestroy, 9 | OnInit, 10 | Output, 11 | SimpleChanges, 12 | TemplateRef, 13 | ViewChild 14 | } from '@angular/core'; 15 | import { TreeService } from './tree.service'; 16 | import * as TreeTypes from './tree.types'; 17 | 18 | import { MenuItemSelectedEvent, NodeCheckedEvent, NodeEvent, NodeUncheckedEvent } from './tree.events'; 19 | 20 | import { Tree } from './tree'; 21 | import { TreeController } from './tree-controller'; 22 | import { Subscription } from 'rxjs'; 23 | 24 | @Component({ 25 | selector: 'tree', 26 | template: ``, 27 | providers: [TreeService] 28 | }) 29 | export class TreeComponent implements OnInit, OnChanges, OnDestroy { 30 | private static EMPTY_TREE: Tree = new Tree({ value: '' }); 31 | 32 | /* tslint:disable:no-input-rename */ 33 | @Input('tree') public treeModel: TreeTypes.TreeModel; 34 | /* tslint:enable:no-input-rename */ 35 | 36 | @Input() public settings: TreeTypes.Ng2TreeSettings; 37 | 38 | @Output() public nodeCreated: EventEmitter = new EventEmitter(); 39 | 40 | @Output() public nodeRemoved: EventEmitter = new EventEmitter(); 41 | 42 | @Output() public nodeRenamed: EventEmitter = new EventEmitter(); 43 | 44 | @Output() public nodeSelected: EventEmitter = new EventEmitter(); 45 | 46 | @Output() public nodeUnselected: EventEmitter = new EventEmitter(); 47 | 48 | @Output() public nodeMoved: EventEmitter = new EventEmitter(); 49 | 50 | @Output() public nodeExpanded: EventEmitter = new EventEmitter(); 51 | 52 | @Output() public nodeCollapsed: EventEmitter = new EventEmitter(); 53 | 54 | @Output() public loadNextLevel: EventEmitter = new EventEmitter(); 55 | 56 | @Output() public nodeChecked: EventEmitter = new EventEmitter(); 57 | 58 | @Output() public nodeUnchecked: EventEmitter = new EventEmitter(); 59 | 60 | @Output() public menuItemSelected: EventEmitter = new EventEmitter(); 61 | 62 | public tree: Tree; 63 | 64 | @ViewChild('rootComponent', { static: false }) 65 | public rootComponent; 66 | 67 | @ContentChild(TemplateRef, { static: false }) 68 | public template; 69 | 70 | private subscriptions: Subscription[] = []; 71 | 72 | public constructor(@Inject(TreeService) private treeService: TreeService) {} 73 | 74 | public ngOnChanges(changes: SimpleChanges): void { 75 | if (!this.treeModel) { 76 | this.tree = TreeComponent.EMPTY_TREE; 77 | } else { 78 | this.tree = new Tree(this.treeModel); 79 | } 80 | } 81 | 82 | public ngOnInit(): void { 83 | this.subscriptions.push( 84 | this.treeService.nodeRemoved$.subscribe((e: NodeEvent) => { 85 | this.nodeRemoved.emit(e); 86 | }) 87 | ); 88 | 89 | this.subscriptions.push( 90 | this.treeService.nodeRenamed$.subscribe((e: NodeEvent) => { 91 | this.nodeRenamed.emit(e); 92 | }) 93 | ); 94 | 95 | this.subscriptions.push( 96 | this.treeService.nodeCreated$.subscribe((e: NodeEvent) => { 97 | this.nodeCreated.emit(e); 98 | }) 99 | ); 100 | 101 | this.subscriptions.push( 102 | this.treeService.nodeSelected$.subscribe((e: NodeEvent) => { 103 | this.nodeSelected.emit(e); 104 | }) 105 | ); 106 | 107 | this.subscriptions.push( 108 | this.treeService.nodeUnselected$.subscribe((e: NodeEvent) => { 109 | this.nodeUnselected.emit(e); 110 | }) 111 | ); 112 | 113 | this.subscriptions.push( 114 | this.treeService.nodeMoved$.subscribe((e: NodeEvent) => { 115 | this.nodeMoved.emit(e); 116 | }) 117 | ); 118 | 119 | this.subscriptions.push( 120 | this.treeService.nodeExpanded$.subscribe((e: NodeEvent) => { 121 | this.nodeExpanded.emit(e); 122 | }) 123 | ); 124 | 125 | this.subscriptions.push( 126 | this.treeService.nodeCollapsed$.subscribe((e: NodeEvent) => { 127 | this.nodeCollapsed.emit(e); 128 | }) 129 | ); 130 | 131 | this.subscriptions.push( 132 | this.treeService.menuItemSelected$.subscribe((e: MenuItemSelectedEvent) => { 133 | this.menuItemSelected.emit(e); 134 | }) 135 | ); 136 | 137 | this.subscriptions.push( 138 | this.treeService.loadNextLevel$.subscribe((e: NodeEvent) => { 139 | this.loadNextLevel.emit(e); 140 | }) 141 | ); 142 | 143 | this.subscriptions.push( 144 | this.treeService.nodeChecked$.subscribe((e: NodeCheckedEvent) => { 145 | this.nodeChecked.emit(e); 146 | }) 147 | ); 148 | 149 | this.subscriptions.push( 150 | this.treeService.nodeUnchecked$.subscribe((e: NodeUncheckedEvent) => { 151 | this.nodeUnchecked.emit(e); 152 | }) 153 | ); 154 | } 155 | 156 | public getController(): TreeController { 157 | return this.rootComponent.controller; 158 | } 159 | 160 | public getControllerByNodeId(id: number | string): TreeController { 161 | return this.treeService.getController(id); 162 | } 163 | 164 | ngOnDestroy(): void { 165 | this.subscriptions.forEach(sub => sub && sub.unsubscribe()); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/tree.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LoadNextLevelEvent, 3 | MenuItemSelectedEvent, 4 | NodeCheckedEvent, 5 | NodeCollapsedEvent, 6 | NodeCreatedEvent, 7 | NodeExpandedEvent, 8 | NodeIndeterminedEvent, 9 | NodeMovedEvent, 10 | NodeRemovedEvent, 11 | NodeRenamedEvent, 12 | NodeSelectedEvent, 13 | NodeUncheckedEvent, 14 | NodeUnselectedEvent 15 | } from './tree.events'; 16 | import { RenamableNode } from './tree.types'; 17 | import { Tree } from './tree'; 18 | import { TreeController } from './tree-controller'; 19 | import { ElementRef, Inject, Injectable } from '@angular/core'; 20 | import { NodeDraggableService } from './draggable/node-draggable.service'; 21 | import { NodeDraggableEvent } from './draggable/draggable.events'; 22 | import { isEmpty } from './utils/fn.utils'; 23 | import { Observable, Subject } from 'rxjs'; 24 | import { filter } from 'rxjs/operators'; 25 | 26 | @Injectable() 27 | export class TreeService { 28 | public nodeMoved$: Subject = new Subject(); 29 | public nodeRemoved$: Subject = new Subject(); 30 | public nodeRenamed$: Subject = new Subject(); 31 | public nodeCreated$: Subject = new Subject(); 32 | public nodeSelected$: Subject = new Subject(); 33 | public nodeUnselected$: Subject = new Subject(); 34 | public nodeExpanded$: Subject = new Subject(); 35 | public nodeCollapsed$: Subject = new Subject(); 36 | public menuItemSelected$: Subject = new Subject(); 37 | public loadNextLevel$: Subject = new Subject(); 38 | public nodeChecked$: Subject = new Subject(); 39 | public nodeUnchecked$: Subject = new Subject(); 40 | public nodeIndetermined$: Subject = new Subject(); 41 | 42 | private controllers: Map = new Map(); 43 | 44 | public constructor(@Inject(NodeDraggableService) private nodeDraggableService: NodeDraggableService) { 45 | this.nodeRemoved$.subscribe((e: NodeRemovedEvent) => e.node.removeItselfFromParent()); 46 | } 47 | 48 | public unselectStream(tree: Tree): Observable { 49 | return this.nodeSelected$.pipe(filter((e: NodeSelectedEvent) => tree !== e.node)); 50 | } 51 | 52 | public fireNodeRemoved(tree: Tree): void { 53 | this.nodeRemoved$.next(new NodeRemovedEvent(tree, tree.positionInParent)); 54 | } 55 | 56 | public fireNodeCreated(tree: Tree): void { 57 | this.nodeCreated$.next(new NodeCreatedEvent(tree)); 58 | } 59 | 60 | public fireNodeSelected(tree: Tree): void { 61 | this.nodeSelected$.next(new NodeSelectedEvent(tree)); 62 | } 63 | 64 | public fireNodeUnselected(tree: Tree): void { 65 | this.nodeUnselected$.next(new NodeUnselectedEvent(tree)); 66 | } 67 | 68 | public fireNodeRenamed(oldValue: RenamableNode | string, tree: Tree): void { 69 | this.nodeRenamed$.next(new NodeRenamedEvent(tree, oldValue, tree.value)); 70 | } 71 | 72 | public fireNodeMoved(tree: Tree, parent: Tree): void { 73 | this.nodeMoved$.next(new NodeMovedEvent(tree, parent)); 74 | } 75 | 76 | public fireMenuItemSelected(tree: Tree, selectedItem: string): void { 77 | this.menuItemSelected$.next(new MenuItemSelectedEvent(tree, selectedItem)); 78 | } 79 | 80 | public fireNodeSwitchFoldingType(tree: Tree): void { 81 | if (tree.isNodeExpanded()) { 82 | this.fireNodeExpanded(tree); 83 | if (this.shouldFireLoadNextLevel(tree)) { 84 | this.fireLoadNextLevel(tree); 85 | } 86 | } else if (tree.isNodeCollapsed()) { 87 | this.fireNodeCollapsed(tree); 88 | } 89 | } 90 | 91 | private fireNodeExpanded(tree: Tree): void { 92 | this.nodeExpanded$.next(new NodeExpandedEvent(tree)); 93 | } 94 | 95 | private fireNodeCollapsed(tree: Tree): void { 96 | this.nodeCollapsed$.next(new NodeCollapsedEvent(tree)); 97 | } 98 | 99 | private fireLoadNextLevel(tree: Tree): void { 100 | this.loadNextLevel$.next(new LoadNextLevelEvent(tree)); 101 | } 102 | 103 | public fireNodeChecked(tree: Tree): void { 104 | this.nodeChecked$.next(new NodeCheckedEvent(tree)); 105 | } 106 | 107 | public fireNodeUnchecked(tree: Tree): void { 108 | this.nodeUnchecked$.next(new NodeUncheckedEvent(tree)); 109 | } 110 | 111 | public draggedStream(tree: Tree, element: ElementRef): Observable { 112 | return this.nodeDraggableService.draggableNodeEvents$.pipe( 113 | filter((e: NodeDraggableEvent) => e.target === element), 114 | filter((e: NodeDraggableEvent) => !e.captured.tree.hasChild(tree)) 115 | ); 116 | } 117 | 118 | public setController(id: string | number, controller: TreeController): void { 119 | this.controllers.set(id, controller); 120 | } 121 | 122 | public deleteController(id: string | number): void { 123 | if (this.controllers.has(id)) { 124 | this.controllers.delete(id); 125 | } 126 | } 127 | 128 | public getController(id: string | number): TreeController { 129 | if (this.controllers.has(id)) { 130 | return this.controllers.get(id); 131 | } 132 | 133 | return null; 134 | } 135 | 136 | public hasController(id: string | number): boolean { 137 | return this.controllers.has(id); 138 | } 139 | 140 | private shouldFireLoadNextLevel(tree: Tree): boolean { 141 | const shouldLoadNextLevel = 142 | tree.node.emitLoadNextLevel && 143 | !tree.node.loadChildren && 144 | !tree.childrenAreBeingLoaded() && 145 | isEmpty(tree.children); 146 | 147 | if (shouldLoadNextLevel) { 148 | tree.loadingChildrenRequested(); 149 | } 150 | 151 | return shouldLoadNextLevel; 152 | } 153 | 154 | public fireNodeIndetermined(tree: Tree): void { 155 | this.nodeIndetermined$.next(new NodeIndeterminedEvent(tree)); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /test/menu/node-menu.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { By } from '@angular/platform-browser'; 3 | import { EventEmitter } from '@angular/core'; 4 | import { NodeMenuComponent } from '../../src/menu/node-menu.component'; 5 | import { NodeMenuService } from '../../src/menu/node-menu.service'; 6 | import { NodeMenuItemAction, NodeMenuAction, NodeMenuEvent } from '../../src/menu/menu.events'; 7 | import { MouseButtons, Keys } from '../../src/utils/event.utils'; 8 | 9 | let fixture; 10 | let nodeMenuService; 11 | let componentInstance; 12 | 13 | describe('NodeMenuComponent', () => { 14 | beforeEach(() => { 15 | TestBed.configureTestingModule({ 16 | declarations: [NodeMenuComponent], 17 | providers: [NodeMenuService] 18 | }); 19 | 20 | fixture = TestBed.createComponent(NodeMenuComponent); 21 | componentInstance = fixture.debugElement.componentInstance; 22 | nodeMenuService = TestBed.inject(NodeMenuService); 23 | }); 24 | 25 | it('should be created by angular', () => { 26 | expect(fixture).not.toBeNull(); 27 | expect(nodeMenuService).not.toBeNull(); 28 | }); 29 | 30 | it('should have event emitter properly created', () => { 31 | expect(fixture.componentInstance.menuItemSelected instanceof EventEmitter).toBe(true); 32 | }); 33 | 34 | it('should have basic menu items', () => { 35 | expect(fixture.componentInstance.availableMenuItems.length).toEqual(4); 36 | expect(fixture.componentInstance.availableMenuItems[0]).toEqual({ 37 | name: 'New tag', 38 | action: NodeMenuItemAction.NewTag, 39 | cssClass: 'new-tag' 40 | }); 41 | 42 | expect(fixture.componentInstance.availableMenuItems[1]).toEqual({ 43 | name: 'New folder', 44 | action: NodeMenuItemAction.NewFolder, 45 | cssClass: 'new-folder' 46 | }); 47 | 48 | expect(fixture.componentInstance.availableMenuItems[2]).toEqual({ 49 | name: 'Rename', 50 | action: NodeMenuItemAction.Rename, 51 | cssClass: 'rename' 52 | }); 53 | 54 | expect(fixture.componentInstance.availableMenuItems[3]).toEqual({ 55 | name: 'Remove', 56 | action: NodeMenuItemAction.Remove, 57 | cssClass: 'remove' 58 | }); 59 | }); 60 | 61 | it('should render basic menu items', () => { 62 | fixture.detectChanges(); 63 | 64 | const menuItems = fixture.debugElement.queryAll(By.css('.node-menu-item')); 65 | expect(menuItems).not.toBeNull(); 66 | expect(menuItems.length).toEqual(4); 67 | 68 | expect(menuItems[0].query(By.css('.node-menu-item-icon')).nativeElement.classList).toContain('new-tag'); 69 | expect(menuItems[0].query(By.css('.node-menu-item-value')).nativeElement.innerText).toEqual('New tag'); 70 | 71 | expect(menuItems[1].query(By.css('.node-menu-item-icon')).nativeElement.classList).toContain('new-folder'); 72 | expect(menuItems[1].query(By.css('.node-menu-item-value')).nativeElement.innerText).toEqual('New folder'); 73 | 74 | expect(menuItems[2].query(By.css('.node-menu-item-icon')).nativeElement.classList).toContain('rename'); 75 | expect(menuItems[2].query(By.css('.node-menu-item-value')).nativeElement.innerText).toEqual('Rename'); 76 | 77 | expect(menuItems[3].query(By.css('.node-menu-item-icon')).nativeElement.classList).toContain('remove'); 78 | expect(menuItems[3].query(By.css('.node-menu-item-value')).nativeElement.innerText).toEqual('Remove'); 79 | }); 80 | 81 | it('should not emit an action on right mouse button click', () => { 82 | fixture.detectChanges(); 83 | 84 | const event = { 85 | button: MouseButtons.Right 86 | }; 87 | 88 | const menuItem = fixture.debugElement.query(By.css('.node-menu-item')); 89 | 90 | spyOn(componentInstance.menuItemSelected, 'emit'); 91 | 92 | menuItem.triggerEventHandler('click', event); 93 | 94 | expect(componentInstance.menuItemSelected.emit).not.toHaveBeenCalled(); 95 | }); 96 | 97 | it('should emit an action on left mouse button click', () => { 98 | fixture.detectChanges(); 99 | 100 | const event = { 101 | button: MouseButtons.Left 102 | }; 103 | 104 | const menuItem = fixture.debugElement.query(By.css('.node-menu-item')); 105 | spyOn(componentInstance.menuItemSelected, 'emit'); 106 | 107 | menuItem.triggerEventHandler('click', event); 108 | 109 | expect(componentInstance.menuItemSelected.emit).toHaveBeenCalledWith({ 110 | nodeMenuItemAction: NodeMenuItemAction.NewTag, 111 | nodeMenuItemSelected: 'New tag' 112 | }); 113 | }); 114 | 115 | it('should close menu on any click outside of it', () => { 116 | fixture.detectChanges(); 117 | 118 | spyOn(nodeMenuService.nodeMenuEvents$, 'next'); 119 | 120 | const event = document.createEvent('MouseEvents'); 121 | event.initMouseEvent('mousedown', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null); 122 | document.dispatchEvent(event); 123 | 124 | const expectedNodeMenuEvent: NodeMenuEvent = { 125 | sender: event.target as HTMLElement, 126 | action: NodeMenuAction.Close 127 | }; 128 | 129 | expect(nodeMenuService.nodeMenuEvents$.next).toHaveBeenCalledWith(expectedNodeMenuEvent); 130 | expect(nodeMenuService.nodeMenuEvents$.next).toHaveBeenCalledTimes(1); 131 | }); 132 | 133 | it('should close menu on any keyup outside of it', () => { 134 | fixture.detectChanges(); 135 | 136 | spyOn(nodeMenuService.nodeMenuEvents$, 'next'); 137 | 138 | const event: any = document.createEvent('Events'); 139 | event.keyCode = Keys.Escape; 140 | event.initEvent('keyup', true, true); 141 | 142 | document.dispatchEvent(event); 143 | 144 | const expectedNodeMenuEvent: NodeMenuEvent = { 145 | sender: event.target as HTMLElement, 146 | action: NodeMenuAction.Close 147 | }; 148 | 149 | expect(nodeMenuService.nodeMenuEvents$.next).toHaveBeenCalledWith(expectedNodeMenuEvent); 150 | expect(nodeMenuService.nodeMenuEvents$.next).toHaveBeenCalledTimes(1); 151 | }); 152 | 153 | it('should not close menu on event not considered to do so', () => { 154 | fixture.detectChanges(); 155 | 156 | spyOn(nodeMenuService.nodeMenuEvents$, 'next'); 157 | 158 | const event: any = document.createEvent('Events'); 159 | event.initEvent('keyup', true, true); 160 | 161 | document.dispatchEvent(event); 162 | 163 | expect(nodeMenuService.nodeMenuEvents$.next).not.toHaveBeenCalled(); 164 | }); 165 | 166 | it('should destroy globally registered event listeners', () => { 167 | fixture.detectChanges(); 168 | 169 | spyOn(nodeMenuService.nodeMenuEvents$, 'next'); 170 | 171 | componentInstance.ngOnDestroy(); 172 | 173 | const mouseEvent = document.createEvent('MouseEvents'); 174 | mouseEvent.initMouseEvent('click', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null); 175 | 176 | const keyboardEvent: any = document.createEvent('Events'); 177 | keyboardEvent.keyCode = Keys.Escape; 178 | keyboardEvent.initEvent('keyup', true, true); 179 | 180 | document.dispatchEvent(keyboardEvent); 181 | document.dispatchEvent(mouseEvent); 182 | 183 | expect(nodeMenuService.nodeMenuEvents$.next).not.toHaveBeenCalled(); 184 | }); 185 | }); 186 | -------------------------------------------------------------------------------- /test/data-provider/tree.data-provider.ts: -------------------------------------------------------------------------------- 1 | export class TreeDataProvider { 2 | public static treeModelSettings: any = { 3 | 'default values': { 4 | treeModelA: { value: '42' }, 5 | treeModelB: { value: '12' }, 6 | result: { 7 | static: false, 8 | leftMenu: false, 9 | rightMenu: true, 10 | isCollapsedOnInit: false, 11 | checked: false, 12 | selectionAllowed: true, 13 | keepNodesInDOM: false 14 | } 15 | }, 16 | 'first settings source has higher priority': { 17 | treeModelA: { 18 | value: '42', 19 | settings: { 20 | static: true, 21 | leftMenu: true, 22 | rightMenu: true, 23 | isCollapsedOnInit: true, 24 | checked: true, 25 | selectionAllowed: false, 26 | keepNodesInDOM: true 27 | } 28 | }, 29 | treeModelB: { 30 | value: '12', 31 | settings: { 32 | static: false, 33 | leftMenu: false, 34 | rightMenu: false, 35 | isCollapsedOnInit: false, 36 | checked: false, 37 | selectionAllowed: true, 38 | keepNodesInDOM: false 39 | } 40 | }, 41 | result: { 42 | static: true, 43 | leftMenu: true, 44 | rightMenu: true, 45 | isCollapsedOnInit: true, 46 | checked: true, 47 | selectionAllowed: false, 48 | keepNodesInDOM: true 49 | } 50 | }, 51 | 'second settings source has priority if first settings source does not have the option': { 52 | treeModelA: { value: '42' }, 53 | treeModelB: { 54 | value: '12', 55 | settings: { 56 | static: true, 57 | leftMenu: true, 58 | rightMenu: false, 59 | isCollapsedOnInit: true, 60 | checked: true, 61 | selectionAllowed: false, 62 | keepNodesInDOM: true 63 | } 64 | }, 65 | result: { 66 | static: true, 67 | leftMenu: true, 68 | rightMenu: false, 69 | isCollapsedOnInit: true, 70 | checked: true, 71 | selectionAllowed: true, 72 | keepNodesInDOM: true 73 | } 74 | }, 75 | 'first expanded property of cssClasses has higher priority': { 76 | treeModelA: { value: '12', settings: { cssClasses: { expanded: 'arrow-down-o' } } }, 77 | treeModelB: { 78 | value: '42', 79 | settings: { cssClasses: { expanded: 'arrow-down', collapsed: 'arrow-right', empty: 'arrow-gray', leaf: 'dot' } } 80 | }, 81 | result: { 82 | isCollapsedOnInit: false, 83 | static: false, 84 | leftMenu: false, 85 | rightMenu: true, 86 | checked: false, 87 | keepNodesInDOM: false, 88 | selectionAllowed: true, 89 | cssClasses: { expanded: 'arrow-down-o', collapsed: 'arrow-right', empty: 'arrow-gray', leaf: 'dot' } 90 | } 91 | }, 92 | 'first collapsed property of cssClasses has higher priority': { 93 | treeModelA: { value: '12', settings: { cssClasses: { collapsed: 'arrow-right-o' } } }, 94 | treeModelB: { 95 | value: '42', 96 | settings: { cssClasses: { expanded: 'arrow-down', collapsed: 'arrow-right', empty: 'arrow-gray', leaf: 'dot' } } 97 | }, 98 | result: { 99 | isCollapsedOnInit: false, 100 | static: false, 101 | leftMenu: false, 102 | rightMenu: true, 103 | keepNodesInDOM: false, 104 | checked: false, 105 | selectionAllowed: true, 106 | cssClasses: { expanded: 'arrow-down', collapsed: 'arrow-right-o', empty: 'arrow-gray', leaf: 'dot' } 107 | } 108 | }, 109 | 'first empty property of cssClasses has higher priority': { 110 | treeModelA: { value: '12', settings: { cssClasses: { empty: 'arrow-gray-o' } } }, 111 | treeModelB: { 112 | value: '42', 113 | settings: { cssClasses: { expanded: 'arrow-down', collapsed: 'arrow-right', empty: 'arrow-gray', leaf: 'dot' } } 114 | }, 115 | result: { 116 | isCollapsedOnInit: false, 117 | static: false, 118 | leftMenu: false, 119 | rightMenu: true, 120 | keepNodesInDOM: false, 121 | checked: false, 122 | selectionAllowed: true, 123 | cssClasses: { expanded: 'arrow-down', collapsed: 'arrow-right', empty: 'arrow-gray-o', leaf: 'dot' } 124 | } 125 | }, 126 | 'first leaf property of cssClasses has higher priority': { 127 | treeModelA: { value: '12', settings: { cssClasses: { leaf: 'dot-o' } } }, 128 | treeModelB: { 129 | value: '42', 130 | settings: { cssClasses: { expanded: 'arrow-down', collapsed: 'arrow-right', empty: 'arrow-gray', leaf: 'dot' } } 131 | }, 132 | result: { 133 | isCollapsedOnInit: false, 134 | static: false, 135 | leftMenu: false, 136 | rightMenu: true, 137 | keepNodesInDOM: false, 138 | checked: false, 139 | selectionAllowed: true, 140 | cssClasses: { expanded: 'arrow-down', collapsed: 'arrow-right', empty: 'arrow-gray', leaf: 'dot-o' } 141 | } 142 | }, 143 | 'first properties of cssClasses has higher priority': { 144 | treeModelA: { 145 | value: '12', 146 | settings: { 147 | cssClasses: { expanded: 'arrow-down-o', collapsed: 'arrow-right-o', empty: 'arrow-gray-o', leaf: 'dot-o' } 148 | } 149 | }, 150 | treeModelB: { 151 | value: '42', 152 | settings: { cssClasses: { expanded: 'arrow-down', collapsed: 'arrow-right', empty: 'arrow-gray', leaf: 'dot' } } 153 | }, 154 | result: { 155 | isCollapsedOnInit: false, 156 | static: false, 157 | leftMenu: false, 158 | rightMenu: true, 159 | keepNodesInDOM: false, 160 | checked: false, 161 | selectionAllowed: true, 162 | cssClasses: { expanded: 'arrow-down-o', collapsed: 'arrow-right-o', empty: 'arrow-gray-o', leaf: 'dot-o' } 163 | } 164 | }, 165 | 'second properties of cssClasses in settings has priority, if first source does not have them': { 166 | treeModelA: { value: '42', settings: { static: true, leftMenu: true, rightMenu: false } }, 167 | treeModelB: { 168 | value: '12', 169 | settings: { 170 | cssClasses: { expanded: 'arrow-down-o', collapsed: 'arrow-right-o', empty: 'arrow-gray-o', leaf: 'dot-o' } 171 | } 172 | }, 173 | result: { 174 | isCollapsedOnInit: false, 175 | static: true, 176 | leftMenu: true, 177 | rightMenu: false, 178 | keepNodesInDOM: false, 179 | checked: false, 180 | selectionAllowed: true, 181 | cssClasses: { expanded: 'arrow-down-o', collapsed: 'arrow-right-o', empty: 'arrow-gray-o', leaf: 'dot-o' } 182 | } 183 | }, 184 | 'first node property of templates has higher priority': { 185 | treeModelA: { value: '12', settings: { templates: { node: '' } } }, 186 | treeModelB: { 187 | value: '42', 188 | settings: { 189 | templates: { 190 | node: '', 191 | leaf: '', 192 | leftMenu: '' 193 | } 194 | } 195 | }, 196 | result: { 197 | isCollapsedOnInit: false, 198 | static: false, 199 | leftMenu: false, 200 | rightMenu: true, 201 | checked: false, 202 | keepNodesInDOM: false, 203 | selectionAllowed: true, 204 | templates: { 205 | node: '', 206 | leaf: '', 207 | leftMenu: '' 208 | } 209 | } 210 | }, 211 | 'first leaf property in templates has higher priority': { 212 | treeModelA: { value: '12', settings: { templates: { leaf: '' } } }, 213 | treeModelB: { 214 | value: '42', 215 | settings: { 216 | templates: { 217 | node: '', 218 | leaf: '', 219 | leftMenu: '' 220 | } 221 | } 222 | }, 223 | result: { 224 | isCollapsedOnInit: false, 225 | static: false, 226 | leftMenu: false, 227 | rightMenu: true, 228 | keepNodesInDOM: false, 229 | checked: false, 230 | selectionAllowed: true, 231 | templates: { 232 | node: '', 233 | leaf: '', 234 | leftMenu: '' 235 | } 236 | } 237 | }, 238 | 'first leftMenu property in templates has higher priority': { 239 | treeModelA: { value: '12', settings: { templates: { leftMenu: '' } } }, 240 | treeModelB: { 241 | value: '42', 242 | settings: { 243 | templates: { 244 | node: '', 245 | leaf: '', 246 | leftMenu: '' 247 | } 248 | } 249 | }, 250 | result: { 251 | isCollapsedOnInit: false, 252 | static: false, 253 | leftMenu: false, 254 | rightMenu: true, 255 | keepNodesInDOM: false, 256 | checked: false, 257 | selectionAllowed: true, 258 | templates: { 259 | node: '', 260 | leaf: '', 261 | leftMenu: '' 262 | } 263 | } 264 | }, 265 | 'first properties of templates has higher priority': { 266 | treeModelA: { 267 | value: '12', 268 | settings: { 269 | templates: { 270 | node: '', 271 | leaf: '', 272 | leftMenu: '' 273 | } 274 | } 275 | }, 276 | treeModelB: { 277 | value: '42', 278 | settings: { 279 | templates: { 280 | node: '', 281 | leaf: '', 282 | leftMenu: '' 283 | } 284 | } 285 | }, 286 | result: { 287 | isCollapsedOnInit: false, 288 | static: false, 289 | leftMenu: false, 290 | rightMenu: true, 291 | checked: false, 292 | keepNodesInDOM: false, 293 | selectionAllowed: true, 294 | templates: { 295 | node: '', 296 | leaf: '', 297 | leftMenu: '' 298 | } 299 | } 300 | }, 301 | 'second properties of templates in settings has priority, if first source does not have them': { 302 | treeModelA: { value: '42', settings: { static: true, leftMenu: true, rightMenu: false } }, 303 | treeModelB: { 304 | value: '12', 305 | settings: { 306 | templates: { 307 | node: '', 308 | leaf: '', 309 | leftMenu: '' 310 | } 311 | } 312 | }, 313 | result: { 314 | isCollapsedOnInit: false, 315 | static: true, 316 | leftMenu: true, 317 | rightMenu: false, 318 | checked: false, 319 | keepNodesInDOM: false, 320 | selectionAllowed: true, 321 | templates: { 322 | node: '', 323 | leaf: '', 324 | leftMenu: '' 325 | } 326 | } 327 | } 328 | }; 329 | } 330 | -------------------------------------------------------------------------------- /test/draggable/node-draggable.directive.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { By } from '@angular/platform-browser'; 3 | import { Component, ElementRef } from '@angular/core'; 4 | import { NodeDraggableDirective } from '../../src/draggable/node-draggable.directive'; 5 | import { NodeDraggableService } from '../../src/draggable/node-draggable.service'; 6 | import { CapturedNode } from '../../src/draggable/captured-node'; 7 | import { NodeDraggableEvent } from '../../src/draggable/draggable.events'; 8 | import { Tree } from '../../src/tree'; 9 | 10 | let fixture; 11 | let directiveEl; 12 | let directiveInstance; 13 | let nodeDraggableService; 14 | 15 | @Component({ 16 | template: '
' 17 | }) 18 | class TestComponent { 19 | public tree: Tree = new Tree({ 20 | value: '42' 21 | }); 22 | 23 | public constructor(public draggableTarget: ElementRef) {} 24 | } 25 | 26 | describe('NodeDraggableDirective', () => { 27 | beforeEach(() => { 28 | TestBed.configureTestingModule({ 29 | declarations: [NodeDraggableDirective, TestComponent], 30 | providers: [NodeDraggableService] 31 | }); 32 | 33 | fixture = TestBed.createComponent(TestComponent); 34 | directiveEl = fixture.debugElement.query(By.directive(NodeDraggableDirective)); 35 | directiveInstance = directiveEl.injector.get(NodeDraggableDirective); 36 | nodeDraggableService = TestBed.inject(NodeDraggableService); 37 | }); 38 | 39 | it('should have correctly set "tree" property', () => { 40 | fixture.detectChanges(); 41 | 42 | expect(directiveInstance).not.toBeNull(); 43 | expect(directiveInstance.tree.value).toEqual('42'); 44 | }); 45 | 46 | it('should have correctly set "nodeDraggable" property', () => { 47 | fixture.detectChanges(); 48 | 49 | expect(directiveInstance).not.toBeNull(); 50 | expect(directiveInstance.nodeDraggable).toBe(fixture.componentInstance.draggableTarget); 51 | }); 52 | 53 | it('should have correctly set "element" property', () => { 54 | fixture.detectChanges(); 55 | 56 | const draggableElement = directiveEl.nativeElement; 57 | expect(directiveInstance.element.nativeElement).toBe(draggableElement); 58 | }); 59 | 60 | it('should make host draggable', () => { 61 | fixture.detectChanges(); 62 | 63 | const draggableElement = directiveEl.nativeElement; 64 | expect(draggableElement.draggable).toBe(true); 65 | }); 66 | 67 | it('should add appropriate class on "dragenter"', () => { 68 | fixture.detectChanges(); 69 | 70 | const dragenterEvent = jasmine.createSpyObj('event', ['preventDefault']); 71 | dragenterEvent.x = 0; 72 | dragenterEvent.y = 0; 73 | 74 | spyOn(document, 'elementFromPoint').and.returnValue(directiveEl.nativeElement); 75 | 76 | directiveEl.triggerEventHandler('dragenter', dragenterEvent); 77 | 78 | expect(document.elementFromPoint).toHaveBeenCalledWith(0, 0); 79 | expect(dragenterEvent.preventDefault).toHaveBeenCalledTimes(1); 80 | expect(directiveEl.nativeElement.classList.contains('over-drop-target')).toBe(true); 81 | }); 82 | 83 | it('should not add appropriate class if "dragenter" was triggered on element which is not child or target element itself', () => { 84 | fixture.detectChanges(); 85 | 86 | const dragenterEvent = jasmine.createSpyObj('event', ['preventDefault']); 87 | dragenterEvent.x = 1; 88 | dragenterEvent.y = 2; 89 | 90 | spyOn(document, 'elementFromPoint').and.returnValue(null); 91 | 92 | directiveEl.triggerEventHandler('dragenter', dragenterEvent); 93 | 94 | expect(document.elementFromPoint).toHaveBeenCalledWith(dragenterEvent.x, dragenterEvent.y); 95 | expect(dragenterEvent.preventDefault).toHaveBeenCalledTimes(1); 96 | expect(directiveEl.nativeElement.classList.contains('over-drop-target')).toBe(false); 97 | }); 98 | 99 | it('should use clientX, clientY properties on event if there are no x and y properties', () => { 100 | fixture.detectChanges(); 101 | 102 | const dragenterEvent = jasmine.createSpyObj('event', ['preventDefault']); 103 | dragenterEvent.clientX = 42; 104 | dragenterEvent.clientY = 12; 105 | 106 | spyOn(document, 'elementFromPoint'); 107 | 108 | directiveEl.triggerEventHandler('dragenter', dragenterEvent); 109 | 110 | expect(document.elementFromPoint).toHaveBeenCalledWith(dragenterEvent.clientX, dragenterEvent.clientY); 111 | }); 112 | 113 | it('should set dropEffect to "move" on dragover', () => { 114 | fixture.detectChanges(); 115 | 116 | const dragenterEvent = jasmine.createSpyObj('event', ['preventDefault']); 117 | dragenterEvent.dataTransfer = {}; 118 | 119 | directiveEl.triggerEventHandler('dragover', dragenterEvent); 120 | 121 | expect(dragenterEvent.preventDefault).toHaveBeenCalledTimes(1); 122 | expect(dragenterEvent.dataTransfer.dropEffect).toBe('move'); 123 | }); 124 | 125 | it('should captutre a node on dragstart', () => { 126 | fixture.detectChanges(); 127 | 128 | const dragenterEvent = jasmine.createSpyObj('e', ['stopPropagation']); 129 | dragenterEvent.dataTransfer = jasmine.createSpyObj('dataTransfer', ['setData']); 130 | 131 | directiveEl.triggerEventHandler('dragstart', dragenterEvent); 132 | 133 | expect(dragenterEvent.stopPropagation).toHaveBeenCalledTimes(1); 134 | 135 | const capturedNode: CapturedNode = nodeDraggableService.getCapturedNode(); 136 | expect(capturedNode.element).toBe(directiveInstance.nodeDraggable); 137 | expect(capturedNode.tree).toBe(directiveInstance.tree); 138 | 139 | expect(dragenterEvent.dataTransfer.setData).toHaveBeenCalledWith( 140 | 'text', 141 | NodeDraggableDirective.DATA_TRANSFER_STUB_DATA 142 | ); 143 | expect(dragenterEvent.dataTransfer.effectAllowed).toBe('move'); 144 | }); 145 | 146 | it('should remove "over-drop-target" class on dragleave if dragging left target element', () => { 147 | fixture.detectChanges(); 148 | 149 | const dragenterEvent = { x: 1, y: 2 }; 150 | 151 | spyOn(document, 'elementFromPoint').and.returnValue(null); 152 | 153 | const draggableElementClassList = directiveEl.nativeElement.classList; 154 | 155 | draggableElementClassList.add('over-drop-target'); 156 | expect(draggableElementClassList.contains('over-drop-target')).toBe(true); 157 | 158 | directiveEl.triggerEventHandler('dragleave', dragenterEvent); 159 | 160 | expect(document.elementFromPoint).toHaveBeenCalledWith(dragenterEvent.x, dragenterEvent.y); 161 | expect(draggableElementClassList.contains('over-drop-target')).toBe(false); 162 | }); 163 | 164 | it('should not remove "over-drop-target" dragging is happening on element', () => { 165 | fixture.detectChanges(); 166 | 167 | const dragenterEvent = { x: 1, y: 2 }; 168 | 169 | spyOn(document, 'elementFromPoint').and.returnValue(directiveEl.nativeElement); 170 | 171 | const draggableElementClassList = directiveEl.nativeElement.classList; 172 | 173 | draggableElementClassList.add('over-drop-target'); 174 | expect(draggableElementClassList.contains('over-drop-target')).toBe(true); 175 | 176 | directiveEl.triggerEventHandler('dragleave', dragenterEvent); 177 | 178 | expect(document.elementFromPoint).toHaveBeenCalledWith(dragenterEvent.x, dragenterEvent.y); 179 | expect(draggableElementClassList.contains('over-drop-target')).toBe(true); 180 | }); 181 | 182 | it('should release captured node on "dragend" and get rid of "over-drop-target" class', () => { 183 | fixture.detectChanges(); 184 | 185 | const draggableElementClassList = directiveEl.nativeElement.classList; 186 | draggableElementClassList.add('over-drop-target'); 187 | expect(draggableElementClassList.contains('over-drop-target')).toBe(true); 188 | 189 | spyOn(nodeDraggableService, 'releaseCapturedNode'); 190 | 191 | directiveEl.triggerEventHandler('dragend'); 192 | 193 | expect(draggableElementClassList.contains('over-drop-target')).toBe(false); 194 | expect(nodeDraggableService.releaseCapturedNode).toHaveBeenCalled(); 195 | }); 196 | 197 | it('should handle drop event: prevent default action and stop event propagation', () => { 198 | fixture.detectChanges(); 199 | 200 | const dragenterEvent = jasmine.createSpyObj('e', ['stopPropagation', 'preventDefault']); 201 | 202 | spyOn(nodeDraggableService, 'fireNodeDragged'); 203 | spyOn(nodeDraggableService, 'getCapturedNode').and.returnValue(null); 204 | 205 | directiveEl.triggerEventHandler('drop', dragenterEvent); 206 | 207 | expect(dragenterEvent.stopPropagation).toHaveBeenCalledTimes(1); 208 | expect(dragenterEvent.preventDefault).toHaveBeenCalledTimes(1); 209 | expect(nodeDraggableService.getCapturedNode).toHaveBeenCalledTimes(1); 210 | expect(nodeDraggableService.fireNodeDragged).not.toHaveBeenCalled(); 211 | }); 212 | 213 | it('should handle drop event: remove "over-drop-target" class', () => { 214 | fixture.detectChanges(); 215 | 216 | const dragenterEvent = jasmine.createSpyObj('e', ['stopPropagation', 'preventDefault']); 217 | 218 | spyOn(nodeDraggableService, 'fireNodeDragged'); 219 | spyOn(nodeDraggableService, 'getCapturedNode').and.returnValue(null); 220 | 221 | spyOn(directiveEl.nativeElement.classList, 'remove'); 222 | 223 | directiveEl.triggerEventHandler('drop', dragenterEvent); 224 | 225 | expect(dragenterEvent.stopPropagation).toHaveBeenCalledTimes(1); 226 | expect(dragenterEvent.preventDefault).toHaveBeenCalledTimes(1); 227 | 228 | expect(directiveEl.nativeElement.classList.remove).toHaveBeenCalledWith('over-drop-target'); 229 | expect(directiveEl.nativeElement.classList.remove).toHaveBeenCalledTimes(1); 230 | 231 | expect(nodeDraggableService.getCapturedNode).toHaveBeenCalledTimes(1); 232 | expect(nodeDraggableService.fireNodeDragged).not.toHaveBeenCalled(); 233 | }); 234 | 235 | it(`should handle drop event: do not notify that node was dropped if it is not a target's child element or target itself`, () => { 236 | fixture.detectChanges(); 237 | 238 | const dragenterEvent = jasmine.createSpyObj('e', ['stopPropagation', 'preventDefault']); 239 | 240 | spyOn(nodeDraggableService, 'fireNodeDragged'); 241 | 242 | const capturedNode = new CapturedNode(directiveInstance.nodeDraggable, directiveInstance.tree); 243 | spyOn(capturedNode, 'canBeDroppedAt').and.returnValue(true); 244 | 245 | spyOn(nodeDraggableService, 'getCapturedNode').and.returnValue(capturedNode); 246 | spyOn(document, 'elementFromPoint').and.returnValue(null); 247 | 248 | directiveEl.triggerEventHandler('drop', dragenterEvent); 249 | 250 | expect(capturedNode.canBeDroppedAt).toHaveBeenCalledWith(directiveInstance.nodeDraggable); 251 | expect(capturedNode.canBeDroppedAt).toHaveBeenCalledTimes(1); 252 | expect(nodeDraggableService.getCapturedNode).toHaveBeenCalledTimes(1); 253 | expect(nodeDraggableService.fireNodeDragged).not.toHaveBeenCalled(); 254 | }); 255 | 256 | it('should handle drop event: should notfy about successfully dropped node', () => { 257 | fixture.detectChanges(); 258 | 259 | const dragenterEvent = jasmine.createSpyObj('e', ['stopPropagation', 'preventDefault']); 260 | 261 | spyOn(nodeDraggableService, 'fireNodeDragged'); 262 | 263 | const capturedNode = new CapturedNode(directiveInstance.nodeDraggable, directiveInstance.tree); 264 | spyOn(capturedNode, 'canBeDroppedAt').and.returnValue(true); 265 | 266 | spyOn(nodeDraggableService, 'getCapturedNode').and.returnValue(capturedNode); 267 | spyOn(document, 'elementFromPoint').and.returnValue(directiveEl.nativeElement); 268 | 269 | directiveEl.triggerEventHandler('drop', dragenterEvent); 270 | 271 | expect(capturedNode.canBeDroppedAt).toHaveBeenCalledWith(directiveInstance.nodeDraggable); 272 | expect(capturedNode.canBeDroppedAt).toHaveBeenCalledTimes(1); 273 | 274 | expect(nodeDraggableService.getCapturedNode).toHaveBeenCalledTimes(3); 275 | expect(nodeDraggableService.fireNodeDragged).toHaveBeenCalledTimes(1); 276 | 277 | const fireCapturedNode = nodeDraggableService.fireNodeDragged.calls.argsFor(0)[0]; 278 | const fireTarget = nodeDraggableService.fireNodeDragged.calls.argsFor(0)[1]; 279 | expect(fireCapturedNode).toBe(capturedNode); 280 | expect(fireTarget).toBe(directiveInstance.nodeDraggable); 281 | }); 282 | 283 | it('TODO: should not make tree draggable if it is static', () => {}); 284 | }); 285 | -------------------------------------------------------------------------------- /test/tree.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { TreeService } from '../src/tree.service'; 3 | import { NodeDraggableService } from '../src/draggable/node-draggable.service'; 4 | import { Tree } from '../src/tree'; 5 | import { 6 | MenuItemSelectedEvent, 7 | NodeCollapsedEvent, 8 | NodeCreatedEvent, 9 | NodeExpandedEvent, 10 | NodeMovedEvent, 11 | NodeRemovedEvent, 12 | NodeRenamedEvent, 13 | NodeSelectedEvent 14 | } from '../src/tree.events'; 15 | import { ElementRef } from '@angular/core'; 16 | import { NodeDraggableEvent } from '../src/draggable/draggable.events'; 17 | import { CapturedNode } from '../src/draggable/captured-node'; 18 | import { Subject } from 'rxjs'; 19 | 20 | let treeService; 21 | let draggableService; 22 | 23 | describe('TreeService', () => { 24 | beforeEach(() => { 25 | TestBed.configureTestingModule({ 26 | providers: [TreeService, NodeDraggableService] 27 | }); 28 | 29 | treeService = TestBed.inject(TreeService); 30 | draggableService = TestBed.inject(NodeDraggableService); 31 | }); 32 | 33 | it('should be created by angular', () => { 34 | expect(treeService).not.toBeNull(); 35 | expect(treeService.nodeMoved$ instanceof Subject).toBe(true); 36 | expect(treeService.nodeRemoved$ instanceof Subject).toBe(true); 37 | expect(treeService.nodeRenamed$ instanceof Subject).toBe(true); 38 | expect(treeService.nodeCreated$ instanceof Subject).toBe(true); 39 | expect(treeService.nodeSelected$ instanceof Subject).toBe(true); 40 | expect(treeService.nodeExpanded$ instanceof Subject).toBe(true); 41 | expect(treeService.nodeCollapsed$ instanceof Subject).toBe(true); 42 | }); 43 | 44 | it('fires node removed events', () => { 45 | spyOn(treeService.nodeRemoved$, 'next'); 46 | 47 | const tree = new Tree({ value: 'Master' }); 48 | treeService.fireNodeRemoved(tree); 49 | 50 | expect(treeService.nodeRemoved$.next).toHaveBeenCalledTimes(1); 51 | expect(treeService.nodeRemoved$.next).toHaveBeenCalledWith(new NodeRemovedEvent(tree, -1)); 52 | }); 53 | 54 | it('fires node removed events witch corretly identified postion removed node used to have in its parent', () => { 55 | spyOn(treeService.nodeRemoved$, 'next'); 56 | 57 | const child1 = { value: 'Servant#1' }; 58 | const child2 = { value: 'Servant#2' }; 59 | const tree = new Tree({ value: 'Master', children: [child1, child2] }); 60 | treeService.fireNodeRemoved(tree.children[1]); 61 | 62 | expect(treeService.nodeRemoved$.next).toHaveBeenCalledTimes(1); 63 | expect(treeService.nodeRemoved$.next).toHaveBeenCalledWith(new NodeRemovedEvent(tree.children[1], 1)); 64 | }); 65 | 66 | it('fires node moved events', () => { 67 | spyOn(treeService.nodeMoved$, 'next'); 68 | 69 | const parent = new Tree({ value: 'Master Pa' }); 70 | const tree = new Tree({ value: 'Master' }, parent); 71 | 72 | treeService.fireNodeMoved(tree, parent); 73 | 74 | expect(treeService.nodeMoved$.next).toHaveBeenCalledTimes(1); 75 | expect(treeService.nodeMoved$.next).toHaveBeenCalledWith(new NodeMovedEvent(tree, parent)); 76 | }); 77 | 78 | it('fires node created events', () => { 79 | spyOn(treeService.nodeCreated$, 'next'); 80 | 81 | const tree = new Tree({ value: 'Master' }); 82 | 83 | treeService.fireNodeCreated(tree); 84 | 85 | expect(treeService.nodeCreated$.next).toHaveBeenCalledTimes(1); 86 | expect(treeService.nodeCreated$.next).toHaveBeenCalledWith(new NodeCreatedEvent(tree)); 87 | }); 88 | 89 | it('fires node selected events', () => { 90 | spyOn(treeService.nodeSelected$, 'next'); 91 | 92 | const tree = new Tree({ value: 'Master' }); 93 | 94 | treeService.fireNodeSelected(tree); 95 | 96 | expect(treeService.nodeSelected$.next).toHaveBeenCalledTimes(1); 97 | expect(treeService.nodeSelected$.next).toHaveBeenCalledWith(new NodeSelectedEvent(tree)); 98 | }); 99 | 100 | it('fires node renamed events', () => { 101 | spyOn(treeService.nodeRenamed$, 'next'); 102 | 103 | const tree = new Tree({ value: 'Master' }); 104 | 105 | treeService.fireNodeRenamed('Bla', tree); 106 | 107 | expect(treeService.nodeRenamed$.next).toHaveBeenCalledTimes(1); 108 | expect(treeService.nodeRenamed$.next).toHaveBeenCalledWith(new NodeRenamedEvent(tree, 'Bla', tree.value)); 109 | }); 110 | 111 | it('fires node expanded events', () => { 112 | spyOn(treeService.nodeExpanded$, 'next'); 113 | 114 | const tree = new Tree({ value: 'Master' }); 115 | 116 | treeService.fireNodeExpanded(tree); 117 | 118 | expect(treeService.nodeExpanded$.next).toHaveBeenCalledTimes(1); 119 | expect(treeService.nodeExpanded$.next).toHaveBeenCalledWith(new NodeExpandedEvent(tree)); 120 | }); 121 | 122 | it('fires node collapsed events', () => { 123 | spyOn(treeService.nodeCollapsed$, 'next'); 124 | 125 | const tree = new Tree({ value: 'Master' }); 126 | 127 | treeService.fireNodeCollapsed(tree); 128 | 129 | expect(treeService.nodeCollapsed$.next).toHaveBeenCalledTimes(1); 130 | expect(treeService.nodeCollapsed$.next).toHaveBeenCalledWith(new NodeCollapsedEvent(tree)); 131 | }); 132 | 133 | it('fires events on which other tree should remove selection', done => { 134 | const selectedTree = new Tree({ value: 'Master' }); 135 | 136 | const tree = new Tree({ value: 'Master' }); 137 | treeService.unselectStream(tree).subscribe((e: NodeSelectedEvent) => { 138 | expect(e.node).toBe(selectedTree); 139 | done(); 140 | }); 141 | 142 | treeService.fireNodeSelected(selectedTree); 143 | }); 144 | 145 | it('removes node from parent when when appropriate event fires', done => { 146 | const masterTree = new Tree({ 147 | value: 'Master', 148 | children: [{ value: 'Servant#1' }, { value: 'Servant#2' }] 149 | }); 150 | 151 | const servantNumber1Tree = masterTree.children[0]; 152 | const servantNumber2Tree = masterTree.children[1]; 153 | 154 | treeService.nodeRemoved$.subscribe((e: NodeRemovedEvent) => { 155 | expect(e.node).toBe(servantNumber1Tree); 156 | expect(masterTree.children.length).toEqual(1); 157 | expect(masterTree.children[0]).toBe(servantNumber2Tree); 158 | done(); 159 | }); 160 | 161 | treeService.fireNodeRemoved(servantNumber1Tree); 162 | }); 163 | 164 | it('should produce drag event for the same element and not on captured node children', done => { 165 | const masterTree = new Tree({ 166 | value: 'Master', 167 | children: [{ value: 'Servant#1' }, { value: 'Servant#2' }] 168 | }); 169 | 170 | const tree = new Tree({ value: 'tree' }); 171 | 172 | const elementRef = new ElementRef(null); 173 | 174 | treeService.draggedStream(tree, elementRef).subscribe((e: NodeDraggableEvent) => { 175 | expect(e.captured.tree).toBe(masterTree); 176 | expect(e.captured.element).toBe(elementRef); 177 | done(); 178 | }); 179 | 180 | draggableService.fireNodeDragged(new CapturedNode(elementRef, masterTree), elementRef); 181 | }); 182 | 183 | it('does not fire "expanded", "collapsed" events for a leaf node', () => { 184 | const masterTree = new Tree({ 185 | value: 'Master' 186 | }); 187 | 188 | spyOn(treeService.nodeExpanded$, 'next'); 189 | spyOn(treeService.nodeCollapsed$, 'next'); 190 | 191 | treeService.fireNodeSwitchFoldingType(masterTree); 192 | 193 | expect(treeService.nodeExpanded$.next).not.toHaveBeenCalled(); 194 | expect(treeService.nodeCollapsed$.next).not.toHaveBeenCalled(); 195 | }); 196 | 197 | it('does not fire "expanded", "collapsed" events for a empty node', () => { 198 | const masterTree = new Tree({ 199 | value: 'Master', 200 | children: [] 201 | }); 202 | 203 | spyOn(treeService.nodeExpanded$, 'next'); 204 | spyOn(treeService.nodeCollapsed$, 'next'); 205 | 206 | treeService.fireNodeSwitchFoldingType(masterTree); 207 | 208 | expect(treeService.nodeExpanded$.next).not.toHaveBeenCalled(); 209 | expect(treeService.nodeCollapsed$.next).not.toHaveBeenCalled(); 210 | }); 211 | 212 | it('fires "expanded" event for expanded tree', () => { 213 | const masterTree = new Tree({ 214 | value: 'Master', 215 | children: [{ value: 'Servant#1' }, { value: 'Servant#2' }] 216 | }); 217 | 218 | spyOn(treeService.nodeExpanded$, 'next'); 219 | spyOn(treeService.nodeCollapsed$, 'next'); 220 | 221 | treeService.fireNodeSwitchFoldingType(masterTree); 222 | 223 | expect(treeService.nodeExpanded$.next).toHaveBeenCalled(); 224 | expect(treeService.nodeCollapsed$.next).not.toHaveBeenCalled(); 225 | }); 226 | 227 | it('fires "collapsed" event for not expanded tree', () => { 228 | const masterTree = new Tree({ 229 | value: 'Master', 230 | children: [{ value: 'Servant#1' }, { value: 'Servant#2' }] 231 | }); 232 | 233 | masterTree.switchFoldingType(); 234 | 235 | spyOn(treeService.nodeExpanded$, 'next'); 236 | spyOn(treeService.nodeCollapsed$, 'next'); 237 | 238 | treeService.fireNodeSwitchFoldingType(masterTree); 239 | 240 | expect(treeService.nodeCollapsed$.next).toHaveBeenCalled(); 241 | expect(treeService.nodeExpanded$.next).not.toHaveBeenCalled(); 242 | }); 243 | 244 | it('fires "loadNextLevel" event when expanding node with emitLoadNextLevel property set to true', () => { 245 | const masterTree = new Tree({ 246 | value: 'Master', 247 | emitLoadNextLevel: true 248 | }); 249 | 250 | masterTree.switchFoldingType(); 251 | 252 | spyOn(treeService.loadNextLevel$, 'next'); 253 | 254 | treeService.fireNodeSwitchFoldingType(masterTree); 255 | 256 | expect(treeService.loadNextLevel$.next).toHaveBeenCalled(); 257 | }); 258 | 259 | it('fires "loadNextLevel" only once', () => { 260 | const masterTree = new Tree({ 261 | value: 'Master', 262 | emitLoadNextLevel: true 263 | }); 264 | 265 | masterTree.switchFoldingType(); 266 | masterTree.switchFoldingType(); 267 | masterTree.switchFoldingType(); 268 | 269 | spyOn(treeService.loadNextLevel$, 'next'); 270 | 271 | treeService.fireNodeSwitchFoldingType(masterTree); 272 | 273 | expect(treeService.loadNextLevel$.next).toHaveBeenCalledTimes(1); 274 | }); 275 | 276 | it('fires "loadNextLevel" if children are provided as empty array', () => { 277 | const masterTree = new Tree({ 278 | value: 'Master', 279 | emitLoadNextLevel: true, 280 | children: [] 281 | }); 282 | 283 | masterTree.switchFoldingType(); 284 | 285 | spyOn(treeService.loadNextLevel$, 'next'); 286 | 287 | treeService.fireNodeSwitchFoldingType(masterTree); 288 | 289 | expect(treeService.loadNextLevel$.next).toHaveBeenCalled(); 290 | }); 291 | 292 | it('not fires "loadNextLevel" if "loadChildren" function is provided', () => { 293 | const masterTree = new Tree({ 294 | value: 'Master', 295 | emitLoadNextLevel: true, 296 | loadChildren: callback => { 297 | setTimeout(() => { 298 | callback([{ value: '1' }, { value: '2' }, { value: '3' }]); 299 | }); 300 | } 301 | }); 302 | 303 | masterTree.switchFoldingType(); 304 | 305 | spyOn(treeService.loadNextLevel$, 'next'); 306 | 307 | treeService.fireNodeSwitchFoldingType(masterTree); 308 | 309 | expect(treeService.loadNextLevel$.next).not.toHaveBeenCalled(); 310 | }); 311 | 312 | it('not fires "loadNextLevel" if children are provided', () => { 313 | const masterTree = new Tree({ 314 | value: 'Master', 315 | emitLoadNextLevel: true, 316 | children: [{ value: '1' }, { value: '2' }, { value: '3' }] 317 | }); 318 | 319 | masterTree.switchFoldingType(); 320 | 321 | spyOn(treeService.loadNextLevel$, 'next'); 322 | 323 | treeService.fireNodeSwitchFoldingType(masterTree); 324 | 325 | expect(treeService.loadNextLevel$.next).not.toHaveBeenCalled(); 326 | }); 327 | 328 | it('not fires "loadNextLevel" event if "emitLoadNextLevel" does not exists', () => { 329 | const masterTree = new Tree({ 330 | value: 'Master' 331 | }); 332 | 333 | masterTree.switchFoldingType(); 334 | 335 | spyOn(treeService.loadNextLevel$, 'next'); 336 | 337 | treeService.fireNodeSwitchFoldingType(masterTree); 338 | 339 | expect(treeService.loadNextLevel$.next).not.toHaveBeenCalled(); 340 | }); 341 | 342 | it('not fires "loadNextLevel" event if "emitLoadNextLevel" is false', () => { 343 | const masterTree = new Tree({ 344 | value: 'Master', 345 | emitLoadNextLevel: false 346 | }); 347 | 348 | masterTree.switchFoldingType(); 349 | 350 | spyOn(treeService.loadNextLevel$, 'next'); 351 | 352 | treeService.fireNodeSwitchFoldingType(masterTree); 353 | 354 | expect(treeService.loadNextLevel$.next).not.toHaveBeenCalled(); 355 | }); 356 | 357 | it('not fires "loadNextLevel" event if "emitLoadNextLevel" is false', () => { 358 | const masterTree = new Tree({ 359 | value: 'Master' 360 | }); 361 | 362 | spyOn(treeService.menuItemSelected$, 'next'); 363 | 364 | treeService.fireMenuItemSelected(masterTree, 'CustomMenu'); 365 | 366 | expect(treeService.menuItemSelected$.next).toHaveBeenCalledWith( 367 | new MenuItemSelectedEvent(masterTree, 'CustomMenu') 368 | ); 369 | }); 370 | 371 | it('return null if there is not controller for the given id', () => { 372 | const controller = treeService.getController('#2'); 373 | 374 | expect(controller).toBeNull(); 375 | }); 376 | }); 377 | -------------------------------------------------------------------------------- /src/tree-internal.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AfterViewInit, 3 | Component, 4 | ElementRef, 5 | Input, 6 | OnChanges, 7 | OnDestroy, 8 | OnInit, 9 | SimpleChanges, 10 | TemplateRef, 11 | ViewChild 12 | } from '@angular/core'; 13 | 14 | import * as TreeTypes from './tree.types'; 15 | import { Ng2TreeSettings } from './tree.types'; 16 | import { Tree } from './tree'; 17 | import { TreeController } from './tree-controller'; 18 | import { NodeMenuService } from './menu/node-menu.service'; 19 | import { NodeMenuItemAction, NodeMenuItemSelectedEvent } from './menu/menu.events'; 20 | import { NodeEditableEvent, NodeEditableEventAction } from './editable/editable.events'; 21 | import { NodeCheckedEvent, NodeEvent } from './tree.events'; 22 | import { TreeService } from './tree.service'; 23 | import * as EventUtils from './utils/event.utils'; 24 | import { NodeDraggableEvent } from './draggable/draggable.events'; 25 | import { get, isNil } from './utils/fn.utils'; 26 | import { Subscription } from 'rxjs'; 27 | import { merge, of } from 'rxjs'; 28 | import { filter } from 'rxjs/operators'; 29 | 30 | @Component({ 31 | selector: 'tree-internal', 32 | template: ` 33 |
    34 |
  • 35 |
    41 | 42 |
    43 | 44 |
    45 | 46 |
    47 | 48 |
    52 |
    53 | 54 | 55 | 56 |
    57 | 58 | 62 | 63 |
    64 |
    65 | 67 | 68 |
    69 | 70 | 72 | 73 | 74 | 77 | 78 | 79 |
    80 | 81 |
    82 | 83 | 84 | 85 |
  • 86 |
87 | ` 88 | }) 89 | export class TreeInternalComponent implements OnInit, OnChanges, OnDestroy, AfterViewInit { 90 | @Input() public tree: Tree; 91 | 92 | @Input() public settings: TreeTypes.Ng2TreeSettings; 93 | 94 | @Input() public template: TemplateRef; 95 | 96 | public isSelected = false; 97 | public isRightMenuVisible = false; 98 | public isLeftMenuVisible = false; 99 | public isReadOnly = false; 100 | public controller: TreeController; 101 | 102 | @ViewChild('checkbox', { static: false }) 103 | public checkboxElementRef: ElementRef; 104 | 105 | private subscriptions: Subscription[] = []; 106 | 107 | public constructor( 108 | private nodeMenuService: NodeMenuService, 109 | public treeService: TreeService, 110 | public nodeElementRef: ElementRef 111 | ) {} 112 | 113 | public ngAfterViewInit(): void { 114 | if (this.tree.checked && !(this.tree as any).firstCheckedFired) { 115 | (this.tree as any).firstCheckedFired = true; 116 | this.treeService.fireNodeChecked(this.tree); 117 | } 118 | } 119 | 120 | public ngOnInit(): void { 121 | const nodeId = get(this.tree, 'node.id', ''); 122 | if (nodeId) { 123 | this.controller = new TreeController(this); 124 | this.treeService.setController(nodeId, this.controller); 125 | } 126 | 127 | this.settings = this.settings || new Ng2TreeSettings(); 128 | this.isReadOnly = !get(this.settings, 'enableCheckboxes', true); 129 | 130 | if (this.tree.isRoot() && this.settings.rootIsVisible === false) { 131 | this.tree.disableCollapseOnInit(); 132 | } 133 | 134 | this.subscriptions.push( 135 | this.nodeMenuService.hideMenuStream(this.nodeElementRef).subscribe(() => { 136 | this.isRightMenuVisible = false; 137 | this.isLeftMenuVisible = false; 138 | }) 139 | ); 140 | 141 | this.subscriptions.push(this.treeService.unselectStream(this.tree).subscribe(() => (this.isSelected = false))); 142 | 143 | this.subscriptions.push( 144 | this.treeService.draggedStream(this.tree, this.nodeElementRef).subscribe((e: NodeDraggableEvent) => { 145 | if (this.tree.hasSibling(e.captured.tree)) { 146 | this.swapWithSibling(e.captured.tree, this.tree); 147 | } else if (this.tree.isBranch()) { 148 | this.moveNodeToThisTreeAndRemoveFromPreviousOne(e, this.tree); 149 | } else { 150 | this.moveNodeToParentTreeAndRemoveFromPreviousOne(e, this.tree); 151 | } 152 | }) 153 | ); 154 | 155 | this.subscriptions.push( 156 | merge(this.treeService.nodeChecked$, this.treeService.nodeUnchecked$) 157 | .pipe(filter((e: NodeCheckedEvent) => this.eventContainsId(e) && this.tree.hasChild(e.node))) 158 | .subscribe((e: NodeCheckedEvent) => this.updateCheckboxState()) 159 | ); 160 | } 161 | 162 | public ngOnChanges(changes: SimpleChanges): void { 163 | this.controller = new TreeController(this); 164 | } 165 | 166 | public ngOnDestroy(): void { 167 | if (get(this.tree, 'node.id', '')) { 168 | this.treeService.deleteController(this.tree.node.id); 169 | } 170 | 171 | this.subscriptions.forEach(sub => sub && sub.unsubscribe()); 172 | } 173 | 174 | private swapWithSibling(sibling: Tree, tree: Tree): void { 175 | tree.swapWithSibling(sibling); 176 | this.treeService.fireNodeMoved(sibling, sibling.parent); 177 | } 178 | 179 | private moveNodeToThisTreeAndRemoveFromPreviousOne(e: NodeDraggableEvent, tree: Tree): void { 180 | this.treeService.fireNodeRemoved(e.captured.tree); 181 | const addedChild = tree.addChild(e.captured.tree); 182 | this.treeService.fireNodeMoved(addedChild, e.captured.tree.parent); 183 | } 184 | 185 | private moveNodeToParentTreeAndRemoveFromPreviousOne(e: NodeDraggableEvent, tree: Tree): void { 186 | this.treeService.fireNodeRemoved(e.captured.tree); 187 | const addedSibling = tree.addSibling(e.captured.tree, tree.positionInParent); 188 | this.treeService.fireNodeMoved(addedSibling, e.captured.tree.parent); 189 | } 190 | 191 | public onNodeSelected(e: { button: number }): void { 192 | if (!this.tree.selectionAllowed) { 193 | return; 194 | } 195 | 196 | if (EventUtils.isLeftButtonClicked(e as MouseEvent)) { 197 | this.isSelected = true; 198 | this.treeService.fireNodeSelected(this.tree); 199 | } 200 | } 201 | 202 | public onNodeUnselected(e: { button: number }): void { 203 | if (!this.tree.selectionAllowed) { 204 | return; 205 | } 206 | 207 | if (EventUtils.isLeftButtonClicked(e as MouseEvent)) { 208 | this.isSelected = false; 209 | this.treeService.fireNodeUnselected(this.tree); 210 | } 211 | } 212 | 213 | public showRightMenu(e: MouseEvent): void { 214 | if (!this.tree.hasRightMenu()) { 215 | return; 216 | } 217 | 218 | if (EventUtils.isRightButtonClicked(e)) { 219 | this.isRightMenuVisible = !this.isRightMenuVisible; 220 | this.nodeMenuService.hideMenuForAllNodesExcept(this.nodeElementRef); 221 | } 222 | e.preventDefault(); 223 | } 224 | 225 | public showLeftMenu(e: MouseEvent): void { 226 | if (!this.tree.hasLeftMenu()) { 227 | return; 228 | } 229 | 230 | if (EventUtils.isLeftButtonClicked(e)) { 231 | this.isLeftMenuVisible = !this.isLeftMenuVisible; 232 | this.nodeMenuService.hideMenuForAllNodesExcept(this.nodeElementRef); 233 | if (this.isLeftMenuVisible) { 234 | e.preventDefault(); 235 | } 236 | } 237 | } 238 | 239 | public onMenuItemSelected(e: NodeMenuItemSelectedEvent): void { 240 | switch (e.nodeMenuItemAction) { 241 | case NodeMenuItemAction.NewTag: 242 | this.onNewSelected(e); 243 | break; 244 | case NodeMenuItemAction.NewFolder: 245 | this.onNewSelected(e); 246 | break; 247 | case NodeMenuItemAction.Rename: 248 | this.onRenameSelected(); 249 | break; 250 | case NodeMenuItemAction.Remove: 251 | this.onRemoveSelected(); 252 | break; 253 | case NodeMenuItemAction.Custom: 254 | this.onCustomSelected(); 255 | this.treeService.fireMenuItemSelected(this.tree, e.nodeMenuItemSelected); 256 | break; 257 | default: 258 | throw new Error(`Chosen menu item doesn't exist`); 259 | } 260 | } 261 | 262 | private onNewSelected(e: NodeMenuItemSelectedEvent): void { 263 | this.tree.createNode(e.nodeMenuItemAction === NodeMenuItemAction.NewFolder); 264 | this.isRightMenuVisible = false; 265 | this.isLeftMenuVisible = false; 266 | } 267 | 268 | private onRenameSelected(): void { 269 | this.tree.markAsBeingRenamed(); 270 | this.isRightMenuVisible = false; 271 | this.isLeftMenuVisible = false; 272 | } 273 | 274 | private onRemoveSelected(): void { 275 | this.treeService.deleteController(get(this.tree, 'node.id', '')); 276 | this.treeService.fireNodeRemoved(this.tree); 277 | } 278 | 279 | private onCustomSelected(): void { 280 | this.isRightMenuVisible = false; 281 | this.isLeftMenuVisible = false; 282 | } 283 | 284 | public onSwitchFoldingType(): void { 285 | this.tree.switchFoldingType(); 286 | this.treeService.fireNodeSwitchFoldingType(this.tree); 287 | } 288 | 289 | public applyNewValue(e: NodeEditableEvent): void { 290 | if ((e.action === NodeEditableEventAction.Cancel || this.tree.isNew()) && Tree.isValueEmpty(e.value)) { 291 | return this.treeService.fireNodeRemoved(this.tree); 292 | } 293 | 294 | if (this.tree.isNew()) { 295 | this.tree.value = e.value; 296 | this.treeService.fireNodeCreated(this.tree); 297 | } 298 | 299 | if (this.tree.isBeingRenamed()) { 300 | const oldValue = this.tree.value; 301 | this.tree.value = e.value; 302 | this.treeService.fireNodeRenamed(oldValue, this.tree); 303 | } 304 | 305 | this.tree.markAsModified(); 306 | } 307 | 308 | public shouldShowInputForTreeValue(): boolean { 309 | return this.tree.isNew() || this.tree.isBeingRenamed(); 310 | } 311 | 312 | public isRootHidden(): boolean { 313 | return this.tree.isRoot() && !this.settings.rootIsVisible; 314 | } 315 | 316 | public hasCustomMenu(): boolean { 317 | return this.tree.hasCustomMenu(); 318 | } 319 | 320 | public switchNodeCheckStatus() { 321 | if (!this.tree.checked) { 322 | this.onNodeChecked(); 323 | } else { 324 | this.onNodeUnchecked(); 325 | } 326 | } 327 | 328 | public onNodeChecked(): void { 329 | if (!this.checkboxElementRef) { 330 | return; 331 | } 332 | 333 | this.checkboxElementRef.nativeElement.indeterminate = false; 334 | this.treeService.fireNodeChecked(this.tree); 335 | this.executeOnChildController(controller => controller.check()); 336 | this.tree.checked = true; 337 | } 338 | 339 | public onNodeUnchecked(): void { 340 | if (!this.checkboxElementRef) { 341 | return; 342 | } 343 | 344 | this.checkboxElementRef.nativeElement.indeterminate = false; 345 | this.treeService.fireNodeUnchecked(this.tree); 346 | this.executeOnChildController(controller => controller.uncheck()); 347 | this.tree.checked = false; 348 | } 349 | 350 | private executeOnChildController(executor: (controller: TreeController) => void) { 351 | if (this.tree.hasLoadedChildern()) { 352 | this.tree.children.forEach((child: Tree) => { 353 | const controller = this.treeService.getController(child.id); 354 | if (!isNil(controller)) { 355 | executor(controller); 356 | } 357 | }); 358 | } 359 | } 360 | 361 | updateCheckboxState(): void { 362 | // Calling setTimeout so the value of isChecked will be updated and after that I'll check the children status. 363 | setTimeout(() => { 364 | const checkedChildrenAmount = this.tree.checkedChildrenAmount(); 365 | if (checkedChildrenAmount === 0) { 366 | this.checkboxElementRef.nativeElement.indeterminate = false; 367 | this.tree.checked = false; 368 | this.treeService.fireNodeUnchecked(this.tree); 369 | } else if (checkedChildrenAmount === this.tree.loadedChildrenAmount()) { 370 | this.checkboxElementRef.nativeElement.indeterminate = false; 371 | this.tree.checked = true; 372 | this.treeService.fireNodeChecked(this.tree); 373 | } else { 374 | this.tree.checked = false; 375 | this.checkboxElementRef.nativeElement.indeterminate = true; 376 | this.treeService.fireNodeIndetermined(this.tree); 377 | } 378 | }); 379 | } 380 | 381 | private eventContainsId(event: NodeEvent): boolean { 382 | if (!event.node.id) { 383 | console.warn( 384 | '"Node with checkbox" feature requires a unique id assigned to every node, please consider to add it.' 385 | ); 386 | return false; 387 | } 388 | return true; 389 | } 390 | } 391 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # [3.0.0](https://github.com/valor-software/ng2-tree/compare/v2.0.0...v3.0.0) (2024-01-30) 4 | 5 | 6 | 7 | # [2.0.0](https://github.com/valor-software/ng2-tree/compare/v2.0.0-rc.11...v2.0.0) (2024-01-25) 8 | 9 | 10 | 11 | # [3.0.0](https://github.com/valor-software/ng2-tree/compare/v2.0.0-rc.11...v3.0.0) (2024-01-16) 12 | 13 | 14 | 15 | # [2.0.0-rc.11](https://github.com/valor-software/ng2-tree/compare/v2.0.0-rc.10...v2.0.0-rc.11) (2018-04-03) 16 | 17 | ### Bug Fixes 18 | 19 | * **tree-controller:** handle change dection in expandToParent properly ([#248](https://github.com/valor-software/ng2-tree/issues/248)) ([d6414d5](https://github.com/valor-software/ng2-tree/commit/d6414d5)) 20 | 21 | 22 | 23 | # [2.0.0-rc.10](https://github.com/valor-software/ng2-tree/compare/v2.0.0-rc.9...v2.0.0-rc.10) (2018-04-02) 24 | 25 | ### Features 26 | 27 | * **tree:** expand node up to parent ([#245](https://github.com/valor-software/ng2-tree/issues/245)) ([3493ff1](https://github.com/valor-software/ng2-tree/commit/3493ff1)) 28 | 29 | 30 | 31 | # [2.0.0-rc.9](https://github.com/valor-software/ng2-tree/compare/v2.0.0-rc.6...v2.0.0-rc.9) (2018-02-22) 32 | 33 | ### Bug Fixes 34 | 35 | * **custom-menu:** close menu when custom menu item gets clicked ([#218](https://github.com/valor-software/ng2-tree/issues/218)) ([ae75381](https://github.com/valor-software/ng2-tree/commit/ae75381)) 36 | * **types:** export missed type definitions ([8335cf9](https://github.com/valor-software/ng2-tree/commit/8335cf9)) 37 | 38 | ### Features 39 | 40 | * **selection:** add ability to allow and forbid a node selection (closes [#220](https://github.com/valor-software/ng2-tree/issues/220)) ([#221](https://github.com/valor-software/ng2-tree/issues/221)) ([12852c9](https://github.com/valor-software/ng2-tree/commit/12852c9)) 41 | * **tree:** unselect tree via controller ([6c43391](https://github.com/valor-software/ng2-tree/commit/6c43391)) 42 | 43 | 44 | 45 | # [2.0.0-rc.8](https://github.com/valor-software/ng2-tree/compare/v2.0.0-rc.7...v2.0.0-rc.8) (2018-02-20) 46 | 47 | ### Features 48 | 49 | * **tree:** unselect tree via controller ([6c43391](https://github.com/valor-software/ng2-tree/commit/6c43391)) 50 | 51 | 52 | 53 | # [2.0.0-rc.7](https://github.com/valor-software/ng2-tree/compare/v2.0.0-rc.5...v2.0.0-rc.7) (2018-02-18) 54 | 55 | ### Bug Fixes 56 | 57 | * **checkboxes:** get rid of performance issue with cyclic event firing; fix indetermined state ([55b975e](https://github.com/valor-software/ng2-tree/commit/55b975e)) 58 | * **custom-menu:** close menu when custom menu item gets clicked ([#218](https://github.com/valor-software/ng2-tree/issues/218)) ([ae75381](https://github.com/valor-software/ng2-tree/commit/ae75381)) 59 | 60 | ### Features 61 | 62 | * **selection:** add ability to allow and forbid a node selection (closes [#220](https://github.com/valor-software/ng2-tree/issues/220)) ([#221](https://github.com/valor-software/ng2-tree/issues/221)) ([12852c9](https://github.com/valor-software/ng2-tree/commit/12852c9)) 63 | 64 | 65 | 66 | # [2.0.0-rc.6](https://github.com/valor-software/ng2-tree/compare/v2.0.0-rc.5...v2.0.0-rc.6) (2018-02-12) 67 | 68 | ### Bug Fixes 69 | 70 | * **checkboxes:** get rid of performance issue with cyclic event firing; fix indetermined state ([55b975e](https://github.com/valor-software/ng2-tree/commit/55b975e)) 71 | 72 | 73 | 74 | # [2.0.0-rc.5](https://github.com/valor-software/ng2-tree/compare/v2.0.0-rc.4...v2.0.0-rc.5) (2018-02-11) 75 | 76 | ### Bug Fixes 77 | 78 | * **dragndrop:** check whether stopPropagation is available on event (closes [#115](https://github.com/valor-software/ng2-tree/issues/115)) ([93b5f9c](https://github.com/valor-software/ng2-tree/commit/93b5f9c)) 79 | * **tree:** add method for creating children asynchronously (closes [#204](https://github.com/valor-software/ng2-tree/issues/204)) ([72cfcb6](https://github.com/valor-software/ng2-tree/commit/72cfcb6)) 80 | * **tree:** does not collapse root node when it is hidden (closes [#209](https://github.com/valor-software/ng2-tree/issues/209)) ([9aaa065](https://github.com/valor-software/ng2-tree/commit/9aaa065)) 81 | 82 | ### Features 83 | 84 | * **tree:** add checkboxes support (closes [#181](https://github.com/valor-software/ng2-tree/issues/181), closes [#79](https://github.com/valor-software/ng2-tree/issues/79)) ([5069953](https://github.com/valor-software/ng2-tree/commit/5069953)) 85 | 86 | 87 | 88 | # [2.0.0-rc.4](https://github.com/valor-software/ng2-tree/compare/v2.0.0-rc.3...v2.0.0-rc.4) (2017-11-25) 89 | 90 | ### Bug Fixes 91 | 92 | * export noop function from rxjs-imports in order to force ngc to generate a proper metadata.json file. Closes [#175](https://github.com/valor-software/ng2-tree/issues/175) ([#177](https://github.com/valor-software/ng2-tree/issues/177)) ([c0aab34](https://github.com/valor-software/ng2-tree/commit/c0aab34)) 93 | 94 | 95 | 96 | # [2.0.0-rc.3](https://github.com/valor-software/ng2-tree/compare/v2.0.0-rc.2...v2.0.0-rc.3) (2017-11-24) 97 | 98 | ### Bug Fixes 99 | 100 | * import rxjs in a proper way. Closes [#172](https://github.com/valor-software/ng2-tree/issues/172) ([#173](https://github.com/valor-software/ng2-tree/issues/173)) ([5360828](https://github.com/valor-software/ng2-tree/commit/5360828)) 101 | 102 | 103 | 104 | # [2.0.0-rc.2](https://github.com/valor-software/ng2-tree/compare/v2.0.0-rc.1...v2.0.0-rc.2) (2017-11-19) 105 | 106 | ### Bug Fixes 107 | 108 | * **TreeController:** populate new nodes with ids unless they have them. Closes [#145](https://github.com/valor-software/ng2-tree/issues/145) ([3d0826a](https://github.com/valor-software/ng2-tree/commit/3d0826a)) 109 | 110 | ### Features 111 | 112 | * **node-menu:** bring custom menu items to the node menu. Closes [#48](https://github.com/valor-software/ng2-tree/issues/48), closes [#53](https://github.com/valor-software/ng2-tree/issues/53), closes [#25](https://github.com/valor-software/ng2-tree/issues/25), closes [#161](https://github.com/valor-software/ng2-tree/issues/161) ([#170](https://github.com/valor-software/ng2-tree/issues/170)) ([d776886](https://github.com/valor-software/ng2-tree/commit/d776886)) 113 | * **tree:** make it possible to collapse a tree on a first load. Closes [#102](https://github.com/valor-software/ng2-tree/issues/102) ([be42398](https://github.com/valor-software/ng2-tree/commit/be42398)) 114 | * **Tree:** adds ability to acquire tree underlying model ([#168](https://github.com/valor-software/ng2-tree/issues/168)). Closes [#147](https://github.com/valor-software/ng2-tree/issues/147) ([68c4dcf](https://github.com/valor-software/ng2-tree/commit/68c4dcf)) 115 | 116 | 117 | 118 | # [2.0.0-rc.1](https://github.com/valor-software/ng2-tree/compare/v2.0.0-alpha.10...v2.0.0-rc.1) (2017-11-05) 119 | 120 | ### Bug Fixes 121 | 122 | * **tree:** should not load children when they were already loaded (closes [#149](https://github.com/valor-software/ng2-tree/issues/149)) ([aa44992](https://github.com/valor-software/ng2-tree/commit/aa44992)) 123 | * **TreeController:** fix inconsistent root node ([9626db7](https://github.com/valor-software/ng2-tree/commit/9626db7)) 124 | 125 | ### Features 126 | 127 | * add ability to pass a template to the tree for nodes rendering ([a83c1e4](https://github.com/valor-software/ng2-tree/commit/a83c1e4)) 128 | * support ngrx (or loading children using any other redux-like library via special LoadNextLevel event) ([1e4095d](https://github.com/valor-software/ng2-tree/commit/1e4095d)) 129 | 130 | 131 | 132 | # [2.0.0-alpha.10](https://github.com/valor-software/ng2-tree/compare/v2.0.0-alpha.9...v2.0.0-alpha.10) (2017-08-27) 133 | 134 | ### Bug Fixes 135 | 136 | * remove lodash 'get' ([5bf144d](https://github.com/valor-software/ng2-tree/commit/5bf144d)) 137 | 138 | 139 | 140 | # [2.0.0-alpha.9](https://github.com/valor-software/ng2-tree/compare/v2.0.0-alpha.8...v2.0.0-alpha.9) (2017-08-27) 141 | 142 | ### Bug Fixes 143 | 144 | * handle rxjs subscriptions ([5db73e0](https://github.com/valor-software/ng2-tree/commit/5db73e0)) 145 | * **tree, menu:** return proper value for positionInParent call; change z-index of the menu ([0db681c](https://github.com/valor-software/ng2-tree/commit/0db681c)) 146 | 147 | 148 | 149 | # [2.0.0-alpha.8](https://github.com/valor-software/ng2-tree/compare/v2.0.0-alpha.6...v2.0.0-alpha.8) (2017-07-02) 150 | 151 | ### Bug Fixes 152 | 153 | * fix demo build output dir, add missing dependency for gh-pages, add extrab badge - version ([cef0385](https://github.com/valor-software/ng2-tree/commit/cef0385)) 154 | * replace lodash functions with own ones in order to avoid tree-shaking issues (closes [#108](https://github.com/valor-software/ng2-tree/issues/108)) ([e6eb712](https://github.com/valor-software/ng2-tree/commit/e6eb712)) 155 | * **fn.utils:** cover with tests critical paths (though coverage should be increased definitely for those utils) ([87eaff5](https://github.com/valor-software/ng2-tree/commit/87eaff5)) 156 | * **system.js:** add section for SystemJS configuration, use lodash-es instead of lodash (closes [#104](https://github.com/valor-software/ng2-tree/issues/104), [#103](https://github.com/valor-software/ng2-tree/issues/103), [#58](https://github.com/valor-software/ng2-tree/issues/58)) ([4b36690](https://github.com/valor-software/ng2-tree/commit/4b36690)) 157 | 158 | 159 | 160 | # [2.0.0-alpha.7](https://github.com/valor-software/ng2-tree/compare/v2.0.0-alpha.6...v2.0.0-alpha.7) (2017-06-23) 161 | 162 | ### Bug Fixes 163 | 164 | * fix demo build output dir, add missing dependency for gh-pages, add extrab badge - version ([cef0385](https://github.com/valor-software/ng2-tree/commit/cef0385)) 165 | * **system.js:** add section for SystemJS configuration, use lodash-es instead of lodash (closes [#104](https://github.com/valor-software/ng2-tree/issues/104), [#103](https://github.com/valor-software/ng2-tree/issues/103), [#58](https://github.com/valor-software/ng2-tree/issues/58)) ([4b36690](https://github.com/valor-software/ng2-tree/commit/4b36690)) 166 | 167 | 168 | 169 | # [2.0.0-alpha.6](https://github.com/valor-software/ng2-tree/compare/v2.0.0-alpha.5...v2.0.0-alpha.6) (2017-06-10) 170 | 171 | ### Bug Fixes 172 | 173 | * **async-children:** create observable for aysnc children only once (fixes [#80](https://github.com/valor-software/ng2-tree/issues/80)) ([c74e1b4](https://github.com/valor-software/ng2-tree/commit/c74e1b4)) 174 | * **tree:** option to have an empty folder node (no children) ([ac4f777](https://github.com/valor-software/ng2-tree/commit/ac4f777)), closes [#87](https://github.com/valor-software/ng2-tree/issues/87) 175 | 176 | ### Features 177 | 178 | * add cssClasses setting for folding elements and html templates for node and left menu activation elements ([adc3c78](https://github.com/valor-software/ng2-tree/commit/adc3c78)) 179 | * **html value:** render html properly in a node.value ([baa62f4](https://github.com/valor-software/ng2-tree/commit/baa62f4)) 180 | * **tree:** add left menu and an option to control availability of menus ([1afb6fc](https://github.com/valor-software/ng2-tree/commit/1afb6fc)) 181 | 182 | 183 | 184 | # [2.0.0-alpha.5](https://github.com/valor-software/ng2-tree/compare/v2.0.0-alpha.4...v2.0.0-alpha.5) (2017-03-04) 185 | 186 | ### Bug Fixes 187 | 188 | * **scripts:** do not update web-driver on postinstall ([fadd8de](https://github.com/valor-software/ng2-tree/commit/fadd8de)) 189 | * **scripts:** remove not ready things from scripts ([c74b977](https://github.com/valor-software/ng2-tree/commit/c74b977)) 190 | 191 | 192 | 193 | # 2.0.0-alpha.4 (2017-03-04) 194 | 195 | ### Bug Fixes 196 | 197 | * add demo/\*_/_.css to .gitignore ([2b2e597](https://github.com/valor-software/ng2-tree/commit/2b2e597)) 198 | * apply new node value on blur (fixes [#4](https://github.com/valor-software/ng2-tree/issues/4)) ([378a36f](https://github.com/valor-software/ng2-tree/commit/378a36f)) 199 | * enable drag-n-drop for safari ([27d344a](https://github.com/valor-software/ng2-tree/commit/27d344a)) 200 | * export only public api, ignore .publish ([b7c22a3](https://github.com/valor-software/ng2-tree/commit/b7c22a3)) 201 | * handle coordinates via e.x, e.y or e.clientX, e.clientY - otherwise it causes issues ([096c08b](https://github.com/valor-software/ng2-tree/commit/096c08b)) 202 | * include font-awesome into built module ([48246bc](https://github.com/valor-software/ng2-tree/commit/48246bc)) 203 | * add previously ignored umd-bundler.js ([af0ce27](https://github.com/valor-software/ng2-tree/commit/af0ce27)) 204 | * **build:** copy styles to build directory ([45c62e7](https://github.com/valor-software/ng2-tree/commit/45c62e7)) 205 | * **package.json:** add lodash missing dependency ([bf31a0a](https://github.com/valor-software/ng2-tree/commit/bf31a0a)) 206 | * **tree.component:** make sure tree can be loaded asynchronously ([fc68654](https://github.com/valor-software/ng2-tree/commit/fc68654)) 207 | * **type.utils:** change lodash import from "lodash/index" to "lodash" ([128fd97](https://github.com/valor-software/ng2-tree/commit/128fd97)) 208 | * **webpack.config:** override css loader ([4cc9a99](https://github.com/valor-software/ng2-tree/commit/4cc9a99)) 209 | * replace font-awesome icons with utf-8 symbols as a workaround to problem with fonts bundling ([a93726a](https://github.com/valor-software/ng2-tree/commit/a93726a)) 210 | * update README.md ([f09b711](https://github.com/valor-software/ng2-tree/commit/f09b711)) 211 | * update README.md ([ecff57d](https://github.com/valor-software/ng2-tree/commit/ecff57d)) 212 | 213 | ### Features 214 | 215 | * **drag-n-drop:** add support of nodes' drag-n-drop ([69e57d7](https://github.com/valor-software/ng2-tree/commit/69e57d7)) 216 | * **ng2-tree:** add stylus, webpack-dev-server support ([be3d56e](https://github.com/valor-software/ng2-tree/commit/be3d56e)) 217 | * **ng2-tree:** tree is implemented as an Angular2 component ([f03846a](https://github.com/valor-software/ng2-tree/commit/f03846a)) 218 | * **node editing:** add support of cancel and applying actions for the node value ([aa0e651](https://github.com/valor-software/ng2-tree/commit/aa0e651)) 219 | * **node menu:** add support of node renaming ([15597c1](https://github.com/valor-software/ng2-tree/commit/15597c1)) 220 | * **node menu:** extracted into separate component ([3c2915f](https://github.com/valor-software/ng2-tree/commit/3c2915f)) 221 | * **node menu:** implement 'Add node' menu item ([d171504](https://github.com/valor-software/ng2-tree/commit/d171504)) 222 | * **node removal:** implement remove action in the node menu ([d9dc8be](https://github.com/valor-software/ng2-tree/commit/d9dc8be)) 223 | * **styles:** make it possible to override styles (refs [#16](https://github.com/valor-software/ng2-tree/issues/16)) ([3435441](https://github.com/valor-software/ng2-tree/commit/3435441)) 224 | * **tree:** add ability to hide root node (refs [#25](https://github.com/valor-software/ng2-tree/issues/25)) ([7d64cdf](https://github.com/valor-software/ng2-tree/commit/7d64cdf)) 225 | * **tree:** add support of async children loading on node expand ([bbbb8f7](https://github.com/valor-software/ng2-tree/commit/bbbb8f7)) 226 | * **tree:** make it possible to create static tree (refs [#21](https://github.com/valor-software/ng2-tree/issues/21)) ([d9b3c79](https://github.com/valor-software/ng2-tree/commit/d9b3c79)) 227 | -------------------------------------------------------------------------------- /test/tree-controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; 2 | import { By } from '@angular/platform-browser'; 3 | import { Component, DebugElement, ElementRef, ViewChild } from '@angular/core'; 4 | import { TreeInternalComponent } from '../src/tree-internal.component'; 5 | import { TreeComponent } from '../src/tree.component'; 6 | import { TreeModel } from '../src/tree.types'; 7 | import { TreeService } from '../src/tree.service'; 8 | import { TreeController } from '../src/tree-controller'; 9 | import { NodeMenuService } from '../src/menu/node-menu.service'; 10 | import { NodeMenuComponent } from '../src/menu/node-menu.component'; 11 | import { NodeDraggableService } from '../src/draggable/node-draggable.service'; 12 | import { NodeDraggableDirective } from '../src/draggable/node-draggable.directive'; 13 | import { NodeEditableDirective } from '../src/editable/node-editable.directive'; 14 | import { TreeStatus } from '../src/tree.types'; 15 | import * as EventUtils from '../src/utils/event.utils'; 16 | import { SafeHtmlPipe } from '../src/utils/safe-html.pipe'; 17 | import { Ng2TreeSettings, Tree } from '../index'; 18 | import { isEmpty } from '../src/utils/fn.utils'; 19 | 20 | let fixture: ComponentFixture; 21 | let lordTreeInstance: TreeComponent; 22 | let lordInternalTreeNative: HTMLElement; 23 | let lordInternalTreeInstance: TreeInternalComponent; 24 | let lordInternalTreeDebugElement: DebugElement; 25 | 26 | let nodeMenuService: NodeMenuService; 27 | let nodeDraggableService: NodeDraggableService; 28 | let treeService: TreeService; 29 | 30 | const treeLord: TreeModel = { 31 | value: 'Lord', 32 | id: 1, 33 | children: [ 34 | { 35 | value: 'Disciple#1', 36 | id: 2, 37 | loadChildren(onLoaded) { 38 | onLoaded([{ value: 'Newborn#1' }, { value: 'Newborn#2' }]); 39 | } 40 | }, 41 | { 42 | value: 'Disciple#2', 43 | id: 3, 44 | children: [{ value: 'SubDisciple#1', id: 4 }, { value: 'SubDisciple#2', id: 5 }] 45 | } 46 | ] 47 | }; 48 | 49 | @Component({ 50 | template: ` 51 |
52 | ` 53 | }) 54 | class TestComponent { 55 | public settings = new Ng2TreeSettings(); 56 | public treeLord: TreeModel = treeLord; 57 | 58 | @ViewChild('lordTreeInstance', { static: false }) 59 | public lordTreeComponent; 60 | 61 | public constructor(public treeHolder: ElementRef) { 62 | this.settings.enableCheckboxes = true; 63 | this.settings.showCheckboxes = true; 64 | } 65 | } 66 | 67 | describe('TreeController', () => { 68 | beforeEach(() => { 69 | TestBed.configureTestingModule({ 70 | declarations: [ 71 | TestComponent, 72 | TreeInternalComponent, 73 | TreeComponent, 74 | NodeEditableDirective, 75 | NodeMenuComponent, 76 | NodeDraggableDirective, 77 | SafeHtmlPipe 78 | ], 79 | providers: [NodeMenuService, NodeDraggableService, TreeService, SafeHtmlPipe] 80 | }); 81 | 82 | fixture = TestBed.createComponent(TestComponent); 83 | 84 | lordInternalTreeDebugElement = fixture.debugElement.query(By.directive(TreeInternalComponent)); 85 | lordTreeInstance = fixture.componentInstance.lordTreeComponent; 86 | lordInternalTreeInstance = lordInternalTreeDebugElement.componentInstance; 87 | lordInternalTreeNative = lordInternalTreeDebugElement.nativeElement; 88 | 89 | treeService = lordInternalTreeInstance.treeService; 90 | 91 | nodeMenuService = TestBed.inject(NodeMenuService); 92 | nodeDraggableService = TestBed.inject(NodeDraggableService); 93 | 94 | fixture.detectChanges(); 95 | }); 96 | 97 | it('should have properly set tree controller property', () => { 98 | expect(treeService.getController(lordInternalTreeInstance.tree.id)).toBeDefined(); 99 | }); 100 | 101 | it('can check a node', () => { 102 | const controller = treeService.getController(lordInternalTreeInstance.tree.id); 103 | expect(controller.isChecked()).toBe(false); 104 | 105 | controller.check(); 106 | 107 | fixture.detectChanges(); 108 | 109 | expect(controller.isChecked()).toBe(true); 110 | }); 111 | 112 | it('can uncheck a node', () => { 113 | const controller = treeService.getController(lordInternalTreeInstance.tree.id); 114 | expect(controller.isChecked()).toBe(false); 115 | 116 | controller.check(); 117 | fixture.detectChanges(); 118 | 119 | controller.uncheck(); 120 | fixture.detectChanges(); 121 | 122 | expect(controller.isChecked()).toBe(false); 123 | }); 124 | 125 | it('forbids selection', () => { 126 | const controller = treeService.getController(lordInternalTreeInstance.tree.id); 127 | expect(controller.isSelectionAllowed()).toBe(true); 128 | 129 | controller.forbidSelection(); 130 | 131 | fixture.detectChanges(); 132 | 133 | expect(controller.isSelectionAllowed()).toBe(false); 134 | }); 135 | 136 | it('allows selection', () => { 137 | const controller = treeService.getController(lordInternalTreeInstance.tree.id); 138 | expect(controller.isSelectionAllowed()).toBe(true); 139 | 140 | controller.forbidSelection(); 141 | fixture.detectChanges(); 142 | 143 | expect(controller.isSelectionAllowed()).toBe(false); 144 | 145 | controller.allowSelection(); 146 | fixture.detectChanges(); 147 | 148 | expect(controller.isSelectionAllowed()).toBe(true); 149 | }); 150 | 151 | it('checks all the children down the branch', () => { 152 | const tree = lordInternalTreeInstance.tree; 153 | const controller = treeService.getController(tree.id); 154 | 155 | controller.check(); 156 | fixture.detectChanges(); 157 | 158 | const checkChildChecked = (children: Tree[], checked: boolean) => 159 | isEmpty(children) 160 | ? checked 161 | : children.every(child => child.checked && checkChildChecked(child.children, child.checked)); 162 | 163 | expect(checkChildChecked(tree.children, tree.checked)).toBe(true, 'All the children should be checked'); 164 | }); 165 | 166 | it('unchecks all the children down the branch', () => { 167 | const tree = lordInternalTreeInstance.tree; 168 | const controller = treeService.getController(tree.id); 169 | 170 | controller.check(); 171 | fixture.detectChanges(); 172 | 173 | controller.uncheck(); 174 | fixture.detectChanges(); 175 | 176 | const checkChildChecked = (children: Tree[], checked: boolean) => 177 | isEmpty(children) 178 | ? checked 179 | : children.every(child => child.checked && checkChildChecked(child.children, child.checked)); 180 | 181 | expect(checkChildChecked(tree.children, tree.checked)).toBe(false, 'All the children should be unchecked'); 182 | }); 183 | 184 | it( 185 | 'detects indetermined node', 186 | fakeAsync(() => { 187 | const tree = lordInternalTreeInstance.tree; 188 | const controller = treeService.getController(tree.id); 189 | const childController = treeService.getController(tree.children[0].id); 190 | 191 | childController.check(); 192 | fixture.detectChanges(); 193 | tick(); 194 | 195 | expect(childController.isChecked()).toBe(true, 'Node should be checked'); 196 | expect(controller.isIndetermined()).toBe(true, 'Node should be in indetermined state'); 197 | }) 198 | ); 199 | 200 | it('knows when node is selected', () => { 201 | const event = jasmine.createSpyObj('e', ['preventDefault']); 202 | event.button = EventUtils.MouseButtons.Left; 203 | 204 | const lordController = treeService.getController(lordInternalTreeInstance.tree.id); 205 | expect(lordController.isSelected()).toBe(false); 206 | 207 | clickOn(lordInternalTreeDebugElement, event); 208 | fixture.detectChanges(); 209 | 210 | expect(lordController.isSelected()).toBe(true); 211 | }); 212 | 213 | it('knows how to select a node', () => { 214 | const lordController = treeService.getController(lordInternalTreeInstance.tree.id); 215 | expect(lordController.isSelected()).toBe(false); 216 | 217 | lordController.select(); 218 | 219 | fixture.detectChanges(); 220 | 221 | expect(lordController.isSelected()).toBe(true); 222 | }); 223 | 224 | it('selects a node only if it is not already selected', () => { 225 | spyOn(treeService.nodeSelected$, 'next'); 226 | 227 | const lordController = treeService.getController(lordInternalTreeInstance.tree.id); 228 | 229 | lordController.select(); 230 | lordController.select(); 231 | lordController.select(); 232 | 233 | fixture.detectChanges(); 234 | 235 | expect(lordController.isSelected()).toBe(true); 236 | expect(treeService.nodeSelected$.next).toHaveBeenCalledTimes(1); 237 | }); 238 | 239 | it('knows how to collapse a node', () => { 240 | spyOn(treeService.nodeCollapsed$, 'next'); 241 | 242 | const lordController = treeService.getController(lordInternalTreeInstance.tree.id); 243 | 244 | expect(lordController.isCollapsed()).toEqual(false); 245 | expect(childrenOf(lordInternalTreeDebugElement).length).toEqual(4); 246 | 247 | lordController.collapse(); 248 | fixture.detectChanges(); 249 | 250 | expect(lordController.isCollapsed()).toEqual(true); 251 | expect(childrenOf(lordInternalTreeDebugElement).length).toEqual(0); 252 | expect(treeService.nodeCollapsed$.next).toHaveBeenCalledTimes(1); 253 | }); 254 | 255 | it('collapses a node only if it is expanded', () => { 256 | spyOn(treeService.nodeCollapsed$, 'next'); 257 | 258 | const lordController = treeService.getController(lordInternalTreeInstance.tree.id); 259 | 260 | lordController.collapse(); 261 | lordController.collapse(); 262 | lordController.collapse(); 263 | 264 | fixture.detectChanges(); 265 | 266 | expect(lordController.isCollapsed()).toBe(true); 267 | expect(treeService.nodeCollapsed$.next).toHaveBeenCalledTimes(1); 268 | }); 269 | 270 | it('knows how to expand a node', () => { 271 | const lordController = treeService.getController(lordInternalTreeInstance.tree.id); 272 | 273 | lordController.collapse(); 274 | fixture.detectChanges(); 275 | 276 | spyOn(treeService.nodeExpanded$, 'next'); 277 | 278 | expect(lordController.isExpanded()).toEqual(false); 279 | expect(childrenOf(lordInternalTreeDebugElement).length).toEqual(0); 280 | 281 | lordController.expand(); 282 | fixture.detectChanges(); 283 | 284 | expect(lordController.isExpanded()).toEqual(true); 285 | expect(childrenOf(lordInternalTreeDebugElement).length).toEqual(4); 286 | expect(treeService.nodeExpanded$.next).toHaveBeenCalledTimes(1); 287 | }); 288 | 289 | it('expands a node only if it is collapsed', () => { 290 | spyOn(treeService.nodeExpanded$, 'next'); 291 | spyOn(treeService.nodeCollapsed$, 'next'); 292 | 293 | const lordController = treeService.getController(lordInternalTreeInstance.tree.id); 294 | 295 | lordController.collapse(); 296 | fixture.detectChanges(); 297 | 298 | lordController.expand(); 299 | lordController.expand(); 300 | 301 | expect(lordController.isExpanded()).toBe(true); 302 | expect(treeService.nodeExpanded$.next).toHaveBeenCalledTimes(1); 303 | expect(treeService.nodeCollapsed$.next).toHaveBeenCalledTimes(1); 304 | }); 305 | 306 | it('knows how to rename a node', () => { 307 | expect(nodeNameOf(lordInternalTreeDebugElement)).toEqual('Lord'); 308 | 309 | const lordController = treeService.getController(lordInternalTreeInstance.tree.id); 310 | 311 | lordController.rename('Master Lord'); 312 | fixture.detectChanges(); 313 | 314 | expect(nodeNameOf(lordInternalTreeDebugElement)).toEqual('Master Lord'); 315 | }); 316 | 317 | it('knows how to remove a node', () => { 318 | const child = firstChildOf(lordInternalTreeDebugElement); 319 | expect(nodeNameOf(child)).toEqual('Disciple#1'); 320 | expect(childrenOf(lordInternalTreeDebugElement).length).toEqual(4); 321 | 322 | const childController = treeService.getController(child.componentInstance.tree.id); 323 | 324 | childController.remove(); 325 | 326 | fixture.detectChanges(); 327 | 328 | expect(nodeNameOf(firstChildOf(lordInternalTreeDebugElement))).toEqual('Disciple#2'); 329 | expect(childrenOf(lordInternalTreeDebugElement).length).toEqual(3); 330 | }); 331 | 332 | it('knows how to add a new child', () => { 333 | expect(childrenOf(lordInternalTreeDebugElement).length).toEqual(4); 334 | 335 | const childController = treeService.getController(lordInternalTreeInstance.tree.id); 336 | 337 | childController.addChild({ 338 | value: 'N', 339 | children: [{ value: 'N1' }, { value: 'N2' }] 340 | }); 341 | 342 | fixture.detectChanges(); 343 | 344 | const children = childrenOf(lordInternalTreeDebugElement); 345 | expect(nodeNameOf(children[6])).toEqual('N2'); 346 | expect(children.length).toEqual(7); 347 | }); 348 | 349 | it('does not add a child if async children of the target node were not loaded', () => { 350 | const child = childrenOf(lordInternalTreeDebugElement)[0]; 351 | 352 | expect(child.componentInstance.tree.value).toEqual('Disciple#1'); 353 | expect(childrenOf(child).length).toEqual(0); 354 | 355 | const childController = treeService.getController(child.componentInstance.tree.id); 356 | 357 | childController.addChild({ value: 'N' }); 358 | 359 | fixture.detectChanges(); 360 | 361 | expect(childrenOf(child).length).toEqual(0); 362 | }); 363 | 364 | it('knows how to change node id', () => { 365 | expect(childrenOf(lordInternalTreeDebugElement).length).toEqual(4); 366 | 367 | const childController = treeService.getController(lordInternalTreeInstance.tree.id); 368 | 369 | childController.changeNodeId('Boom!'); 370 | 371 | expect(lordInternalTreeInstance.tree.id).toEqual('Boom!'); 372 | expect(childController).toBe(treeService.getController('Boom!')); 373 | }); 374 | 375 | it('throws an error if new id is not given', () => { 376 | const childController = treeService.getController(lordInternalTreeInstance.tree.id); 377 | 378 | try { 379 | childController.changeNodeId(null); 380 | fail('Should throw an error if id is not given'); 381 | } catch (error) { 382 | expect(error.message).toEqual('You should supply an id!'); 383 | } 384 | }); 385 | 386 | it('throws an error if controller for a given id already exists', () => { 387 | const childController = treeService.getController(lordInternalTreeInstance.tree.id); 388 | 389 | try { 390 | childController.changeNodeId(lordInternalTreeInstance.tree.id); 391 | fail('Should throw an error if controller for a given id already exists'); 392 | } catch (error) { 393 | expect(error.message).toEqual(`Controller already exists for the given id: 1`); 394 | } 395 | }); 396 | 397 | it('knows how to reload async children', () => { 398 | spyOn(lordInternalTreeInstance.tree, 'reloadChildren'); 399 | 400 | const lordController = treeService.getController(lordInternalTreeInstance.tree.id); 401 | lordController.reloadChildren(); 402 | 403 | expect(lordInternalTreeInstance.tree.reloadChildren).toHaveBeenCalledTimes(1); 404 | }); 405 | 406 | it('knows how to set children for a node', () => { 407 | expect(childrenOf(lordInternalTreeDebugElement).length).toEqual(4); 408 | 409 | const childController = treeService.getController(lordInternalTreeInstance.tree.id); 410 | 411 | childController.setChildren([{ value: 'N1' }, { value: 'N2' }]); 412 | 413 | fixture.detectChanges(); 414 | 415 | const children = childrenOf(lordInternalTreeDebugElement); 416 | expect(children.length).toEqual(2); 417 | expect(children[0].componentInstance.tree.value).toEqual('N1'); 418 | expect(children[1].componentInstance.tree.value).toEqual('N2'); 419 | }); 420 | 421 | it('does not set children for the leaf', () => { 422 | const children = childrenOf(lordInternalTreeDebugElement); 423 | expect(children.length).toEqual(4); 424 | 425 | const child = children[3]; 426 | expect(child.componentInstance.tree.value).toEqual('SubDisciple#2'); 427 | expect(child.componentInstance.tree.hasChildren()).toBe(false); 428 | 429 | const childController = treeService.getController(child.componentInstance.tree.id); 430 | 431 | childController.setChildren([{ value: 'N1' }, { value: 'N2' }]); 432 | 433 | fixture.detectChanges(); 434 | 435 | expect(childrenOf(child).length).toEqual(0); 436 | }); 437 | 438 | it('knows how to transfer a node into a BeingRenamed state', () => { 439 | const lordController = treeService.getController(lordInternalTreeInstance.tree.id); 440 | expect(lordInternalTreeInstance.tree.isBeingRenamed()).toEqual(false); 441 | 442 | lordController.startRenaming(); 443 | 444 | fixture.detectChanges(); 445 | 446 | expect(lordInternalTreeInstance.tree.isBeingRenamed()).toEqual(true); 447 | }); 448 | 449 | it('knows how to convert a tree to tree model', () => { 450 | const model = { value: 'bla' }; 451 | 452 | const tree: any = { 453 | toTreeModel: jasmine.createSpy('tree.toTreeModel').and.returnValue(model) 454 | }; 455 | 456 | const controller = new TreeController({ tree, treeService: null } as any); 457 | 458 | const actualModel = controller.toTreeModel(); 459 | 460 | expect(actualModel).toBe(model); 461 | }); 462 | }); 463 | 464 | function nodeNameOf(internalTreeDebugElement: DebugElement): string { 465 | return internalTreeDebugElement.query(By.css('.node-name')).nativeElement.innerHTML; 466 | } 467 | 468 | function nodeValueElementOf(internalTreeDebugElement: DebugElement): DebugElement { 469 | return internalTreeDebugElement.query(By.css('.node-value')); 470 | } 471 | 472 | function childrenOf(internalTreeDebugElement: DebugElement): DebugElement[] { 473 | return internalTreeDebugElement.queryAll(By.directive(TreeInternalComponent)); 474 | } 475 | 476 | function firstChildOf(internalTreeDebugElement: DebugElement): DebugElement { 477 | return internalTreeDebugElement.query(By.directive(TreeInternalComponent)); 478 | } 479 | 480 | function clickOn(internalTreeDebugElement: DebugElement, event: any): void { 481 | nodeValueElementOf(internalTreeDebugElement).triggerEventHandler('click', event); 482 | } 483 | --------------------------------------------------------------------------------