26 changed files with 1594 additions and 26 deletions
File diff suppressed because one or more lines are too long
@ -0,0 +1,51 @@ |
|||||
|
<!-- |
||||
|
|
||||
|
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. |
||||
|
|
||||
|
--> |
||||
|
<div class="tb-entities-hierarchy tb-absolute-fill" tb-toast toastTarget="{{ toastTargetId }}"> |
||||
|
<div fxFlex fxLayout="column" class="tb-absolute-fill"> |
||||
|
<mat-toolbar class="mat-table-toolbar" [fxShow]="textSearchMode"> |
||||
|
<div class="mat-toolbar-tools"> |
||||
|
<button mat-button mat-icon-button |
||||
|
matTooltip="{{ 'action.search' | translate }}" |
||||
|
matTooltipPosition="above"> |
||||
|
<mat-icon>search</mat-icon> |
||||
|
</button> |
||||
|
<mat-form-field fxFlex> |
||||
|
<mat-label> </mat-label> |
||||
|
<input #searchInput matInput |
||||
|
[(ngModel)]="textSearch" |
||||
|
placeholder="{{ 'entity.search' | translate }}"/> |
||||
|
</mat-form-field> |
||||
|
<button mat-button mat-icon-button (click)="exitFilterMode()" |
||||
|
matTooltip="{{ 'action.close' | translate }}" |
||||
|
matTooltipPosition="above"> |
||||
|
<mat-icon>close</mat-icon> |
||||
|
</button> |
||||
|
</div> |
||||
|
</mat-toolbar> |
||||
|
<div fxFlex class="tb-entities-nav-tree-panel"> |
||||
|
<tb-nav-tree |
||||
|
[loadNodes]="loadNodes" |
||||
|
[onNodeSelected]="onNodeSelected" |
||||
|
[onNodesInserted]="onNodesInserted" |
||||
|
[editCallbacks]="nodeEditCallbacks" |
||||
|
enableSearch="true" |
||||
|
[searchCallback]="searchCallback" |
||||
|
></tb-nav-tree> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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<HierarchyNodeDatasource>; |
||||
|
|
||||
|
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<AppState>, |
||||
|
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<HierarchyNodeDatasource>; |
||||
|
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<DatasourceData>) { |
||||
|
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<HierarchyNavTreeNode>[] = []; |
||||
|
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<HierarchyNavTreeNode>[] = []; |
||||
|
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<HierarchyNavTreeNode> { |
||||
|
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<HierarchyNavTreeNode> { |
||||
|
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<BaseData<EntityId>> { |
||||
|
if (datasource.type === DatasourceType.function) { |
||||
|
const entity = { |
||||
|
id: { |
||||
|
entityType: 'function' |
||||
|
}, |
||||
|
name: datasource.name |
||||
|
}; |
||||
|
return of(entity as BaseData<EntityId>); |
||||
|
} 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; |
||||
|
} |
||||
|
} |
||||
@ -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<EntityId>; |
||||
|
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<F extends Function>(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 '<mat-icon class="node-icon material-icons" role="img" aria-hidden="false">'+materialIcon+'</mat-icon>'; |
||||
|
} |
||||
|
|
||||
|
export function iconUrlHtml(iconUrl: string): string { |
||||
|
return '<div class="node-icon" style="background-image: url('+iconUrl+');"> </div>'; |
||||
|
} |
||||
|
|
||||
|
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; |
||||
|
}; |
||||
@ -0,0 +1,18 @@ |
|||||
|
<!-- |
||||
|
|
||||
|
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. |
||||
|
|
||||
|
--> |
||||
|
<div class="tb-nav-tree-container"></div> |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
@ -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<HTMLElement>, |
||||
|
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'); |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
Loading…
Reference in new issue