diff --git a/application/src/main/data/json/system/widget_bundles/cards.json b/application/src/main/data/json/system/widget_bundles/cards.json
index bf40d495df..eaf129c1c0 100644
--- a/application/src/main/data/json/system/widget_bundles/cards.json
+++ b/application/src/main/data/json/system/widget_bundles/cards.json
@@ -125,9 +125,9 @@
"sizeX": 7.5,
"sizeY": 3.5,
"resources": [],
- "templateHtml": "\n",
+ "templateHtml": "\n",
"templateCss": "",
- "controllerScript": "self.onInit = function() {\n var scope = self.ctx.$scope;\n var id = self.ctx.$scope.$injector.get('utils').guid();\n scope.hierarchyId = \"hierarchy-\"+id;\n scope.ctx = self.ctx;\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.$broadcast('entities-hierarchy-data-updated', self.ctx.$scope.hierarchyId);\n}\n\nself.typeParameters = function() {\n return {\n dataKeysOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n 'nodeSelected': {\n name: 'widget-action.node-selected',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n",
+ "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesHierarchyWidget.onDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n dataKeysOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n 'nodeSelected': {\n name: 'widget-action.node-selected',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n",
"settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"EntitiesHierarchySettings\",\n \"properties\": {\n \"nodeRelationQueryFunction\": {\n \"title\": \"Node relations query function: f(nodeCtx)\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"nodeHasChildrenFunction\": {\n \"title\": \"Node has children function: f(nodeCtx)\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"nodeOpenedFunction\": {\n \"title\": \"Default node opened function: f(nodeCtx)\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"nodeDisabledFunction\": {\n \"title\": \"Node disabled function: f(nodeCtx)\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"nodeIconFunction\": {\n \"title\": \"Node icon function: f(nodeCtx)\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"nodeTextFunction\": {\n \"title\": \"Node text function: f(nodeCtx)\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"nodesSortFunction\": {\n \"title\": \"Nodes sort function: f(nodeCtx1, nodeCtx2)\",\n \"type\": \"string\",\n \"default\": \"\"\n }\n },\n \"required\": []\n },\n \"form\": [\n {\n \"key\": \"nodeRelationQueryFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"nodeHasChildrenFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"nodeOpenedFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"nodeDisabledFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"nodeIconFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"nodeTextFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"nodesSortFunction\",\n \"type\": \"javascript\"\n }\n ]\n}",
"dataKeySettingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"DataKeySettings\",\n \"properties\": {},\n \"required\": []\n },\n \"form\": []\n}",
"defaultConfig": "{\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":86400000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"4px\",\"settings\":{\"nodeRelationQueryFunction\":\"/**\\n\\n// Function should return relations query object for current node used to fetch entity children.\\n// Function can return 'default' string value. In this case default relations query will be used.\\n\\n// The following example code will construct simple relations query that will fetch relations of type 'Contains'\\n// from the current entity.\\n\\nvar entity = nodeCtx.entity;\\nvar query = {\\n parameters: {\\n rootId: entity.id.id,\\n rootType: entity.id.entityType,\\n direction: types.entitySearchDirection.from,\\n relationTypeGroup: \\\"COMMON\\\",\\n maxLevel: 1\\n },\\n filters: [{\\n relationType: \\\"Contains\\\",\\n entityTypes: []\\n }]\\n};\\nreturn query;\\n\\n**/\\n\",\"nodeHasChildrenFunction\":\"/**\\n\\n// Function should return boolean value indicating whether current node has children (whether it can be expanded).\\n\\n// The following example code will restrict entities hierarchy expansion up to third level.\\n\\nreturn nodeCtx.level <= 2;\\n\\n// The next example code will restrict entities expansion according to the value of example 'nodeHasChildren' attribute.\\n\\nvar data = nodeCtx.data;\\nif (data.hasOwnProperty('nodeHasChildren') && data['nodeHasChildren'] !== null) {\\n return data['nodeHasChildren'] === 'true';\\n} else {\\n return true;\\n}\\n \\n**/\\n \",\"nodeTextFunction\":\"/**\\n\\n// Function should return text (can be HTML code) for the current node.\\n\\n// The following example code will generate node text consisting of entity name and temperature if temperature value is present in entity attributes/timeseries.\\n\\nvar data = nodeCtx.data;\\nvar entity = nodeCtx.entity;\\nvar text = entity.name;\\nif (data.hasOwnProperty('temperature') && data['temperature'] !== null) {\\n text += \\\" \\\"+ data['temperature'] +\\\" °C\\\";\\n}\\nreturn text;\\n\\n**/\",\"nodeIconFunction\":\"/** \\n\\n// Function should return node icon info object.\\n// Resulting object should contain either 'materialIcon' or 'iconUrl' property. \\n// Where:\\n - 'materialIcon' - name of the material icon to be used from the Material Icons Library (https://material.io/tools/icons);\\n - 'iconUrl' - url of the external image to be used as node icon.\\n// Function can return 'default' string value. In this case default icons according to entity type will be used.\\n\\n// The following example code shows how to use external image for devices which name starts with 'Test' and use \\n// default icons for the rest of entities.\\n\\nvar entity = nodeCtx.entity;\\nif (entity.id.entityType === 'DEVICE' && entity.name.startsWith('Test')) {\\n return {iconUrl: 'https://avatars1.githubusercontent.com/u/14793288?v=4&s=117'};\\n} else {\\n return 'default';\\n}\\n \\n**/\",\"nodeDisabledFunction\":\"/**\\n\\n// Function should return boolean value indicating whether current node should be disabled (not selectable).\\n\\n// The following example code will disable current node according to the value of example 'nodeDisabled' attribute.\\n\\nvar data = nodeCtx.data;\\nif (data.hasOwnProperty('nodeDisabled') && data['nodeDisabled'] !== null) {\\n return data['nodeDisabled'] === 'true';\\n} else {\\n return false;\\n}\\n \\n**/\\n\",\"nodesSortFunction\":\"/**\\n\\n// This function is used to sort nodes of the same level. Function should compare two nodes and return \\n// integer value: \\n// - less than 0 - sort nodeCtx1 to an index lower than nodeCtx2\\n// - 0 - leave nodeCtx1 and nodeCtx2 unchanged with respect to each other\\n// - greater than 0 - sort nodeCtx2 to an index lower than nodeCtx1\\n\\n// The following example code will sort entities first by entity type in alphabetical order then\\n// by entity name in alphabetical order.\\n\\nvar result = nodeCtx1.entity.id.entityType.localeCompare(nodeCtx2.entity.id.entityType);\\nif (result === 0) {\\n result = nodeCtx1.entity.name.localeCompare(nodeCtx2.entity.name);\\n}\\nreturn result;\\n \\n**/\",\"nodeOpenedFunction\":\"/**\\n\\n// Function should return boolean value indicating whether current node should be opened (expanded) when it first loaded.\\n\\n// The following example code will open by default nodes up to third level.\\n\\nreturn nodeCtx.level <= 2;\\n\\n**/\\n \"},\"title\":\"Entities hierarchy\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"datasources\":[{\"type\":\"function\",\"name\":\"Simulated\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Sin\",\"color\":\"#2196f3\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.472295003170325,\"funcBody\":\"return Math.round(1000*Math.sin(time/5000));\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Cos\",\"color\":\"#4caf50\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.8926244886945558,\"funcBody\":\"return Math.round(1000*Math.cos(time/5000));\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#f44336\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.6401141393938932,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"widgetStyle\":{},\"actions\":{}}"
diff --git a/ui-ngx/angular.json b/ui-ngx/angular.json
index 3a6fb4fdb6..e1106a7c7e 100644
--- a/ui-ngx/angular.json
+++ b/ui-ngx/angular.json
@@ -36,7 +36,8 @@
"node_modules/tooltipster/dist/css/tooltipster.bundle.min.css",
"node_modules/tooltipster/dist/css/plugins/tooltipster/sideTip/themes/tooltipster-sideTip-shadow.min.css",
"src/app/shared/components/json-form/react/json-form.scss",
- "node_modules/rc-select/assets/index.css"
+ "node_modules/rc-select/assets/index.css",
+ "node_modules/jstree-bootstrap-theme/dist/themes/proton/style.min.css"
],
"stylePreprocessorOptions": {
"includePaths": [
@@ -79,7 +80,8 @@
"node_modules/ace-builds/src-min/snippets/json.js",
"node_modules/ace-builds/src-min/snippets/java.js",
"node_modules/ace-builds/src-min/snippets/javascript.js",
- "node_modules/systemjs/dist/system.js"
+ "node_modules/systemjs/dist/system.js",
+ "node_modules/jstree/dist/jstree.min.js"
],
"es5BrowserSupport": true,
"customWebpackConfig": {
diff --git a/ui-ngx/package-lock.json b/ui-ngx/package-lock.json
index 1922bf20ed..95a439ed51 100644
--- a/ui-ngx/package-lock.json
+++ b/ui-ngx/package-lock.json
@@ -3603,6 +3603,15 @@
"integrity": "sha512-B1Br8yE27obcYvFx5ECZswT/947aAFNb9lHqnkUOhtOfvJqaa6Axibo4T+5G6iQlUfjgSd8am9R/9j9UBfRlrw==",
"dev": true
},
+ "@types/jstree": {
+ "version": "3.3.39",
+ "resolved": "https://registry.npmjs.org/@types/jstree/-/jstree-3.3.39.tgz",
+ "integrity": "sha512-lUUl9NCRqziIXYTC0n6NUykQS0oII5tUhXq6/pXVZSbrbcxB0jr3i7Bpn/8v6f8BVA9su1/NF/DoJ8IrwKuzKw==",
+ "dev": true,
+ "requires": {
+ "@types/jquery": "*"
+ }
+ },
"@types/minimatch": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
@@ -8685,6 +8694,22 @@
"jss": "10.0.0"
}
},
+ "jstree": {
+ "version": "3.3.8",
+ "resolved": "https://registry.npmjs.org/jstree/-/jstree-3.3.8.tgz",
+ "integrity": "sha512-0/nhGxVLSGfGQyVg+q59ocqSEKWRDKHoA8wNrcOIvlzCCw19tzvcMNGJ19hf+U0b7fycABowkny7fQPcLgUwwA==",
+ "requires": {
+ "jquery": ">=1.9.1"
+ }
+ },
+ "jstree-bootstrap-theme": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/jstree-bootstrap-theme/-/jstree-bootstrap-theme-1.0.1.tgz",
+ "integrity": "sha1-fV7cc6hG6Np/lPV6HMXd7p2eq0s=",
+ "requires": {
+ "jquery": ">=1.9.1"
+ }
+ },
"jszip": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.2.2.tgz",
diff --git a/ui-ngx/package.json b/ui-ngx/package.json
index 07d0d21c81..dced2e17a4 100644
--- a/ui-ngx/package.json
+++ b/ui-ngx/package.json
@@ -54,6 +54,8 @@
"jquery.terminal": "^2.9.0",
"js-beautify": "^1.10.2",
"json-schema-defaults": "^0.4.0",
+ "jstree": "^3.3.8",
+ "jstree-bootstrap-theme": "^1.0.1",
"material-design-icons": "^3.0.1",
"messageformat": "^2.3.0",
"moment": "^2.24.0",
@@ -93,6 +95,7 @@
"@types/jasminewd2": "~2.0.8",
"@types/jquery": "^3.3.31",
"@types/js-beautify": "^1.8.1",
+ "@types/jstree": "^3.3.39",
"@types/node": "~12.12.17",
"@types/react": "^16.9.16",
"@types/react-dom": "^16.9.4",
diff --git a/ui-ngx/src/app/app.component.ts b/ui-ngx/src/app/app.component.ts
index 2a0c78585f..1d30454510 100644
--- a/ui-ngx/src/app/app.component.ts
+++ b/ui-ngx/src/app/app.component.ts
@@ -51,7 +51,7 @@ export class AppComponent implements OnInit {
this.matIconRegistry.addSvgIconLiteral(
'alpha-a-circle-outline',
this.domSanitizer.bypassSecurityTrustHtml(
- ''
diff --git a/ui-ngx/src/app/core/interceptors/global-http-interceptor.ts b/ui-ngx/src/app/core/interceptors/global-http-interceptor.ts
index fe11d8461d..ded6a2ec5a 100644
--- a/ui-ngx/src/app/core/interceptors/global-http-interceptor.ts
+++ b/ui-ngx/src/app/core/interceptors/global-http-interceptor.ts
@@ -128,7 +128,7 @@ export class GlobalHttpInterceptor implements HttpInterceptor {
if (errorResponse.error.refreshTokenPending || errorResponse.status === 401) {
if (errorResponse.error.refreshTokenPending || errorCode && errorCode === Constants.serverErrorCode.jwtTokenExpired) {
return this.refreshTokenAndRetry(req, next);
- } else {
+ } else if (errorCode !== Constants.serverErrorCode.credentialsExpired) {
unhandled = true;
}
} else if (errorResponse.status === 429) {
diff --git a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.scss b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.scss
index 3b525bae39..e5cbdb8404 100644
--- a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.scss
+++ b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.scss
@@ -91,5 +91,11 @@
opacity: 1;
}
}
+ ul.indicators {
+ pointer-events: none;
+ li {
+ pointer-events: all;
+ }
+ }
}
}
diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.scss b/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.scss
index 1197999f23..b10ad081f1 100644
--- a/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.scss
+++ b/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.scss
@@ -21,14 +21,22 @@
}
:host ::ng-deep {
- .mat-tab-body-wrapper {
- position: absolute;
- top: 49px;
- left: 0;
- right: 0;
- bottom: 0;
- }
- .mat-tab-label {
- min-width: 40px;
+ tb-details-panel {
+ > .mat-content {
+ > .mat-tab-group {
+ > .mat-tab-body-wrapper {
+ position: absolute;
+ top: 49px;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ }
+ > .mat-tab-header {
+ .mat-tab-label {
+ min-width: 40px;
+ }
+ }
+ }
+ }
}
}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.component.html
new file mode 100644
index 0000000000..1e3f6da1a3
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.component.html
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.component.scss
new file mode 100644
index 0000000000..9f296c4e60
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.component.scss
@@ -0,0 +1,122 @@
+/**
+ * Copyright © 2016-2019 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+:host-context(.tb-has-timewindow) {
+ .tb-entities-hierarchy {
+ mat-toolbar {
+ height: 60px;
+ max-height: 60px;
+ .mat-toolbar-tools {
+ height: 60px;
+ max-height: 60px;
+ }
+ }
+ }
+}
+
+:host {
+ .tb-entities-hierarchy {
+ mat-toolbar.mat-table-toolbar:not([color="primary"]) {
+ background: transparent;
+ }
+ mat-toolbar {
+ min-height: 39px;
+ max-height: 39px;
+ .mat-toolbar-tools {
+ min-height: 39px;
+ max-height: 39px;
+ }
+ }
+
+ .tb-entities-nav-tree-panel {
+ overflow-x: auto;
+ overflow-y: auto;
+ }
+ }
+}
+
+:host ::ng-deep {
+ .tb-nav-tree-container {
+ &.jstree-proton {
+ .jstree-anchor {
+ div.node-icon {
+ display: inline-block;
+ width: 22px;
+ height: 22px;
+ margin-right: 2px;
+ margin-bottom: 2px;
+ background-color: transparent;
+ background-repeat: no-repeat;
+ background-attachment: scroll;
+ background-position: center center;
+ background-size: 18px 18px;
+ }
+
+ mat-icon.node-icon {
+ width: 22px;
+ min-width: 22px;
+ height: 22px;
+ min-height: 22px;
+ margin-right: 2px;
+ margin-bottom: 2px;
+ color: inherit;
+ vertical-align: middle;
+
+ &.material-icons {
+ font-size: 18px;
+ line-height: 22px;
+ text-align: center;
+ }
+ }
+
+ &.jstree-hovered:not(.jstree-clicked),
+ &.jstree-disabled {
+ div.node-icon {
+ opacity: .5;
+ }
+ }
+ }
+ }
+ }
+
+ @media (max-width: 768px) {
+ .tb-nav-tree-container {
+ &.jstree-proton-responsive {
+ .jstree-anchor {
+ div.node-icon {
+ width: 40px;
+ height: 40px;
+ margin: 0;
+ background-size: 24px 24px;
+ }
+
+ mat-icon.node-icon {
+ width: 40px;
+ min-width: 40px;
+ height: 40px;
+ min-height: 40px;
+ margin: 0;
+
+ &.material-icons {
+ font-size: 24px;
+ line-height: 40px;
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.component.ts
new file mode 100644
index 0000000000..bde8f744ff
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.component.ts
@@ -0,0 +1,490 @@
+///
+/// Copyright © 2016-2019 The Thingsboard Authors
+///
+/// Licensed under the Apache License, Version 2.0 (the "License");
+/// you may not use this file except in compliance with the License.
+/// You may obtain a copy of the License at
+///
+/// http://www.apache.org/licenses/LICENSE-2.0
+///
+/// Unless required by applicable law or agreed to in writing, software
+/// distributed under the License is distributed on an "AS IS" BASIS,
+/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+/// See the License for the specific language governing permissions and
+/// limitations under the License.
+///
+
+import { AfterViewInit, Component, ElementRef, Input, OnInit, ViewChild, ViewContainerRef } from '@angular/core';
+import { PageComponent } from '@shared/components/page.component';
+import { Store } from '@ngrx/store';
+import { AppState } from '@core/core.state';
+import { WidgetAction, WidgetContext } from '@home/models/widget-component.models';
+import { DatasourceData, DatasourceType, WidgetConfig, widgetType } from '@shared/models/widget.models';
+import { IWidgetSubscription, WidgetSubscriptionOptions } from '@core/api/widget-api.models';
+import { UtilsService } from '@core/services/utils.service';
+import cssjs from '@core/css/css';
+import { forkJoin, fromEvent, Observable, of } from 'rxjs';
+import { catchError, debounceTime, distinctUntilChanged, map, mergeMap, tap } from 'rxjs/operators';
+import { constructTableCssString } from '@home/components/widget/lib/table-widget.models';
+import { Overlay } from '@angular/cdk/overlay';
+import {
+ LoadNodesCallback,
+ NavTreeEditCallbacks,
+ NodeSearchCallback,
+ NodeSelectedCallback,
+ NodesInsertedCallback
+} from '@shared/components/nav-tree.component';
+import { BaseData } from '@shared/models/base-data';
+import { EntityId } from '@shared/models/id/entity-id';
+import { EntityType } from '@shared/models/entity-type.models';
+import { deepClone } from '@core/utils';
+import {
+ defaultNodeIconFunction,
+ defaultNodeOpenedFunction,
+ defaultNodeRelationQueryFunction,
+ defaultNodesSortFunction,
+ EntitiesHierarchyWidgetSettings,
+ HierarchyNavTreeNode,
+ HierarchyNodeContext,
+ HierarchyNodeDatasource,
+ iconUrlHtml,
+ loadNodeCtxFunction,
+ materialIconHtml,
+ NodeDisabledFunction,
+ NodeHasChildrenFunction,
+ NodeIconFunction,
+ NodeOpenedFunction,
+ NodeRelationQueryFunction,
+ NodesSortFunction,
+ NodeTextFunction
+} from '@home/components/widget/lib/entities-hierarchy-widget.models';
+import { EntityService } from '@core/http/entity.service';
+import { EntityRelationsQuery, EntitySearchDirection } from '@shared/models/relation.models';
+import { EntityRelationService } from '@core/http/entity-relation.service';
+import { ActionNotificationShow } from '@core/notification/notification.actions';
+
+@Component({
+ selector: 'tb-entities-hierarchy-widget',
+ templateUrl: './entities-hierarchy-widget.component.html',
+ styleUrls: ['./entities-hierarchy-widget.component.scss']
+})
+export class EntitiesHierarchyWidgetComponent extends PageComponent implements OnInit, AfterViewInit {
+
+ @Input()
+ ctx: WidgetContext;
+
+ @ViewChild('searchInput', {static: false}) searchInputField: ElementRef;
+
+ public toastTargetId = 'entities-hierarchy-' + this.utils.guid();
+
+ public textSearchMode = false;
+ public textSearch = null;
+
+ public nodeEditCallbacks: NavTreeEditCallbacks = {};
+
+ private settings: EntitiesHierarchyWidgetSettings;
+ private widgetConfig: WidgetConfig;
+ private subscription: IWidgetSubscription;
+ private datasources: Array;
+
+ private nodesMap: {[nodeId: string]: HierarchyNavTreeNode} = {};
+ private pendingUpdateNodeTasks: {[nodeId: string]: () => void} = {};
+ private nodeIdCounter = 0;
+
+ private nodeRelationQueryFunction: NodeRelationQueryFunction;
+ private nodeIconFunction: NodeIconFunction;
+ private nodeTextFunction: NodeTextFunction;
+ private nodeDisabledFunction: NodeDisabledFunction;
+ private nodeOpenedFunction: NodeOpenedFunction;
+ private nodeHasChildrenFunction: NodeHasChildrenFunction;
+ private nodesSortFunction: NodesSortFunction;
+
+ private searchAction: WidgetAction = {
+ name: 'action.search',
+ show: true,
+ icon: 'search',
+ onAction: () => {
+ this.enterFilterMode();
+ }
+ };
+
+
+ constructor(protected store: Store,
+ private elementRef: ElementRef,
+ private overlay: Overlay,
+ private viewContainerRef: ViewContainerRef,
+ private utils: UtilsService,
+ private entityService: EntityService,
+ private entityRelationService: EntityRelationService) {
+ super(store);
+ }
+
+ ngOnInit(): void {
+ this.ctx.$scope.entitiesHierarchyWidget = this;
+ this.settings = this.ctx.settings;
+ this.widgetConfig = this.ctx.widgetConfig;
+ this.subscription = this.ctx.defaultSubscription;
+ this.datasources = this.subscription.datasources as Array;
+ this.initializeConfig();
+ this.ctx.updateWidgetParams();
+ }
+
+ ngAfterViewInit(): void {
+ fromEvent(this.searchInputField.nativeElement, 'keyup')
+ .pipe(
+ debounceTime(150),
+ distinctUntilChanged(),
+ tap(() => {
+ this.updateSearchNodes();
+ })
+ )
+ .subscribe();
+ }
+
+ public onDataUpdated() {
+ this.updateNodeData(this.subscription.data);
+ }
+
+ private initializeConfig() {
+ this.ctx.widgetActions = [this.searchAction];
+
+ const testNodeCtx: HierarchyNodeContext = {
+ entity: {
+ id: {
+ entityType: EntityType.DEVICE,
+ id: '123'
+ },
+ name: 'TEST DEV1'
+ },
+ data: {},
+ level: 2
+ };
+ const parentNodeCtx = deepClone(testNodeCtx);
+ parentNodeCtx.level = 1;
+ testNodeCtx.parentNodeCtx = parentNodeCtx;
+
+ this.nodeRelationQueryFunction = loadNodeCtxFunction(this.settings.nodeRelationQueryFunction, 'nodeCtx', testNodeCtx);
+ this.nodeIconFunction = loadNodeCtxFunction(this.settings.nodeIconFunction, 'nodeCtx', testNodeCtx);
+ this.nodeTextFunction = loadNodeCtxFunction(this.settings.nodeTextFunction, 'nodeCtx', testNodeCtx);
+ this.nodeDisabledFunction = loadNodeCtxFunction(this.settings.nodeDisabledFunction, 'nodeCtx', testNodeCtx);
+ this.nodeOpenedFunction = loadNodeCtxFunction(this.settings.nodeOpenedFunction, 'nodeCtx', testNodeCtx);
+ this.nodeHasChildrenFunction = loadNodeCtxFunction(this.settings.nodeHasChildrenFunction, 'nodeCtx', testNodeCtx);
+
+ const testNodeCtx2 = deepClone(testNodeCtx);
+ testNodeCtx2.entity.name = 'TEST DEV2';
+
+ this.nodesSortFunction = loadNodeCtxFunction(this.settings.nodesSortFunction, 'nodeCtx1,nodeCtx2', testNodeCtx, testNodeCtx2);
+
+ this.nodeRelationQueryFunction = this.nodeRelationQueryFunction || defaultNodeRelationQueryFunction;
+ this.nodeIconFunction = this.nodeIconFunction || defaultNodeIconFunction;
+ this.nodeTextFunction = this.nodeTextFunction || ((nodeCtx) => nodeCtx.entity.name);
+ this.nodeDisabledFunction = this.nodeDisabledFunction || (() => false);
+ this.nodeOpenedFunction = this.nodeOpenedFunction || defaultNodeOpenedFunction;
+ this.nodeHasChildrenFunction = this.nodeHasChildrenFunction || (() => true);
+ this.nodesSortFunction = this.nodesSortFunction || defaultNodesSortFunction;
+
+ const cssString = constructTableCssString(this.widgetConfig);
+ const cssParser = new cssjs();
+ cssParser.testMode = false;
+ const namespace = 'entities-hierarchy-' + this.utils.hashCode(cssString);
+ cssParser.cssPreviewNamespace = namespace;
+ cssParser.createStyleElement(namespace, cssString);
+ $(this.elementRef.nativeElement).addClass(namespace);
+ }
+
+ private enterFilterMode() {
+ this.textSearchMode = true;
+ this.textSearch = '';
+ this.ctx.hideTitlePanel = true;
+ this.ctx.detectChanges(true);
+ setTimeout(() => {
+ this.searchInputField.nativeElement.focus();
+ this.searchInputField.nativeElement.setSelectionRange(0, 0);
+ }, 10);
+ }
+
+ exitFilterMode() {
+ this.textSearchMode = false;
+ this.textSearch = null;
+ this.updateSearchNodes();
+ this.ctx.hideTitlePanel = false;
+ this.ctx.detectChanges(true);
+ }
+
+ private updateSearchNodes() {
+ if (this.textSearch != null) {
+ this.nodeEditCallbacks.search(this.textSearch);
+ } else {
+ this.nodeEditCallbacks.clearSearch();
+ }
+ }
+
+ private updateNodeData(subscriptionData: Array) {
+ const affectedNodes: string[] = [];
+ if (subscriptionData) {
+ subscriptionData.forEach((datasourceData) => {
+ const datasource = datasourceData.datasource as HierarchyNodeDatasource;
+ if (datasource.nodeId) {
+ const node = this.nodesMap[datasource.nodeId];
+ const key = datasourceData.dataKey.label;
+ let value = undefined;
+ if (datasourceData.data && datasourceData.data.length) {
+ value = datasourceData.data[0][1];
+ }
+ if (node.data.nodeCtx.data[key] !== value) {
+ if (affectedNodes.indexOf(datasource.nodeId) === -1) {
+ affectedNodes.push(datasource.nodeId);
+ }
+ node.data.nodeCtx.data[key] = value;
+ }
+ }
+ });
+ }
+ affectedNodes.forEach((nodeId) => {
+ const node: HierarchyNavTreeNode = this.nodeEditCallbacks.getNode(nodeId);
+ if (node) {
+ this.updateNodeStyle(this.nodesMap[nodeId]);
+ } else {
+ this.pendingUpdateNodeTasks[nodeId] = () => {
+ this.updateNodeStyle(this.nodesMap[nodeId]);
+ };
+ }
+ });
+ }
+
+ public loadNodes: LoadNodesCallback = (node, cb) => {
+ if (node.id === '#') {
+ const tasks: Observable[] = [];
+ this.datasources.forEach((datasource) => {
+ tasks.push(this.datasourceToNode(datasource));
+ });
+ forkJoin(tasks).subscribe((nodes) => {
+ cb(this.prepareNodes(nodes));
+ this.updateNodeData(this.subscription.data);
+ });
+ } else {
+ if (node.data && node.data.nodeCtx.entity && node.data.nodeCtx.entity.id && node.data.nodeCtx.entity.id.entityType !== 'function') {
+ const relationQuery = this.prepareNodeRelationQuery(node.data.nodeCtx);
+ this.entityRelationService.findByQuery(relationQuery, {ignoreErrors: true, ignoreLoading: true}).subscribe(
+ (entityRelations) => {
+ if (entityRelations.length) {
+ const tasks: Observable[] = [];
+ entityRelations.forEach((relation) => {
+ const targetId = relationQuery.parameters.direction === EntitySearchDirection.FROM ? relation.to : relation.from;
+ tasks.push(this.entityIdToNode(targetId.entityType as EntityType, targetId.id, node.data.datasource, node.data.nodeCtx));
+ });
+ forkJoin(tasks).subscribe((nodes) => {
+ cb(this.prepareNodes(nodes));
+ });
+ } else {
+ cb([]);
+ }
+ },
+ (error) => {
+ let errorText = 'Failed to get relations!';
+ if (error && error.status === 400) {
+ errorText = 'Invalid relations query returned by \'Node relations query function\'! Please check widget configuration!';
+ }
+ this.showError(errorText);
+ }
+ );
+ } else {
+ cb([]);
+ }
+ }
+ };
+
+ public onNodeSelected: NodeSelectedCallback = (node, event) => {
+ let nodeId;
+ if (!node) {
+ nodeId = -1;
+ } else {
+ nodeId = node.id;
+ }
+ if (nodeId !== -1) {
+ const selectedNode = this.nodesMap[nodeId];
+ if (selectedNode) {
+ const descriptors = this.ctx.actionsApi.getActionDescriptors('nodeSelected');
+ if (descriptors.length) {
+ const entity = selectedNode.data.nodeCtx.entity;
+ this.ctx.actionsApi.handleWidgetAction(event, descriptors[0], entity.id, entity.name, { nodeCtx: selectedNode.data.nodeCtx });
+ }
+ }
+ }
+ };
+
+ public onNodesInserted: NodesInsertedCallback = (nodes, parent) => {
+ if (nodes) {
+ nodes.forEach((nodeId) => {
+ const task = this.pendingUpdateNodeTasks[nodeId];
+ if (task) {
+ task();
+ delete this.pendingUpdateNodeTasks[nodeId];
+ }
+ });
+ }
+ };
+
+ public searchCallback: NodeSearchCallback = (searchText, node) => {
+ const theNode = this.nodesMap[node.id];
+ if (theNode && theNode.data.searchText) {
+ return theNode.data.searchText.includes(searchText.toLowerCase());
+ }
+ return false;
+ };
+
+ private updateNodeStyle(node: HierarchyNavTreeNode) {
+ const newText = this.prepareNodeText(node);
+ if (node.text !== newText) {
+ node.text = newText;
+ this.nodeEditCallbacks.updateNode(node.id, node.text);
+ }
+ const newDisabled = this.nodeDisabledFunction(node.data.nodeCtx);
+ if (node.state.disabled !== newDisabled) {
+ node.state.disabled = newDisabled;
+ if (node.state.disabled) {
+ this.nodeEditCallbacks.disableNode(node.id);
+ } else {
+ this.nodeEditCallbacks.enableNode(node.id);
+ }
+ }
+ const newHasChildren = this.nodeHasChildrenFunction(node.data.nodeCtx);
+ if (node.children !== newHasChildren) {
+ node.children = newHasChildren;
+ this.nodeEditCallbacks.setNodeHasChildren(node.id, node.children);
+ }
+ }
+
+ private showError(errorText: string) {
+ this.store.dispatch(new ActionNotificationShow(
+ {
+ message: errorText,
+ type: 'error',
+ target: this.toastTargetId,
+ verticalPosition: 'bottom',
+ horizontalPosition: 'left'
+ }));
+ }
+
+ private prepareNodes(nodes: HierarchyNavTreeNode[]): HierarchyNavTreeNode[] {
+ nodes = nodes.filter((node) => node !== null);
+ nodes.sort((node1, node2) => this.nodesSortFunction(node1.data.nodeCtx, node2.data.nodeCtx));
+ return nodes;
+ }
+
+ private prepareNodeText(node: HierarchyNavTreeNode): string {
+ const nodeIcon = this.prepareNodeIcon(node.data.nodeCtx);
+ const nodeText = this.nodeTextFunction(node.data.nodeCtx);
+ node.data.searchText = nodeText ? nodeText.replace(/<[^>]+>/g, '').toLowerCase() : '';
+ return nodeIcon + nodeText;
+ }
+
+ private prepareNodeIcon(nodeCtx: HierarchyNodeContext): string {
+ let iconInfo = this.nodeIconFunction(nodeCtx);
+ if (iconInfo) {
+ if (iconInfo === 'default') {
+ iconInfo = defaultNodeIconFunction(nodeCtx);
+ }
+ if (iconInfo && iconInfo !== 'default' && (iconInfo.iconUrl || iconInfo.materialIcon)) {
+ if (iconInfo.materialIcon) {
+ return materialIconHtml(iconInfo.materialIcon);
+ } else {
+ return iconUrlHtml(iconInfo.iconUrl);
+ }
+ } else {
+ return '';
+ }
+ } else {
+ return '';
+ }
+ }
+
+ private datasourceToNode(datasource: HierarchyNodeDatasource, parentNodeCtx?: HierarchyNodeContext): Observable {
+ return this.resolveEntity(datasource).pipe(
+ map(entity => {
+ if (entity !== null) {
+ const node: HierarchyNavTreeNode = {
+ id: (++this.nodeIdCounter)+''
+ };
+ this.nodesMap[node.id] = node;
+ datasource.nodeId = node.id;
+ node.icon = false;
+ const nodeCtx: HierarchyNodeContext = {
+ parentNodeCtx,
+ entity,
+ data: {}
+ };
+ nodeCtx.level = parentNodeCtx ? parentNodeCtx.level + 1 : 1;
+ node.data = {
+ datasource,
+ nodeCtx
+ };
+ node.state = {
+ disabled: this.nodeDisabledFunction(node.data.nodeCtx),
+ opened: this.nodeOpenedFunction(node.data.nodeCtx)
+ };
+ node.text = this.prepareNodeText(node);
+ node.children = this.nodeHasChildrenFunction(node.data.nodeCtx);
+ return node;
+ } else {
+ return null;
+ }
+ })
+ );
+ }
+
+ private entityIdToNode(entityType: EntityType, entityId: string,
+ parentDatasource: HierarchyNodeDatasource,
+ parentNodeCtx: HierarchyNodeContext): Observable {
+ const datasource = {
+ dataKeys: parentDatasource.dataKeys,
+ type: DatasourceType.entity,
+ entityType,
+ entityId
+ } as HierarchyNodeDatasource;
+ return this.datasourceToNode(datasource, parentNodeCtx).pipe(
+ mergeMap((node) => {
+ if (node != null) {
+ const subscriptionOptions: WidgetSubscriptionOptions = {
+ type: widgetType.latest,
+ datasources: [datasource],
+ callbacks: {
+ onDataUpdated: subscription => {
+ this.updateNodeData(subscription.data);
+ }
+ }
+ };
+ return this.ctx.subscriptionApi.
+ createSubscription(subscriptionOptions, true).pipe(
+ map(() => node));
+ } else {
+ return of(node);
+ }
+ })
+ );
+ }
+
+ private resolveEntity(datasource: HierarchyNodeDatasource): Observable> {
+ if (datasource.type === DatasourceType.function) {
+ const entity = {
+ id: {
+ entityType: 'function'
+ },
+ name: datasource.name
+ };
+ return of(entity as BaseData);
+ } else {
+ return this.entityService.getEntity(datasource.entityType, datasource.entityId, {ignoreLoading: true}).pipe(
+ catchError(err => of(null))
+ );
+ }
+ }
+
+ private prepareNodeRelationQuery(nodeCtx: HierarchyNodeContext): EntityRelationsQuery {
+ let relationQuery = this.nodeRelationQueryFunction(nodeCtx);
+ if (relationQuery && relationQuery === 'default') {
+ relationQuery = defaultNodeRelationQueryFunction(nodeCtx);
+ }
+ return relationQuery as EntityRelationsQuery;
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.models.ts
new file mode 100644
index 0000000000..585d40e20c
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.models.ts
@@ -0,0 +1,160 @@
+///
+/// Copyright © 2016-2019 The Thingsboard Authors
+///
+/// Licensed under the Apache License, Version 2.0 (the "License");
+/// you may not use this file except in compliance with the License.
+/// You may obtain a copy of the License at
+///
+/// http://www.apache.org/licenses/LICENSE-2.0
+///
+/// Unless required by applicable law or agreed to in writing, software
+/// distributed under the License is distributed on an "AS IS" BASIS,
+/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+/// See the License for the specific language governing permissions and
+/// limitations under the License.
+///
+
+import { BaseData } from '@shared/models/base-data';
+import { EntityId } from '@shared/models/id/entity-id';
+import { NavTreeNode } from '@shared/components/nav-tree.component';
+import { Datasource } from '@shared/models/widget.models';
+import { isDefined, isUndefined } from '@core/utils';
+import { EntityRelationsQuery, EntitySearchDirection, RelationTypeGroup } from '@shared/models/relation.models';
+import { EntityType } from '@shared/models/entity-type.models';
+
+export interface EntitiesHierarchyWidgetSettings {
+ nodeRelationQueryFunction: string;
+ nodeHasChildrenFunction: string;
+ nodeOpenedFunction: string;
+ nodeDisabledFunction: string;
+ nodeIconFunction: string;
+ nodeTextFunction: string;
+ nodesSortFunction: string;
+}
+
+export interface HierarchyNodeContext {
+ parentNodeCtx?: HierarchyNodeContext;
+ entity: BaseData;
+ level?: number;
+ data: {[key: string]: any};
+}
+
+export interface HierarchyNavTreeNode extends NavTreeNode {
+ data?: {
+ datasource: HierarchyNodeDatasource;
+ nodeCtx: HierarchyNodeContext;
+ searchText?: string;
+ }
+}
+
+export interface HierarchyNodeDatasource extends Datasource {
+ nodeId: string;
+}
+
+export interface HierarchyNodeIconInfo {
+ iconUrl?: string;
+ materialIcon?: string;
+}
+
+export type NodeRelationQueryFunction = (nodeCtx: HierarchyNodeContext) => EntityRelationsQuery | 'default';
+export type NodeTextFunction = (nodeCtx: HierarchyNodeContext) => string;
+export type NodeDisabledFunction = (nodeCtx: HierarchyNodeContext) => boolean;
+export type NodeIconFunction = (nodeCtx: HierarchyNodeContext) => HierarchyNodeIconInfo | 'default';
+export type NodeOpenedFunction = (nodeCtx: HierarchyNodeContext) => boolean;
+export type NodeHasChildrenFunction = (nodeCtx: HierarchyNodeContext) => boolean;
+export type NodesSortFunction = (nodeCtx1: HierarchyNodeContext, nodeCtx2: HierarchyNodeContext) => number;
+
+export function loadNodeCtxFunction(functionBody: string, argNames: string, ...args: any[]): F {
+ let nodeCtxFunction: F = null;
+ if (isDefined(functionBody) && functionBody.length) {
+ try {
+ nodeCtxFunction = new Function(argNames, functionBody) as F;
+ const res = nodeCtxFunction.apply(null, args);
+ if (isUndefined(res)) {
+ nodeCtxFunction = null;
+ }
+ } catch (e) {
+ nodeCtxFunction = null;
+ }
+ }
+ return nodeCtxFunction;
+}
+
+export function materialIconHtml(materialIcon: string): string {
+ return ''+materialIcon+'';
+}
+
+export function iconUrlHtml(iconUrl: string): string {
+ return '
';
+}
+
+export const defaultNodeRelationQueryFunction: NodeRelationQueryFunction = nodeCtx => {
+ const entity = nodeCtx.entity;
+ const query: EntityRelationsQuery = {
+ parameters: {
+ rootId: entity.id.id,
+ rootType: entity.id.entityType as EntityType,
+ direction: EntitySearchDirection.FROM,
+ relationTypeGroup: RelationTypeGroup.COMMON,
+ maxLevel: 1
+ },
+ filters: [
+ {
+ relationType: "Contains",
+ entityTypes: []
+ }
+ ]
+ };
+ return query;
+};
+
+export const defaultNodeIconFunction: NodeIconFunction = nodeCtx => {
+ let materialIcon = 'insert_drive_file';
+ const entity = nodeCtx.entity;
+ if (entity && entity.id && entity.id.entityType) {
+ switch (entity.id.entityType as EntityType | string) {
+ case 'function':
+ materialIcon = 'functions';
+ break;
+ case EntityType.DEVICE:
+ materialIcon = 'devices_other';
+ break;
+ case EntityType.ASSET:
+ materialIcon = 'domain';
+ break;
+ case EntityType.TENANT:
+ materialIcon = 'supervisor_account';
+ break;
+ case EntityType.CUSTOMER:
+ materialIcon = 'supervisor_account';
+ break;
+ case EntityType.USER:
+ materialIcon = 'account_circle';
+ break;
+ case EntityType.DASHBOARD:
+ materialIcon = 'dashboards';
+ break;
+ case EntityType.ALARM:
+ materialIcon = 'notifications_active';
+ break;
+ case EntityType.ENTITY_VIEW:
+ materialIcon = 'view_quilt';
+ break;
+ }
+ }
+ return {
+ materialIcon
+ };
+};
+
+export const defaultNodeOpenedFunction: NodeOpenedFunction = nodeCtx => {
+ return nodeCtx.level <= 4;
+};
+
+export const defaultNodesSortFunction: NodesSortFunction = (nodeCtx1, nodeCtx2) => {
+ let result = nodeCtx1.entity.id.entityType.localeCompare(nodeCtx2.entity.id.entityType);
+ if (result === 0) {
+ result = nodeCtx1.entity.name.localeCompare(nodeCtx2.entity.name);
+ }
+ return result;
+};
diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
index 369d2546b0..27a4a5487f 100644
--- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
@@ -23,6 +23,7 @@ import { AlarmsTableWidgetComponent } from '@home/components/widget/lib/alarms-t
import { AlarmStatusFilterPanelComponent } from '@home/components/widget/lib/alarm-status-filter-panel.component';
import { SharedHomeComponentsModule } from '@home/components/shared-home-components.module';
import { TimeseriesTableWidgetComponent } from '@home/components/widget/lib/timeseries-table-widget.component';
+import { EntitiesHierarchyWidgetComponent } from '@home/components/widget/lib/entities-hierarchy-widget.component';
@NgModule({
entryComponents: [
@@ -35,7 +36,8 @@ import { TimeseriesTableWidgetComponent } from '@home/components/widget/lib/time
AlarmStatusFilterPanelComponent,
EntitiesTableWidgetComponent,
AlarmsTableWidgetComponent,
- TimeseriesTableWidgetComponent
+ TimeseriesTableWidgetComponent,
+ EntitiesHierarchyWidgetComponent
],
imports: [
CommonModule,
@@ -45,7 +47,8 @@ import { TimeseriesTableWidgetComponent } from '@home/components/widget/lib/time
exports: [
EntitiesTableWidgetComponent,
AlarmsTableWidgetComponent,
- TimeseriesTableWidgetComponent
+ TimeseriesTableWidgetComponent,
+ EntitiesHierarchyWidgetComponent
]
})
export class WidgetComponentsModule { }
diff --git a/ui-ngx/src/app/modules/login/login-routing.module.ts b/ui-ngx/src/app/modules/login/login-routing.module.ts
index 3af7f4dd1d..bfc37edba2 100644
--- a/ui-ngx/src/app/modules/login/login-routing.module.ts
+++ b/ui-ngx/src/app/modules/login/login-routing.module.ts
@@ -51,6 +51,16 @@ const routes: Routes = [
},
canActivate: [AuthGuard]
},
+ {
+ path: 'login/resetExpiredPassword',
+ component: ResetPasswordComponent,
+ data: {
+ title: 'login.reset-password',
+ module: 'public',
+ expiredPassword: true
+ },
+ canActivate: [AuthGuard]
+ },
{
path: 'login/createPassword',
component: CreatePasswordComponent,
diff --git a/ui-ngx/src/app/modules/login/pages/login/login.component.ts b/ui-ngx/src/app/modules/login/pages/login/login.component.ts
index b893f729f3..c048276a57 100644
--- a/ui-ngx/src/app/modules/login/pages/login/login.component.ts
+++ b/ui-ngx/src/app/modules/login/pages/login/login.component.ts
@@ -21,6 +21,9 @@ import { Store } from '@ngrx/store';
import { AppState } from '../../../../core/core.state';
import { PageComponent } from '../../../../shared/components/page.component';
import { FormBuilder } from '@angular/forms';
+import { HttpErrorResponse } from '@angular/common/http';
+import { Constants } from '@shared/models/constants';
+import { Router } from '@angular/router';
@Component({
selector: 'tb-login',
@@ -36,7 +39,8 @@ export class LoginComponent extends PageComponent implements OnInit {
constructor(protected store: Store,
private authService: AuthService,
- public fb: FormBuilder) {
+ public fb: FormBuilder,
+ private router: Router) {
super(store);
}
@@ -45,7 +49,16 @@ export class LoginComponent extends PageComponent implements OnInit {
login(): void {
if (this.loginFormGroup.valid) {
- this.authService.login(this.loginFormGroup.value).subscribe();
+ this.authService.login(this.loginFormGroup.value).subscribe(
+ () => {},
+ (error: HttpErrorResponse) => {
+ if (error && error.error && error.error.errorCode) {
+ if (error.error.errorCode === Constants.serverErrorCode.credentialsExpired) {
+ this.router.navigateByUrl(`login/resetExpiredPassword?resetToken=${error.error.resetToken}`);
+ }
+ }
+ }
+ );
} else {
Object.keys(this.loginFormGroup.controls).forEach(field => {
const control = this.loginFormGroup.get(field);
diff --git a/ui-ngx/src/app/modules/login/pages/login/reset-password.component.html b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.html
index 6e7ddcee49..828da1e21f 100644
--- a/ui-ngx/src/app/modules/login/pages/login/reset-password.component.html
+++ b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.html
@@ -20,6 +20,9 @@
login.password-reset
+
+ login.expired-password-reset-message
+
diff --git a/ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts
index 6d97bbe2dd..7ad658fcba 100644
--- a/ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts
+++ b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts
@@ -34,6 +34,8 @@ import { map } from 'rxjs/operators';
})
export class ResetPasswordComponent extends PageComponent implements OnInit, OnDestroy {
+ isExpiredPassword: boolean;
+
resetToken = '';
sub: Subscription;
@@ -51,6 +53,7 @@ export class ResetPasswordComponent extends PageComponent implements OnInit, OnD
}
ngOnInit() {
+ this.isExpiredPassword = this.route.snapshot.data.expiredPassword;
this.sub = this.route
.queryParams
.subscribe(params => {
diff --git a/ui-ngx/src/app/shared/components/nav-tree.component.html b/ui-ngx/src/app/shared/components/nav-tree.component.html
new file mode 100644
index 0000000000..88612965ff
--- /dev/null
+++ b/ui-ngx/src/app/shared/components/nav-tree.component.html
@@ -0,0 +1,18 @@
+
+
diff --git a/ui-ngx/src/app/shared/components/nav-tree.component.scss b/ui-ngx/src/app/shared/components/nav-tree.component.scss
new file mode 100644
index 0000000000..eef75026b0
--- /dev/null
+++ b/ui-ngx/src/app/shared/components/nav-tree.component.scss
@@ -0,0 +1,347 @@
+/**
+ * Copyright © 2016-2019 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+.tb-nav-tree-container {
+ padding: 15px;
+ font-family: Roboto, "Helvetica Neue", sans-serif;
+
+ &.jstree-proton {
+ .jstree-node,
+ .jstree-icon {
+ background-image: url("../../../assets/jstree/32px.png");
+ }
+
+ .jstree-last {
+ background: transparent;
+ }
+
+ .jstree-themeicon-custom {
+ background-image: none;
+
+ &.material-icons {
+ font-size: 18px;
+ }
+ }
+
+ .jstree-anchor {
+ font-size: 16px;
+ }
+ }
+
+ &.jstree-proton-small {
+ .jstree-node,
+ .jstree-icon {
+ background-image: url("../../../assets/jstree/32px.png");
+ }
+
+ .jstree-last {
+ background: transparent;
+ }
+
+ .jstree-themeicon-custom {
+ background-image: none;
+
+ &.material-icons {
+ font-size: 14px;
+ }
+ }
+
+ .jstree-anchor {
+ font-size: 14px;
+ }
+ }
+
+ &.jstree-proton-large {
+ .jstree-node,
+ .jstree-icon {
+ background-image: url("../../../assets/jstree/32px.png");
+ }
+
+ .jstree-last {
+ background: transparent;
+ }
+
+ .jstree-themeicon-custom {
+ background-image: none;
+
+ &.material-icons {
+ font-size: 24px;
+ }
+ }
+
+ .jstree-anchor {
+ font-size: 20px;
+ }
+ }
+
+ a {
+ border-bottom: none;
+
+ i.jstree-themeicon-custom {
+ &.tb-user-group {
+ &::before {
+ content: "account_circle";
+ }
+ }
+
+ &.tb-customer-group {
+ &::before {
+ content: "supervisor_account";
+ }
+ }
+
+ &.tb-asset-group {
+ &::before {
+ content: "domain";
+ }
+ }
+
+ &.tb-device-group {
+ &::before {
+ content: "devices_other";
+ }
+ }
+
+ &.tb-entity-view-group {
+ &::before {
+ content: "view_quilt";
+ }
+ }
+
+ &.tb-dashboard-group {
+ &::before {
+ content: "dashboard";
+ }
+ }
+
+ &.tb-customer {
+ &::before {
+ content: "supervisor_account";
+ }
+ }
+ }
+ }
+}
+
+@media (max-width: 768px) {
+ .tb-nav-tree-container {
+ &.jstree-proton-responsive {
+ .jstree-node,
+ .jstree-icon,
+ .jstree-node > .jstree-ocl,
+ .jstree-themeicon,
+ .jstree-checkbox {
+ background-image: url("../../../assets/jstree/40px.png");
+ background-size: 120px 240px;
+ }
+
+ .jstree-container-ul {
+ overflow: visible;
+ }
+
+ .jstree-themeicon-custom {
+ background-color: transparent;
+ background-image: none;
+ background-position: 0 0;
+
+ &.material-icons {
+ margin: 0;
+ font-size: 24px;
+ }
+ }
+
+ .jstree-node,
+ .jstree-leaf > .jstree-ocl {
+ background: 0 0;
+ }
+
+ .jstree-node {
+ min-width: 40px;
+ min-height: 40px;
+ margin-left: 40px;
+ line-height: 40px;
+ white-space: nowrap;
+ background-repeat: repeat-y;
+ background-position: -80px 0;
+ }
+
+ .jstree-last {
+ background: 0 0;
+ }
+
+ .jstree-anchor {
+ height: 40px;
+ font-size: 1.1em;
+ font-weight: 700;
+ line-height: 40px;
+ text-shadow: 1px 1px #fff;
+ }
+
+ .jstree-icon,
+ .jstree-icon:empty {
+ width: 40px;
+ height: 40px;
+ line-height: 40px;
+ }
+
+ > {
+ .jstree-container-ul > .jstree-node {
+ margin-right: 0;
+ margin-left: 0;
+ }
+ }
+
+ .jstree-ocl,
+ .jstree-themeicon,
+ .jstree-checkbox {
+ background-size: 120px 240px;
+ }
+
+ .jstree-leaf > .jstree-ocl {
+ background: 0 0;
+ background-position: -40px -120px;
+ }
+
+ .jstree-last > .jstree-ocl {
+ background-position: -40px -160px;
+ }
+
+ .jstree-open > .jstree-ocl {
+ background-position: 0 0 !important;
+ }
+
+ .jstree-closed > .jstree-ocl {
+ background-position: 0 -40px !important;
+ }
+
+ .jstree-themeicon {
+ background-position: -40px -40px;
+ }
+
+ .jstree-checkbox,
+ .jstree-checkbox:hover {
+ background-position: -40px -80px;
+ }
+
+ &.jstree-checkbox-selection {
+ .jstree-clicked > .jstree-checkbox,
+ .jstree-clicked > .jstree-checkbox:hover {
+ background-position: 0 -80px;
+ }
+ }
+
+ .jstree-checked > .jstree-checkbox,
+ .jstree-checked > .jstree-checkbox:hover {
+ background-position: 0 -80px;
+ }
+
+ .jstree-anchor > .jstree-undetermined,
+ .jstree-anchor > .jstree-undetermined:hover {
+ background-position: 0 -120px;
+ }
+
+ .jstree-striped {
+ background: 0 0;
+ }
+
+ .jstree-wholerow {
+ height: 40px;
+ background: #ebebeb;
+ border-top: 1px solid rgba(255, 255, 255, .7);
+ border-bottom: 1px solid rgba(64, 64, 64, .2);
+ }
+
+ .jstree-wholerow-hovered {
+ background: #e7f4f9;
+ }
+
+ .jstree-wholerow-clicked {
+ background: #beebff;
+ }
+
+ .jstree-children {
+ .jstree-last > .jstree-wholerow {
+ box-shadow: inset 0 -6px 3px -5px #666;
+ }
+
+ .jstree-open > .jstree-wholerow {
+ border-top: 0;
+ box-shadow: inset 0 6px 3px -5px #666;
+ }
+
+ .jstree-open + .jstree-open {
+ box-shadow: none;
+ }
+ }
+
+ &.jstree-rtl {
+ .jstree-node {
+ margin-right: 40px;
+ margin-left: 0;
+ }
+
+ .jstree-container-ul > .jstree-node {
+ margin-right: 0;
+ }
+
+ .jstree-closed > .jstree-ocl {
+ background-position: -40px 0 !important;
+ }
+ }
+ }
+ }
+}
+
+.tb-nav-tree .mat-button.tb-active {
+ font-weight: 500;
+ background-color: rgba(255, 255, 255, .15);
+}
+
+.tb-nav-tree,
+.tb-nav-tree ul {
+ margin-top: 0;
+ list-style: none;
+
+ &:first-child {
+ padding: 0;
+ }
+
+ li {
+ .mat-button {
+ width: 100%;
+ max-height: 40px;
+ padding: 0 16px;
+ margin: 0;
+ overflow: hidden;
+ line-height: 40px;
+ color: inherit;
+ text-align: left;
+ text-decoration: none;
+ text-overflow: ellipsis;
+ text-transform: none;
+ text-rendering: optimizeLegibility;
+ white-space: nowrap;
+ cursor: pointer;
+ border-radius: 0;
+
+ span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
+ }
+}
+
diff --git a/ui-ngx/src/app/shared/components/nav-tree.component.ts b/ui-ngx/src/app/shared/components/nav-tree.component.ts
new file mode 100644
index 0000000000..30f124731c
--- /dev/null
+++ b/ui-ngx/src/app/shared/components/nav-tree.component.ts
@@ -0,0 +1,272 @@
+///
+/// Copyright © 2016-2019 The Thingsboard Authors
+///
+/// Licensed under the Apache License, Version 2.0 (the "License");
+/// you may not use this file except in compliance with the License.
+/// You may obtain a copy of the License at
+///
+/// http://www.apache.org/licenses/LICENSE-2.0
+///
+/// Unless required by applicable law or agreed to in writing, software
+/// distributed under the License is distributed on an "AS IS" BASIS,
+/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+/// See the License for the specific language governing permissions and
+/// limitations under the License.
+///
+
+import { Component, ElementRef, Input, NgZone, OnInit, ViewEncapsulation } from '@angular/core';
+import { coerceBooleanProperty } from '@angular/cdk/coercion';
+import { deepClone } from '@core/utils';
+
+export interface NavTreeNodeState {
+ disabled?: boolean;
+ opened?: boolean;
+ loaded?: boolean;
+}
+
+export interface NavTreeNode {
+ id: string;
+ icon?: boolean;
+ text?: string;
+ state?: NavTreeNodeState;
+ children?: NavTreeNode[] | boolean;
+ data?: any;
+}
+
+export interface NavTreeEditCallbacks {
+ selectNode?: (id: string) => void;
+ deselectAll?: () => void;
+ getNode?: (id: string) => NavTreeNode;
+ getParentNodeId?: (id: string) => string;
+ openNode?: (id: string, cb?: () => void) => void;
+ nodeIsOpen?: (id: string) => boolean;
+ nodeIsLoaded?: (id: string) => boolean;
+ refreshNode?: (id: string) => void;
+ updateNode?: (id: string, newName: string) => void;
+ createNode?: (parentId: string, node: NavTreeNode, pos: number) => void;
+ deleteNode?: (id: string) => void;
+ disableNode?: (id: string) => void;
+ enableNode?: (id: string) => void;
+ setNodeHasChildren?: (id: string, hasChildren: boolean) => void;
+ search?: (searchText: string) => void;
+ clearSearch?: () => void;
+}
+
+export type NodesCallback = (nodes: NavTreeNode[]) => void;
+export type LoadNodesCallback = (node: NavTreeNode, cb: NodesCallback) => void;
+export type NodeSearchCallback = (searchText: string, node: NavTreeNode) => boolean;
+export type NodeSelectedCallback = (node: NavTreeNode, event: Event) => void;
+export type NodesInsertedCallback = (nodes: string[], parent: string) => void;
+
+@Component({
+ selector: 'tb-nav-tree',
+ templateUrl: './nav-tree.component.html',
+ styleUrls: ['./nav-tree.component.scss'],
+ encapsulation: ViewEncapsulation.None
+})
+export class NavTreeComponent implements OnInit {
+
+ private enableSearchValue: boolean;
+ get enableSearch(): boolean {
+ return this.enableSearchValue;
+ }
+ @Input()
+ set enableSearch(value: boolean) {
+ this.enableSearchValue = coerceBooleanProperty(value);
+ }
+
+ @Input()
+ private loadNodes: LoadNodesCallback;
+
+ @Input()
+ private searchCallback: NodeSearchCallback;
+
+ @Input()
+ private onNodeSelected: NodeSelectedCallback;
+
+ @Input()
+ private onNodesInserted: NodesInsertedCallback;
+
+ @Input()
+ private editCallbacks: NavTreeEditCallbacks;
+
+ private treeElement: JSTree;
+
+ constructor(private elementRef: ElementRef,
+ private ngZone: NgZone) {
+ }
+
+ ngOnInit(): void {
+ this.initTree();
+ }
+
+ private initTree() {
+
+ const loadNodes: LoadNodesCallback = (node, cb) => {
+ const outCb = (_nodes: NavTreeNode[]) => {
+ const copied: NavTreeNode[] = [];
+ if (_nodes) {
+ _nodes.forEach((n) => {
+ copied.push(deepClone(n, ['data']));
+ });
+ }
+ cb(copied);
+ };
+ this.ngZone.runOutsideAngular(() => {
+ this.loadNodes(node, outCb);
+ });
+ };
+
+ const config: JSTreeStaticDefaults = {
+ core: {
+ worker: false,
+ multiple: false,
+ check_callback: true,
+ themes: { name: 'proton', responsive: true },
+ data: loadNodes,
+ error: () => {
+ console.error('Unexpected jstree error!');
+ }
+ },
+ plugins: []
+ };
+
+ if (this.enableSearch) {
+ config.plugins.push('search');
+ config.search = {
+ ajax: false,
+ fuzzy: false,
+ close_opened_onclear: true,
+ case_sensitive: false,
+ show_only_matches: true,
+ show_only_matches_children: false,
+ search_leaves_only: false,
+ search_callback: this.searchCallback
+ };
+ }
+
+ this.treeElement = $('.tb-nav-tree-container', this.elementRef.nativeElement).jstree(config);
+
+ this.treeElement.on('changed.jstree', (e, data) => {
+ const node: NavTreeNode = data.instance.get_selected(true)[0];
+ if (this.onNodeSelected) {
+ this.onNodeSelected(node, e);
+ }
+ });
+
+ this.treeElement.on('model.jstree', (e, data) => {
+ if (this.onNodesInserted) {
+ this.onNodesInserted(data.nodes, data.parent);
+ }
+ });
+
+ if (this.editCallbacks) {
+ this.editCallbacks.selectNode = id => {
+ const node: NavTreeNode = this.treeElement.jstree('get_node', id);
+ if (node) {
+ this.treeElement.jstree('deselect_all', true);
+ this.treeElement.jstree('select_node', node);
+ }
+ };
+ this.editCallbacks.deselectAll = () => {
+ this.treeElement.jstree('deselect_all');
+ };
+ this.editCallbacks.getNode = (id) => {
+ const node: NavTreeNode = this.treeElement.jstree('get_node', id);
+ return node;
+ };
+ this.editCallbacks.getParentNodeId = (id) => {
+ const node: NavTreeNode = this.treeElement.jstree('get_node', id);
+ if (node) {
+ return this.treeElement.jstree('get_parent', node);
+ }
+ };
+ this.editCallbacks.openNode = (id, cb) => {
+ const node: NavTreeNode = this.treeElement.jstree('get_node', id);
+ if (node) {
+ this.treeElement.jstree('open_node', node, cb);
+ }
+ };
+ this.editCallbacks.nodeIsOpen = (id) => {
+ const node: NavTreeNode = this.treeElement.jstree('get_node', id);
+ if (node) {
+ return this.treeElement.jstree('is_open', node);
+ } else {
+ return true;
+ }
+ };
+ this.editCallbacks.nodeIsLoaded = (id) => {
+ const node: NavTreeNode = this.treeElement.jstree('get_node', id);
+ if (node) {
+ return this.treeElement.jstree('is_loaded', node);
+ } else {
+ return true;
+ }
+ };
+ this.editCallbacks.refreshNode = (id) => {
+ if (id === '#') {
+ this.treeElement.jstree('refresh');
+ this.treeElement.jstree('redraw');
+ } else {
+ const node: NavTreeNode = this.treeElement.jstree('get_node', id);
+ if (node) {
+ const opened = this.treeElement.jstree('is_open', node);
+ this.treeElement.jstree('refresh_node', node);
+ this.treeElement.jstree('redraw');
+ if (node.children && opened/* && !node.children.length*/) {
+ this.treeElement.jstree('open_node', node);
+ }
+ }
+ }
+ };
+ this.editCallbacks.updateNode = (id, newName) => {
+ const node: NavTreeNode = this.treeElement.jstree('get_node', id);
+ if (node) {
+ this.treeElement.jstree('rename_node', node, newName);
+ }
+ };
+ this.editCallbacks.createNode = (parentId, node, pos) => {
+ const parentNode: NavTreeNode = this.treeElement.jstree('get_node', parentId);
+ if (parentNode) {
+ this.treeElement.jstree('create_node', parentNode, node, pos);
+ }
+ };
+ this.editCallbacks.deleteNode = (id) => {
+ const node: NavTreeNode = this.treeElement.jstree('get_node', id);
+ if (node) {
+ this.treeElement.jstree('delete_node', node);
+ }
+ };
+ this.editCallbacks.disableNode = (id) => {
+ const node: NavTreeNode = this.treeElement.jstree('get_node', id);
+ if (node) {
+ this.treeElement.jstree('disable_node', node);
+ }
+ };
+ this.editCallbacks.enableNode = (id) => {
+ const node: NavTreeNode = this.treeElement.jstree('get_node', id);
+ if (node) {
+ this.treeElement.jstree('enable_node', node);
+ }
+ };
+ this.editCallbacks.setNodeHasChildren = (id, hasChildren) => {
+ const node: NavTreeNode = this.treeElement.jstree('get_node', id);
+ if (node) {
+ if (!node.children || (Array.isArray(node.children) && !node.children.length)) {
+ node.children = hasChildren;
+ node.state.loaded = !hasChildren;
+ node.state.opened = false;
+ this.treeElement.jstree('_node_changed', node.id);
+ this.treeElement.jstree('redraw');
+ }
+ }
+ };
+ this.editCallbacks.search = (searchText) => {
+ this.treeElement.jstree('search', searchText);
+ };
+ this.editCallbacks.clearSearch = () => {
+ this.treeElement.jstree('clear_search');
+ };
+ }
+ }
+}
diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts
index 0bbb06ce2b..22d4557f79 100644
--- a/ui-ngx/src/app/shared/models/constants.ts
+++ b/ui-ngx/src/app/shared/models/constants.ts
@@ -20,6 +20,7 @@ export const Constants = {
authentication: 10,
jwtTokenExpired: 11,
tenantTrialExpired: 12,
+ credentialsExpired: 15,
permissionDenied: 20,
invalidArguments: 30,
badRequestParams: 31,
diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts
index 1334836c67..b42298df15 100644
--- a/ui-ngx/src/app/shared/shared.module.ts
+++ b/ui-ngx/src/app/shared/shared.module.ts
@@ -120,6 +120,7 @@ import { JsonContentComponent } from './components/json-content.component';
import { KeyValMapComponent } from './components/kv-map.component';
import { TbCheatSheetComponent } from '@shared/components/cheatsheet.component';
import { TbHotkeysDirective } from '@shared/components/hotkeys.directive';
+import { NavTreeComponent } from '@shared/components/nav-tree.component';
@NgModule({
providers: [
@@ -198,6 +199,7 @@ import { TbHotkeysDirective } from '@shared/components/hotkeys.directive';
FileInputComponent,
MessageTypeAutocompleteComponent,
KeyValMapComponent,
+ NavTreeComponent,
NospacePipe,
MillisecondsToTimeStringPipe,
EnumToArrayPipe,
@@ -346,6 +348,7 @@ import { TbHotkeysDirective } from '@shared/components/hotkeys.directive';
FileInputComponent,
MessageTypeAutocompleteComponent,
KeyValMapComponent,
+ NavTreeComponent,
NospacePipe,
MillisecondsToTimeStringPipe,
EnumToArrayPipe,
diff --git a/ui-ngx/src/scss/mixins.scss b/ui-ngx/src/scss/mixins.scss
index 3b31b0e10c..07208636f9 100644
--- a/ui-ngx/src/scss/mixins.scss
+++ b/ui-ngx/src/scss/mixins.scss
@@ -21,12 +21,10 @@
min-height: #{$size}px;
font-size: #{$size}px;
line-height: #{$size}px;
-/* svg {
- width: 24px;
- height: 24px;
- transform: scale($size/24);
- transform-origin: top;
- }*/
+ svg {
+ width: #{$size}px;
+ height: #{$size}px;
+ }
}
@mixin tb-mat-icon-button-size($size) {
diff --git a/ui-ngx/src/tsconfig.app.json b/ui-ngx/src/tsconfig.app.json
index d32504308a..1332922f5b 100644
--- a/ui-ngx/src/tsconfig.app.json
+++ b/ui-ngx/src/tsconfig.app.json
@@ -2,7 +2,7 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/app",
- "types": ["node", "jquery", "flot", "tooltipster", "tinycolor2", "js-beautify", "react", "react-dom"]
+ "types": ["node", "jquery", "flot", "tooltipster", "tinycolor2", "js-beautify", "react", "react-dom", "jstree"]
},
"exclude": [
"test.ts",
diff --git a/ui-ngx/src/typings/jquery.jstree.typings.d.ts b/ui-ngx/src/typings/jquery.jstree.typings.d.ts
new file mode 100644
index 0000000000..a19c9bc3af
--- /dev/null
+++ b/ui-ngx/src/typings/jquery.jstree.typings.d.ts
@@ -0,0 +1,29 @@
+///
+/// Copyright © 2016-2019 The Thingsboard Authors
+///
+/// Licensed under the Apache License, Version 2.0 (the "License");
+/// you may not use this file except in compliance with the License.
+/// You may obtain a copy of the License at
+///
+/// http://www.apache.org/licenses/LICENSE-2.0
+///
+/// Unless required by applicable law or agreed to in writing, software
+/// distributed under the License is distributed on an "AS IS" BASIS,
+/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+/// See the License for the specific language governing permissions and
+/// limitations under the License.
+///
+
+interface JSTreeEventData {
+ instance: JSTree;
+}
+
+interface JSTreeModelEventData extends JSTreeEventData {
+ nodes: string[];
+ parent: string;
+}
+
+interface JQuery {
+ on(events: 'changed.jstree', handler: (e: Event, data: JSTreeEventData) => void): this;
+ on(events: 'model.jstree', handler: (e: Event, data: JSTreeModelEventData) => void): this;
+}
diff --git a/ui-ngx/tsconfig.json b/ui-ngx/tsconfig.json
index 02b121d540..911c6a31dc 100644
--- a/ui-ngx/tsconfig.json
+++ b/ui-ngx/tsconfig.json
@@ -18,6 +18,7 @@
"src/typings/rawloader.typings.d.ts",
"src/typings/jquery.typings.d.ts",
"src/typings/jquery.flot.typings.d.ts",
+ "src/typings/jquery.jstree.typings.d.ts",
"src/typings/split.js.typings.d.ts"
],
"paths": {