├── 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 |
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 |
57 |
58 |
62 |
63 |
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 |
--------------------------------------------------------------------------------