39 changed files with 1724 additions and 247 deletions
@ -0,0 +1,21 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2021 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. |
|||
|
|||
--> |
|||
<ng-container #markdownContainer> |
|||
<markdown *ngIf="!isMarkdownReady" #markdownComponent [data]="data" |
|||
[lineNumbers]="lineNumbers" (ready)="onMarkdownReady()"></markdown> |
|||
</ng-container> |
|||
@ -0,0 +1,20 @@ |
|||
/** |
|||
* Copyright © 2016-2021 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 { |
|||
markdown { |
|||
display: none; |
|||
} |
|||
} |
|||
@ -0,0 +1,154 @@ |
|||
///
|
|||
/// Copyright © 2016-2021 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, |
|||
ComponentFactory, |
|||
ComponentRef, |
|||
EventEmitter, |
|||
Inject, |
|||
Injector, |
|||
Input, OnChanges, |
|||
OnDestroy, |
|||
OnInit, |
|||
Output, |
|||
Renderer2, SimpleChanges, |
|||
Type, |
|||
ViewChild, |
|||
ViewContainerRef |
|||
} from '@angular/core'; |
|||
import { HelpService } from '@core/services/help.service'; |
|||
import { MarkdownComponent } from 'ngx-markdown'; |
|||
import { DynamicComponentFactoryService } from '@core/services/dynamic-component-factory.service'; |
|||
import { coerceBooleanProperty } from '@angular/cdk/coercion'; |
|||
import { SHARED_MODULE_TOKEN } from '@shared/components/tokens'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-markdown', |
|||
templateUrl: './markdown.component.html', |
|||
styleUrls: ['./markdown.component.scss'] |
|||
}) |
|||
export class TbMarkdownComponent implements OnDestroy, OnInit, OnChanges { |
|||
|
|||
private markdownComponent: MarkdownComponent; |
|||
|
|||
@ViewChild('markdownContainer', {read: ViewContainerRef, static: true}) markdownContainer: ViewContainerRef; |
|||
|
|||
@ViewChild('markdownComponent', {static: false}) set content(content: MarkdownComponent) { |
|||
this.markdownComponent = content; |
|||
if (this.isMarkdownReady && this.markdownComponent) { |
|||
this.processMarkdownComponent(); |
|||
} |
|||
} |
|||
|
|||
@Input() data: string | undefined; |
|||
|
|||
@Input() markdownClass: string | undefined; |
|||
|
|||
@Input() style: { [klass: string]: any } = {}; |
|||
|
|||
@Input() |
|||
get lineNumbers(): boolean { return this.lineNumbersValue; } |
|||
set lineNumbers(value: boolean) { this.lineNumbersValue = coerceBooleanProperty(value); } |
|||
|
|||
@Output() ready = new EventEmitter<void>(); |
|||
|
|||
private lineNumbersValue = false; |
|||
|
|||
isMarkdownReady = false; |
|||
|
|||
private tbMarkdownInstanceComponentRef: ComponentRef<any>; |
|||
private tbMarkdownInstanceComponentFactory: ComponentFactory<any>; |
|||
|
|||
constructor(private help: HelpService, |
|||
private renderer: Renderer2, |
|||
@Inject(SHARED_MODULE_TOKEN) private sharedModule: Type<any>, |
|||
private dynamicComponentFactoryService: DynamicComponentFactoryService) {} |
|||
|
|||
ngOnInit(): void { |
|||
} |
|||
|
|||
ngOnDestroy(): void { |
|||
} |
|||
|
|||
ngOnChanges(changes: SimpleChanges): void { |
|||
for (const propName of Object.keys(changes)) { |
|||
const change = changes[propName]; |
|||
if (!change.firstChange && change.currentValue !== change.previousValue) { |
|||
if (propName === 'data') { |
|||
if (this.data) { |
|||
this.isMarkdownReady = false; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
private processMarkdownComponent() { |
|||
let template = this.markdownComponent.element.nativeElement.innerHTML; |
|||
template = this.sanitizeCurlyBraces(template); |
|||
let markdownClass = 'tb-markdown-view'; |
|||
if (this.markdownClass) { |
|||
markdownClass += ` ${this.markdownClass}`; |
|||
} |
|||
template = `<div [ngStyle]="style" class="${markdownClass}">${template}</div>`; |
|||
this.markdownContainer.clear(); |
|||
this.markdownComponent = null; |
|||
const parent = this; |
|||
this.dynamicComponentFactoryService.createDynamicComponentFactory( |
|||
class TbMarkdownInstance { |
|||
ngOnDestroy(): void { |
|||
parent.destroyMarkdownInstanceResources(); |
|||
} |
|||
}, |
|||
template, |
|||
[this.sharedModule], |
|||
true |
|||
).subscribe((factory) => { |
|||
this.tbMarkdownInstanceComponentFactory = factory; |
|||
const injector: Injector = Injector.create({providers: [], parent: this.markdownContainer.injector}); |
|||
try { |
|||
this.tbMarkdownInstanceComponentRef = |
|||
this.markdownContainer.createComponent(this.tbMarkdownInstanceComponentFactory, 0, injector); |
|||
this.tbMarkdownInstanceComponentRef.instance.style = this.style; |
|||
} catch (e) { |
|||
this.destroyMarkdownInstanceResources(); |
|||
} |
|||
this.ready.emit(); |
|||
}); |
|||
} |
|||
|
|||
private sanitizeCurlyBraces(template: string): string { |
|||
return template.replace(/{/g, '{').replace(/}/g, '}'); |
|||
} |
|||
|
|||
private destroyMarkdownInstanceResources() { |
|||
if (this.tbMarkdownInstanceComponentFactory) { |
|||
this.dynamicComponentFactoryService.destroyDynamicComponentFactory(this.tbMarkdownInstanceComponentFactory); |
|||
this.tbMarkdownInstanceComponentFactory = null; |
|||
} |
|||
this.tbMarkdownInstanceComponentRef = null; |
|||
} |
|||
|
|||
onMarkdownReady() { |
|||
if (this.data) { |
|||
this.isMarkdownReady = true; |
|||
if (this.markdownComponent) { |
|||
this.processMarkdownComponent(); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,190 @@ |
|||
///
|
|||
/// Copyright © 2016-2021 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 { |
|||
ComponentFactory, |
|||
ComponentFactoryResolver, ElementRef, Inject, |
|||
Injectable, Injector, |
|||
Renderer2, |
|||
Type, |
|||
ViewContainerRef |
|||
} from '@angular/core'; |
|||
import { PopoverPlacement, PopoverWithTrigger } from '@shared/components/popover.models'; |
|||
import { TbPopoverComponent } from '@shared/components/popover.component'; |
|||
import { ComponentType } from '@angular/cdk/portal'; |
|||
import { HELP_MARKDOWN_COMPONENT_TOKEN } from '@shared/components/tokens'; |
|||
|
|||
@Injectable() |
|||
export class TbPopoverService { |
|||
|
|||
private popoverWithTriggers: PopoverWithTrigger[] = []; |
|||
|
|||
componentFactory: ComponentFactory<TbPopoverComponent> = this.resolver.resolveComponentFactory(TbPopoverComponent); |
|||
|
|||
constructor(private resolver: ComponentFactoryResolver, |
|||
@Inject(HELP_MARKDOWN_COMPONENT_TOKEN) private helpMarkdownComponent: ComponentType<any>) { |
|||
} |
|||
|
|||
hasPopover(trigger: Element): boolean { |
|||
const res = this.findPopoverByTrigger(trigger); |
|||
return res !== null; |
|||
} |
|||
|
|||
hidePopover(trigger: Element): boolean { |
|||
const component: TbPopoverComponent = this.findPopoverByTrigger(trigger); |
|||
if (component && component.tbVisible) { |
|||
component.hide(); |
|||
return true; |
|||
} else { |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
displayPopover<T>(trigger: Element, renderer: Renderer2, hostView: ViewContainerRef, |
|||
componentType: Type<T>, preferredPlacement: PopoverPlacement = 'top', hideOnClickOutside = true, |
|||
injector?: Injector, context?: any, overlayStyle: any = {}, popoverStyle: any = {}, style?: any): TbPopoverComponent { |
|||
const componentRef = hostView.createComponent(this.componentFactory); |
|||
const component = componentRef.instance; |
|||
this.popoverWithTriggers.push({ |
|||
trigger, |
|||
popoverComponent: component |
|||
}); |
|||
renderer.removeChild( |
|||
renderer.parentNode(trigger), |
|||
componentRef.location.nativeElement |
|||
); |
|||
const originElementRef = new ElementRef(trigger); |
|||
component.setOverlayOrigin({ elementRef: originElementRef }); |
|||
component.tbPlacement = preferredPlacement; |
|||
component.tbComponentFactory = this.resolver.resolveComponentFactory(componentType); |
|||
component.tbComponentInjector = injector; |
|||
component.tbComponentContext = context; |
|||
component.tbOverlayStyle = overlayStyle; |
|||
component.tbPopoverInnerStyle = popoverStyle; |
|||
component.tbComponentStyle = style; |
|||
component.tbHideOnClickOutside = hideOnClickOutside; |
|||
component.tbVisibleChange.subscribe((visible: boolean) => { |
|||
if (!visible) { |
|||
component.tbAnimationDone.subscribe(() => { |
|||
componentRef.destroy(); |
|||
}); |
|||
} |
|||
}); |
|||
component.tbDestroy.subscribe(() => { |
|||
this.removePopoverByComponent(component); |
|||
}); |
|||
component.show(); |
|||
return component; |
|||
} |
|||
|
|||
toggleHelpPopover(trigger: Element, renderer: Renderer2, hostView: ViewContainerRef, helpId = '', |
|||
helpContent = '', |
|||
visibleFn: (visible: boolean) => void = () => {}, |
|||
readyFn: (ready: boolean) => void = () => {}, |
|||
preferredPlacement: PopoverPlacement = 'bottom', |
|||
overlayStyle: any = {}, helpStyle: any = {}) { |
|||
if (this.hasPopover(trigger)) { |
|||
this.hidePopover(trigger); |
|||
} else { |
|||
readyFn(false); |
|||
const injector = Injector.create({ |
|||
parent: hostView.injector, providers: [] |
|||
}); |
|||
const componentRef = hostView.createComponent(this.componentFactory); |
|||
const component = componentRef.instance; |
|||
this.popoverWithTriggers.push({ |
|||
trigger, |
|||
popoverComponent: component |
|||
}); |
|||
renderer.removeChild( |
|||
renderer.parentNode(trigger), |
|||
componentRef.location.nativeElement |
|||
); |
|||
const originElementRef = new ElementRef(trigger); |
|||
component.tbAnimationState = 'void'; |
|||
component.tbOverlayStyle = {...overlayStyle, opacity: '0' }; |
|||
component.setOverlayOrigin({ elementRef: originElementRef }); |
|||
component.tbPlacement = preferredPlacement; |
|||
component.tbComponentFactory = this.resolver.resolveComponentFactory(this.helpMarkdownComponent); |
|||
component.tbComponentInjector = injector; |
|||
component.tbComponentContext = { |
|||
helpId, |
|||
helpContent, |
|||
style: helpStyle, |
|||
visible: true |
|||
}; |
|||
component.tbHideOnClickOutside = true; |
|||
component.tbVisibleChange.subscribe((visible: boolean) => { |
|||
if (!visible) { |
|||
visibleFn(false); |
|||
component.tbAnimationDone.subscribe(() => { |
|||
componentRef.destroy(); |
|||
}); |
|||
} |
|||
}); |
|||
component.tbDestroy.subscribe(() => { |
|||
this.removePopoverByComponent(component); |
|||
}); |
|||
const showHelpMarkdownComponent = () => { |
|||
component.tbOverlayStyle = {...component.tbOverlayStyle, opacity: '1' }; |
|||
component.tbAnimationState = 'active'; |
|||
component.updatePosition(); |
|||
readyFn(true); |
|||
setTimeout(() => { |
|||
component.updatePosition(); |
|||
}); |
|||
}; |
|||
const setupHelpMarkdownComponent = (helpMarkdownComponent: any) => { |
|||
if (helpMarkdownComponent.isMarkdownReady) { |
|||
showHelpMarkdownComponent(); |
|||
} else { |
|||
helpMarkdownComponent.markdownReady.subscribe(() => { |
|||
showHelpMarkdownComponent(); |
|||
}); |
|||
} |
|||
}; |
|||
if (component.tbComponentRef) { |
|||
setupHelpMarkdownComponent(component.tbComponentRef.instance); |
|||
} else { |
|||
component.tbComponentChange.subscribe((helpMarkdownComponentRef) => { |
|||
setupHelpMarkdownComponent(helpMarkdownComponentRef.instance); |
|||
}); |
|||
} |
|||
component.show(); |
|||
visibleFn(true); |
|||
} |
|||
} |
|||
|
|||
private findPopoverByTrigger(trigger: Element): TbPopoverComponent | null { |
|||
const res = this.popoverWithTriggers.find(val => this.elementsAreEqualOrDescendant(trigger, val.trigger)); |
|||
if (res) { |
|||
return res.popoverComponent; |
|||
} else { |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
private removePopoverByComponent(component: TbPopoverComponent): void { |
|||
const index = this.popoverWithTriggers.findIndex(val => val.popoverComponent === component); |
|||
if (index > -1) { |
|||
this.popoverWithTriggers.splice(index, 1); |
|||
} |
|||
} |
|||
|
|||
private elementsAreEqualOrDescendant(element1: Element, element2: Element): boolean { |
|||
return element1 === element2 || element1.contains(element2) || element2.contains(element1); |
|||
} |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
///
|
|||
/// Copyright © 2016-2021 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 { InjectionToken, Type } from '@angular/core'; |
|||
import { ComponentType } from '@angular/cdk/portal'; |
|||
|
|||
export const HELP_MARKDOWN_COMPONENT_TOKEN: InjectionToken<ComponentType<any>> = |
|||
new InjectionToken<ComponentType<any>>('HELP_MARKDOWN_COMPONENT_TOKEN'); |
|||
|
|||
export const SHARED_MODULE_TOKEN: InjectionToken<Type<any>> = |
|||
new InjectionToken<Type<any>>('HELP_MARKDOWN_COMPONENT_TOKEN'); |
|||
@ -0,0 +1,92 @@ |
|||
#### Custom action function |
|||
|
|||
<div class="divider"></div> |
|||
<br/> |
|||
|
|||
*function ($event, widgetContext, entityId, entityName, additionalParams, entityLabel): void* |
|||
|
|||
A JavaScript function performing custom action. |
|||
|
|||
**Parameters:** |
|||
|
|||
<ul> |
|||
<li><b>$event:</b> <code><a href="https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent" target="_blank">MouseEvent</a></code> - The <a href="https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent" target="_blank">MouseEvent</a> object. Usually a result of a mouse click event. |
|||
</li> |
|||
<li><b>widgetContext:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/models/widget-component.models.ts#L107" target="_blank">WidgetContext</a></code> - A reference to <a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/models/widget-component.models.ts#L107" target="_blank">WidgetContext</a> that has all necessary API |
|||
and data used by widget instance. |
|||
</li> |
|||
<li><b>entityId:</b> <code>string</code> - An optional string id of the target entity. |
|||
</li> |
|||
<li><b>entityName:</b> <code>string</code> - An optional string name of the target entity. |
|||
</li> |
|||
{% include widget/action/custom_additional_params %} |
|||
<li><b>entityLabel:</b> <code>string</code> - An optional string label of the target entity. |
|||
</li> |
|||
</ul> |
|||
|
|||
<div class="divider"></div> |
|||
|
|||
##### Examples |
|||
|
|||
* Display alert dialog with entity information: |
|||
|
|||
```javascript |
|||
var title; |
|||
var content; |
|||
if (entityName) { |
|||
title = entityName + ' details'; |
|||
content = '<b>Entity name</b>: ' + entityName; |
|||
if (additionalParams && additionalParams.entity) { |
|||
var entity = additionalParams.entity; |
|||
if (entity.id) { |
|||
content += '<br><b>Entity type</b>: ' + entity.id.entityType; |
|||
} |
|||
if (!isNaN(entity.temperature) && entity.temperature !== '') { |
|||
content += '<br><b>Temperature</b>: ' + entity.temperature + ' °C'; |
|||
} |
|||
} |
|||
} else { |
|||
title = 'No entity information available'; |
|||
content = '<b>No entity information available</b>'; |
|||
} |
|||
|
|||
showAlertDialog(title, content); |
|||
|
|||
function showAlertDialog(title, content) { |
|||
setTimeout(function() { |
|||
widgetContext.dialogs.alert(title, content).subscribe(); |
|||
}, 100); |
|||
} |
|||
{:copy-code} |
|||
``` |
|||
|
|||
* Delete device after confirmation: |
|||
|
|||
```javascript |
|||
var $injector = widgetContext.$scope.$injector; |
|||
var dialogs = $injector.get(widgetContext.servicesMap.get('dialogs')); |
|||
var deviceService = $injector.get(widgetContext.servicesMap.get('deviceService')); |
|||
|
|||
openDeleteDeviceDialog(); |
|||
|
|||
function openDeleteDeviceDialog() { |
|||
var title = 'Are you sure you want to delete the device ' + entityName + '?'; |
|||
var content = 'Be careful, after the confirmation, the device and all related data will become unrecoverable!'; |
|||
dialogs.confirm(title, content, 'Cancel', 'Delete').subscribe( |
|||
function(result) { |
|||
if (result) { |
|||
deleteDevice(); |
|||
} |
|||
} |
|||
); |
|||
} |
|||
|
|||
function deleteDevice() { |
|||
deviceService.deleteDevice(entityId.id).subscribe( |
|||
function() { |
|||
widgetContext.updateAliases(); |
|||
} |
|||
); |
|||
} |
|||
{:copy-code} |
|||
``` |
|||
@ -0,0 +1,46 @@ |
|||
<li><b>additionalParams:</b> <code>{[key: string]: any}</code> - An optional key/value object holding additional entity parameters depending on widget type and action source: |
|||
<ul> |
|||
<li>Entities table widget (<i>On row click</i> or <i>Action cell button</i>) - <b>additionalParams:</b> <code>{ entity: <a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts#L61" target="_blank">EntityData</a> }</code>: |
|||
<ul> |
|||
<li><b>entity:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts#L61" target="_blank">EntityData</a></code> - An |
|||
<a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts#L61" target="_blank">EntityData</a> object |
|||
presenting basic entity properties (ex. <code>id</code>, <code>entityName</code>) and <br> provides access to other entity attributes/timeseries declared in widget datasource configuration. |
|||
</li> |
|||
</ul> |
|||
</li> |
|||
<li>Alarms table widget (<i>On row click</i> or <i>Action cell button</i>) - <b>additionalParams:</b> <code>{ alarm: <a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/shared/models/alarm.models.ts#L108" target="_blank">AlarmDataInfo</a> }</code>: |
|||
<ul> |
|||
<li><b>alarm:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/shared/models/alarm.models.ts#L108" target="_blank">AlarmDataInfo</a></code> - An |
|||
<a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/shared/models/alarm.models.ts#L108" target="_blank">AlarmDataInfo</a> object |
|||
presenting basic alarm properties (ex. <code>type</code>, <code>severity</code>, <code>originator</code>, etc.) and <br> provides access to other alarm or originator entity fields/attributes/timeseries declared in widget datasource configuration. |
|||
</li> |
|||
</ul> |
|||
</li> |
|||
<li>Timeseries table widget (<i>On row click</i> or <i>Action cell button</i>) - <b>additionalParams:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts#L80" target="_blank">TimeseriesRow</a></code>: |
|||
<ul> |
|||
<li><b>additionalParams:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts#L80" target="_blank">TimeseriesRow</a></code> - A |
|||
<a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts#L80" target="_blank">TimeseriesRow</a> object |
|||
presenting <code>formattedTs</code> (a string value of formatted timestamp) and <br> timeseries values for each column declared in widget datasource configuration. |
|||
</li> |
|||
</ul> |
|||
</li> |
|||
<li>Entities hierarchy widget (<i>On node selected</i>) - <b>additionalParams:</b> <code>{ nodeCtx: <a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.models.ts#L35" target="_blank">HierarchyNodeContext</a> }</code>: |
|||
<ul> |
|||
<li><b>nodeCtx:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.models.ts#L35" target="_blank">HierarchyNodeContext</a></code> - An |
|||
<a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/modules/home/components/widget/lib/entities-hierarchy-widget.models.ts#L35" target="_blank">HierarchyNodeContext</a> object |
|||
containing <code>entity</code> field holding basic entity properties <br> (ex. <code>id</code>, <code>name</code>, <code>label</code>) and <code>data</code> field holding other entity attributes/timeseries declared in widget datasource configuration. |
|||
</li> |
|||
</ul> |
|||
</li> |
|||
<li>Pie - Flot widget (<i>On slice click</i>) - <b>additionalParams:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.models.ts#L62" target="_blank">TbFlotPlotItem</a></code>: |
|||
<ul> |
|||
<li><b>additionalParams:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.models.ts#L62" target="_blank">TbFlotPlotItem</a></code> - A |
|||
<a href="https://github.com/thingsboard/thingsboard/blob/e264f7b8ddff05bda85c4833bf497f47f447496e/ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.models.ts#L62" target="_blank">TbFlotPlotItem</a> object |
|||
containing <code>series</code> field with information about datasource and <br> data key of clicked pie slice. |
|||
</li> |
|||
</ul> |
|||
</li> |
|||
<li><i>All other widgets</i> - does not provide <b>additionalParams</b> value. |
|||
</li> |
|||
</ul> |
|||
</li> |
|||
@ -0,0 +1,74 @@ |
|||
#### Custom action (with HTML template) function |
|||
|
|||
<div class="divider"></div> |
|||
<br/> |
|||
|
|||
*function ($event, widgetContext, entityId, entityName, htmlTemplate, additionalParams, entityLabel): void* |
|||
|
|||
A JavaScript function performing custom action with defined HTML template to render dialog. |
|||
|
|||
**Parameters:** |
|||
|
|||
<ul> |
|||
<li><b>$event:</b> <code><a href="https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent" target="_blank">MouseEvent</a></code> - The <a href="https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent" target="_blank">MouseEvent</a> object. Usually a result of a mouse click event. |
|||
</li> |
|||
<li><b>widgetContext:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/models/widget-component.models.ts#L107" target="_blank">WidgetContext</a></code> - A reference to <a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/models/widget-component.models.ts#L107" target="_blank">WidgetContext</a> that has all necessary API |
|||
and data used by widget instance. |
|||
</li> |
|||
<li><b>entityId:</b> <code>string</code> - An optional string id of the target entity. |
|||
</li> |
|||
<li><b>entityName:</b> <code>string</code> - An optional string name of the target entity. |
|||
</li> |
|||
<li><b>htmlTemplate:</b> <code>string</code> - An optional HTML template string defined in <b>HTML</b> tab.<br/> Used to render custom dialog (see <b>Examples</b> for more details). |
|||
</li> |
|||
{% include widget/action/custom_additional_params %} |
|||
<li><b>entityLabel:</b> <code>string</code> - An optional string label of the target entity. |
|||
</li> |
|||
</ul> |
|||
|
|||
<div class="divider"></div> |
|||
|
|||
##### Examples |
|||
|
|||
###### Display dialog to create a device or an asset |
|||
|
|||
<br> |
|||
|
|||
<div style="padding-left: 64px;" |
|||
tb-help-popup="widget/action/examples/custom_pretty_create_dialog_js" |
|||
tb-help-popup-placement="top" |
|||
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}" |
|||
trigger-text="JavaScript function"> |
|||
</div> |
|||
|
|||
<br> |
|||
|
|||
<div style="padding-left: 64px;" |
|||
tb-help-popup="widget/action/examples/custom_pretty_create_dialog_html" |
|||
tb-help-popup-placement="top" |
|||
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}" |
|||
trigger-text="HTML code"> |
|||
</div> |
|||
|
|||
###### Display dialog to edit a device or an asset |
|||
|
|||
<br> |
|||
|
|||
<div style="padding-left: 64px;" |
|||
tb-help-popup="widget/action/examples/custom_pretty_edit_dialog_js" |
|||
tb-help-popup-placement="top" |
|||
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}" |
|||
trigger-text="JavaScript function"> |
|||
</div> |
|||
|
|||
<br> |
|||
|
|||
<div style="padding-left: 64px;" |
|||
tb-help-popup="widget/action/examples/custom_pretty_edit_dialog_html" |
|||
tb-help-popup-placement="top" |
|||
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}" |
|||
trigger-text="HTML code"> |
|||
</div> |
|||
|
|||
<br> |
|||
<br> |
|||
@ -0,0 +1,160 @@ |
|||
#### HTML template of dialog to create a device or an asset |
|||
|
|||
```html |
|||
<form #addEntityForm="ngForm" [formGroup]="addEntityFormGroup" |
|||
(ngSubmit)="save()" class="add-entity-form"> |
|||
<mat-toolbar fxLayout="row" color="primary"> |
|||
<h2>Add entity</h2> |
|||
<span fxFlex></span> |
|||
<button mat-icon-button (click)="cancel()" type="button"> |
|||
<mat-icon class="material-icons">close</mat-icon> |
|||
</button> |
|||
</mat-toolbar> |
|||
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async"> |
|||
</mat-progress-bar> |
|||
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div> |
|||
<div mat-dialog-content fxLayout="column"> |
|||
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0"> |
|||
<mat-form-field fxFlex class="mat-block"> |
|||
<mat-label>Entity Name</mat-label> |
|||
<input matInput formControlName="entityName" required> |
|||
<mat-error *ngIf="addEntityFormGroup.get('entityName').hasError('required')"> |
|||
Entity name is required. |
|||
</mat-error> |
|||
</mat-form-field> |
|||
<mat-form-field fxFlex class="mat-block"> |
|||
<mat-label>Entity Label</mat-label> |
|||
<input matInput formControlName="entityLabel" > |
|||
</mat-form-field> |
|||
</div> |
|||
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0"> |
|||
<tb-entity-type-select |
|||
class="mat-block" |
|||
formControlName="entityType" |
|||
[showLabel]="true" |
|||
[allowedEntityTypes]="allowedEntityTypes" |
|||
></tb-entity-type-select> |
|||
<tb-entity-subtype-autocomplete |
|||
fxFlex *ngIf="addEntityFormGroup.get('entityType').value == 'ASSET'" |
|||
class="mat-block" |
|||
formControlName="type" |
|||
[required]="true" |
|||
[entityType]="'ASSET'" |
|||
></tb-entity-subtype-autocomplete> |
|||
<tb-entity-subtype-autocomplete |
|||
fxFlex *ngIf="addEntityFormGroup.get('entityType').value != 'ASSET'" |
|||
class="mat-block" |
|||
formControlName="type" |
|||
[required]="true" |
|||
[entityType]="'DEVICE'" |
|||
></tb-entity-subtype-autocomplete> |
|||
</div> |
|||
<div formGroupName="attributes" fxLayout="column"> |
|||
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0"> |
|||
<mat-form-field fxFlex class="mat-block"> |
|||
<mat-label>Latitude</mat-label> |
|||
<input type="number" step="any" matInput formControlName="latitude"> |
|||
</mat-form-field> |
|||
<mat-form-field fxFlex class="mat-block"> |
|||
<mat-label>Longitude</mat-label> |
|||
<input type="number" step="any" matInput formControlName="longitude"> |
|||
</mat-form-field> |
|||
</div> |
|||
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0"> |
|||
<mat-form-field fxFlex class="mat-block"> |
|||
<mat-label>Address</mat-label> |
|||
<input matInput formControlName="address"> |
|||
</mat-form-field> |
|||
<mat-form-field fxFlex class="mat-block"> |
|||
<mat-label>Owner</mat-label> |
|||
<input matInput formControlName="owner"> |
|||
</mat-form-field> |
|||
</div> |
|||
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0"> |
|||
<mat-form-field fxFlex class="mat-block"> |
|||
<mat-label>Integer Value</mat-label> |
|||
<input type="number" step="1" matInput formControlName="number"> |
|||
<mat-error *ngIf="addEntityFormGroup.get('attributes.number').hasError('pattern')"> |
|||
Invalid integer value. |
|||
</mat-error> |
|||
</mat-form-field> |
|||
<div class="boolean-value-input" fxLayout="column" fxLayoutAlign="center start" fxFlex> |
|||
<label class="checkbox-label">Boolean Value</label> |
|||
<mat-checkbox formControlName="booleanValue" style="margin-bottom: 40px;"> |
|||
|
|||
</mat-checkbox> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="relations-list"> |
|||
<div class="mat-body-1" style="padding-bottom: 10px; color: rgba(0,0,0,0.57);">Relations</div> |
|||
<div class="body" [fxShow]="relations().length"> |
|||
<div class="row" fxLayout="row" fxLayoutAlign="start center" formArrayName="relations" *ngFor="let relation of relations().controls; let i = index;"> |
|||
<div [formGroupName]="i" class="mat-elevation-z2" fxFlex fxLayout="row" style="padding: 5px 0 5px 5px;"> |
|||
<div fxFlex fxLayout="column"> |
|||
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0"> |
|||
<mat-form-field class="mat-block" style="min-width: 100px;"> |
|||
<mat-label>Direction</mat-label> |
|||
<mat-select formControlName="direction" name="direction"> |
|||
<mat-option *ngFor="let direction of entitySearchDirection | keyvalue" [value]="direction.value"> |
|||
|
|||
</mat-option> |
|||
</mat-select> |
|||
<mat-error *ngIf="relation.get('direction').hasError('required')"> |
|||
Relation direction is required. |
|||
</mat-error> |
|||
</mat-form-field> |
|||
<tb-relation-type-autocomplete |
|||
fxFlex class="mat-block" |
|||
formControlName="relationType" |
|||
[required]="true"> |
|||
</tb-relation-type-autocomplete> |
|||
</div> |
|||
<div fxLayout="row" fxLayout.xs="column"> |
|||
<tb-entity-select |
|||
fxFlex class="mat-block" |
|||
[required]="true" |
|||
formControlName="relatedEntity"> |
|||
</tb-entity-select> |
|||
</div> |
|||
</div> |
|||
<div fxLayout="column" fxLayoutAlign="center center"> |
|||
<button mat-icon-button color="primary" |
|||
aria-label="Remove" |
|||
type="button" |
|||
(click)="removeRelation(i)" |
|||
matTooltip="Remove relation" |
|||
matTooltipPosition="above"> |
|||
<mat-icon>close</mat-icon> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div> |
|||
<button mat-raised-button color="primary" |
|||
type="button" |
|||
(click)="addRelation()" |
|||
matTooltip="Add Relation" |
|||
matTooltipPosition="above"> |
|||
Add |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div mat-dialog-actions fxLayout="row" fxLayoutAlign="end center"> |
|||
<button mat-button color="primary" |
|||
type="button" |
|||
[disabled]="(isLoading$ | async)" |
|||
(click)="cancel()" cdkFocusInitial> |
|||
Cancel |
|||
</button> |
|||
<button mat-button mat-raised-button color="primary" |
|||
type="submit" |
|||
[disabled]="(isLoading$ | async) || addEntityForm.invalid || !addEntityForm.dirty"> |
|||
Create |
|||
</button> |
|||
</div> |
|||
</form> |
|||
{:copy-code} |
|||
``` |
|||
@ -0,0 +1,132 @@ |
|||
#### Function displaying dialog to create a device or an asset |
|||
|
|||
```javascript |
|||
let $injector = widgetContext.$scope.$injector; |
|||
let customDialog = $injector.get(widgetContext.servicesMap.get('customDialog')); |
|||
let assetService = $injector.get(widgetContext.servicesMap.get('assetService')); |
|||
let deviceService = $injector.get(widgetContext.servicesMap.get('deviceService')); |
|||
let attributeService = $injector.get(widgetContext.servicesMap.get('attributeService')); |
|||
let entityRelationService = $injector.get(widgetContext.servicesMap.get('entityRelationService')); |
|||
|
|||
openAddEntityDialog(); |
|||
|
|||
function openAddEntityDialog() { |
|||
customDialog.customDialog(htmlTemplate, AddEntityDialogController).subscribe(); |
|||
} |
|||
|
|||
function AddEntityDialogController(instance) { |
|||
let vm = instance; |
|||
|
|||
vm.allowedEntityTypes = ['ASSET', 'DEVICE']; |
|||
vm.entitySearchDirection = { |
|||
from: "FROM", |
|||
to: "TO" |
|||
} |
|||
|
|||
vm.addEntityFormGroup = vm.fb.group({ |
|||
entityName: ['', [vm.validators.required]], |
|||
entityType: ['DEVICE'], |
|||
entityLabel: [null], |
|||
type: ['', [vm.validators.required]], |
|||
attributes: vm.fb.group({ |
|||
latitude: [null], |
|||
longitude: [null], |
|||
address: [null], |
|||
owner: [null], |
|||
number: [null, [vm.validators.pattern(/^-?[0-9]+$/)]], |
|||
booleanValue: [null] |
|||
}), |
|||
relations: vm.fb.array([]) |
|||
}); |
|||
|
|||
vm.cancel = function () { |
|||
vm.dialogRef.close(null); |
|||
}; |
|||
|
|||
vm.relations = function () { |
|||
return vm.addEntityFormGroup.get('relations'); |
|||
}; |
|||
|
|||
vm.addRelation = function () { |
|||
vm.relations().push(vm.fb.group({ |
|||
relatedEntity: [null, [vm.validators.required]], |
|||
relationType: [null, [vm.validators.required]], |
|||
direction: [null, [vm.validators.required]] |
|||
})); |
|||
}; |
|||
|
|||
vm.removeRelation = function (index) { |
|||
vm.relations().removeAt(index); |
|||
vm.relations().markAsDirty(); |
|||
}; |
|||
|
|||
vm.save = function () { |
|||
vm.addEntityFormGroup.markAsPristine(); |
|||
saveEntityObservable().subscribe( |
|||
function (entity) { |
|||
widgetContext.rxjs.forkJoin([ |
|||
saveAttributes(entity.id), |
|||
saveRelations(entity.id) |
|||
]).subscribe( |
|||
function () { |
|||
widgetContext.updateAliases(); |
|||
vm.dialogRef.close(null); |
|||
} |
|||
); |
|||
} |
|||
); |
|||
}; |
|||
|
|||
function saveEntityObservable() { |
|||
const formValues = vm.addEntityFormGroup.value; |
|||
let entity = { |
|||
name: formValues.entityName, |
|||
type: formValues.type, |
|||
label: formValues.entityLabel |
|||
}; |
|||
if (formValues.entityType == 'ASSET') { |
|||
return assetService.saveAsset(entity); |
|||
} else if (formValues.entityType == 'DEVICE') { |
|||
return deviceService.saveDevice(entity); |
|||
} |
|||
} |
|||
|
|||
function saveAttributes(entityId) { |
|||
let attributes = vm.addEntityFormGroup.get('attributes').value; |
|||
let attributesArray = []; |
|||
for (let key in attributes) { |
|||
if (attributes[key] !== null) { |
|||
attributesArray.push({key: key, value: attributes[key]}); |
|||
} |
|||
} |
|||
if (attributesArray.length > 0) { |
|||
return attributeService.saveEntityAttributes(entityId, "SERVER_SCOPE", attributesArray); |
|||
} |
|||
return widgetContext.rxjs.of([]); |
|||
} |
|||
|
|||
function saveRelations(entityId) { |
|||
let relations = vm.addEntityFormGroup.get('relations').value; |
|||
let tasks = []; |
|||
for (let i = 0; i < relations.length; i++) { |
|||
let relation = { |
|||
type: relations[i].relationType, |
|||
typeGroup: 'COMMON' |
|||
}; |
|||
if (relations[i].direction == 'FROM') { |
|||
relation.to = relations[i].relatedEntity; |
|||
relation.from = entityId; |
|||
} else { |
|||
relation.to = entityId; |
|||
relation.from = relations[i].relatedEntity; |
|||
} |
|||
tasks.push(entityRelationService.saveRelation(relation)); |
|||
} |
|||
if (tasks.length > 0) { |
|||
return widgetContext.rxjs.forkJoin(tasks); |
|||
} |
|||
return widgetContext.rxjs.of([]); |
|||
} |
|||
} |
|||
{:copy-code} |
|||
``` |
|||
@ -0,0 +1,192 @@ |
|||
#### HTML template of dialog to edit a device or an asset |
|||
|
|||
```html |
|||
<form #editEntityForm="ngForm" [formGroup]="editEntityFormGroup" |
|||
(ngSubmit)="save()" class="edit-entity-form"> |
|||
<mat-toolbar fxLayout="row" color="primary"> |
|||
<h2>Edit </h2> |
|||
<span fxFlex></span> |
|||
<button mat-icon-button (click)="cancel()" type="button"> |
|||
<mat-icon class="material-icons">close</mat-icon> |
|||
</button> |
|||
</mat-toolbar> |
|||
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async"> |
|||
</mat-progress-bar> |
|||
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div> |
|||
<div mat-dialog-content fxLayout="column"> |
|||
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0"> |
|||
<mat-form-field fxFlex class="mat-block"> |
|||
<mat-label>Entity Name</mat-label> |
|||
<input matInput formControlName="entityName" required readonly=""> |
|||
</mat-form-field> |
|||
<mat-form-field fxFlex class="mat-block"> |
|||
<mat-label>Entity Label</mat-label> |
|||
<input matInput formControlName="entityLabel"> |
|||
</mat-form-field> |
|||
</div> |
|||
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0"> |
|||
<mat-form-field fxFlex class="mat-block"> |
|||
<mat-label>Entity Type</mat-label> |
|||
<input matInput formControlName="entityType" readonly> |
|||
</mat-form-field> |
|||
<mat-form-field fxFlex class="mat-block"> |
|||
<mat-label>Type</mat-label> |
|||
<input matInput formControlName="type" readonly> |
|||
</mat-form-field> |
|||
</div> |
|||
<div formGroupName="attributes" fxLayout="column"> |
|||
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0"> |
|||
<mat-form-field fxFlex class="mat-block"> |
|||
<mat-label>Latitude</mat-label> |
|||
<input type="number" step="any" matInput formControlName="latitude"> |
|||
</mat-form-field> |
|||
<mat-form-field fxFlex class="mat-block"> |
|||
<mat-label>Longitude</mat-label> |
|||
<input type="number" step="any" matInput formControlName="longitude"> |
|||
</mat-form-field> |
|||
</div> |
|||
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0"> |
|||
<mat-form-field fxFlex class="mat-block"> |
|||
<mat-label>Address</mat-label> |
|||
<input matInput formControlName="address"> |
|||
</mat-form-field> |
|||
<mat-form-field fxFlex class="mat-block"> |
|||
<mat-label>Owner</mat-label> |
|||
<input matInput formControlName="owner"> |
|||
</mat-form-field> |
|||
</div> |
|||
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0"> |
|||
<mat-form-field fxFlex class="mat-block"> |
|||
<mat-label>Integer Value</mat-label> |
|||
<input type="number" step="1" matInput formControlName="number"> |
|||
<mat-error *ngIf="editEntityFormGroup.get('attributes.number').hasError('pattern')"> |
|||
Invalid integer value. |
|||
</mat-error> |
|||
</mat-form-field> |
|||
<div class="boolean-value-input" fxLayout="column" fxLayoutAlign="center start" fxFlex> |
|||
<label class="checkbox-label">Boolean Value</label> |
|||
<mat-checkbox formControlName="booleanValue" style="margin-bottom: 40px;"> |
|||
|
|||
</mat-checkbox> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="relations-list old-relations"> |
|||
<div class="mat-body-1" style="padding-bottom: 10px; color: rgba(0,0,0,0.57);">Relations</div> |
|||
<div class="body" [fxShow]="oldRelations().length"> |
|||
<div class="row" fxLayout="row" fxLayoutAlign="start center" formArrayName="oldRelations" |
|||
*ngFor="let relation of oldRelations().controls; let i = index;"> |
|||
<div [formGroupName]="i" class="mat-elevation-z2" fxFlex fxLayout="row" style="padding: 5px 0 5px 5px;"> |
|||
<div fxFlex fxLayout="column"> |
|||
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0"> |
|||
<mat-form-field class="mat-block" style="min-width: 100px;"> |
|||
<mat-label>Direction</mat-label> |
|||
<mat-select formControlName="direction" name="direction"> |
|||
<mat-option *ngFor="let direction of entitySearchDirection | keyvalue" [value]="direction.value"> |
|||
|
|||
</mat-option> |
|||
</mat-select> |
|||
<mat-error *ngIf="relation.get('direction').hasError('required')"> |
|||
Relation direction is required. |
|||
</mat-error> |
|||
</mat-form-field> |
|||
<tb-relation-type-autocomplete |
|||
fxFlex class="mat-block" |
|||
formControlName="relationType" |
|||
required="true"> |
|||
</tb-relation-type-autocomplete> |
|||
</div> |
|||
<div fxLayout="row" fxLayout.xs="column"> |
|||
<tb-entity-select |
|||
fxFlex class="mat-block" |
|||
required="true" |
|||
formControlName="relatedEntity"> |
|||
</tb-entity-select> |
|||
</div> |
|||
</div> |
|||
<div fxLayout="column" fxLayoutAlign="center center"> |
|||
<button mat-icon-button color="primary" |
|||
aria-label="Remove" |
|||
type="button" |
|||
(click)="removeOldRelation(i)" |
|||
matTooltip="Remove relation" |
|||
matTooltipPosition="above"> |
|||
<mat-icon>close</mat-icon> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="relations-list"> |
|||
<div class="mat-body-1" style="padding-bottom: 10px; color: rgba(0,0,0,0.57);">New Relations</div> |
|||
<div class="body" [fxShow]="relations().length"> |
|||
<div class="row" fxLayout="row" fxLayoutAlign="start center" formArrayName="relations" *ngFor="let relation of relations().controls; let i = index;"> |
|||
<div [formGroupName]="i" class="mat-elevation-z2" fxFlex fxLayout="row" style="padding: 5px 0 5px 5px;"> |
|||
<div fxFlex fxLayout="column"> |
|||
<div fxLayout="row" fxLayoutGap="8px" fxLayout.xs="column" fxLayoutGap.xs="0"> |
|||
<mat-form-field class="mat-block" style="min-width: 100px;"> |
|||
<mat-label>Direction</mat-label> |
|||
<mat-select formControlName="direction" name="direction"> |
|||
<mat-option *ngFor="let direction of entitySearchDirection | keyvalue" [value]="direction.value"> |
|||
|
|||
</mat-option> |
|||
</mat-select> |
|||
<mat-error *ngIf="relation.get('direction').hasError('required')"> |
|||
Relation direction is required. |
|||
</mat-error> |
|||
</mat-form-field> |
|||
<tb-relation-type-autocomplete |
|||
fxFlex class="mat-block" |
|||
formControlName="relationType" |
|||
[required]="true"> |
|||
</tb-relation-type-autocomplete> |
|||
</div> |
|||
<div fxLayout="row" fxLayout.xs="column"> |
|||
<tb-entity-select |
|||
fxFlex class="mat-block" |
|||
[required]="true" |
|||
formControlName="relatedEntity"> |
|||
</tb-entity-select> |
|||
</div> |
|||
</div> |
|||
<div fxLayout="column" fxLayoutAlign="center center"> |
|||
<button mat-icon-button color="primary" |
|||
aria-label="Remove" |
|||
type="button" |
|||
(click)="removeRelation(i)" |
|||
matTooltip="Remove relation" |
|||
matTooltipPosition="above"> |
|||
<mat-icon>close</mat-icon> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div> |
|||
<button mat-raised-button color="primary" |
|||
type="button" |
|||
(click)="addRelation()" |
|||
matTooltip="Add Relation" |
|||
matTooltipPosition="above"> |
|||
Add |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div mat-dialog-actions fxLayout="row" fxLayoutAlign="end center"> |
|||
<button mat-button color="primary" |
|||
type="button" |
|||
[disabled]="(isLoading$ | async)" |
|||
(click)="cancel()" cdkFocusInitial> |
|||
Cancel |
|||
</button> |
|||
<button mat-button mat-raised-button color="primary" |
|||
type="submit" |
|||
[disabled]="(isLoading$ | async) || editEntityForm.invalid || !editEntityForm.dirty"> |
|||
Save |
|||
</button> |
|||
</div> |
|||
</form> |
|||
{:copy-code} |
|||
``` |
|||
@ -0,0 +1,220 @@ |
|||
#### Function displaying dialog to edit a device or an asset |
|||
|
|||
```javascript |
|||
let $injector = widgetContext.$scope.$injector; |
|||
let customDialog = $injector.get(widgetContext.servicesMap.get('customDialog')); |
|||
let entityService = $injector.get(widgetContext.servicesMap.get('entityService')); |
|||
let assetService = $injector.get(widgetContext.servicesMap.get('assetService')); |
|||
let deviceService = $injector.get(widgetContext.servicesMap.get('deviceService')); |
|||
let attributeService = $injector.get(widgetContext.servicesMap.get('attributeService')); |
|||
let entityRelationService = $injector.get(widgetContext.servicesMap.get('entityRelationService')); |
|||
|
|||
openEditEntityDialog(); |
|||
|
|||
function openEditEntityDialog() { |
|||
customDialog.customDialog(htmlTemplate, EditEntityDialogController).subscribe(); |
|||
} |
|||
|
|||
function EditEntityDialogController(instance) { |
|||
let vm = instance; |
|||
|
|||
vm.entityName = entityName; |
|||
vm.entityType = entityId.entityType; |
|||
vm.entitySearchDirection = { |
|||
from: "FROM", |
|||
to: "TO" |
|||
}; |
|||
vm.attributes = {}; |
|||
vm.oldRelationsData = []; |
|||
vm.relationsToDelete = []; |
|||
vm.entity = {}; |
|||
|
|||
vm.editEntityFormGroup = vm.fb.group({ |
|||
entityName: ['', [vm.validators.required]], |
|||
entityType: [null], |
|||
entityLabel: [null], |
|||
type: ['', [vm.validators.required]], |
|||
attributes: vm.fb.group({ |
|||
latitude: [null], |
|||
longitude: [null], |
|||
address: [null], |
|||
owner: [null], |
|||
number: [null, [vm.validators.pattern(/^-?[0-9]+$/)]], |
|||
booleanValue: [false] |
|||
}), |
|||
oldRelations: vm.fb.array([]), |
|||
relations: vm.fb.array([]) |
|||
}); |
|||
|
|||
getEntityInfo(); |
|||
|
|||
vm.cancel = function() { |
|||
vm.dialogRef.close(null); |
|||
}; |
|||
|
|||
vm.relations = function() { |
|||
return vm.editEntityFormGroup.get('relations'); |
|||
}; |
|||
|
|||
vm.oldRelations = function() { |
|||
return vm.editEntityFormGroup.get('oldRelations'); |
|||
}; |
|||
|
|||
vm.addRelation = function() { |
|||
vm.relations().push(vm.fb.group({ |
|||
relatedEntity: [null, [vm.validators.required]], |
|||
relationType: [null, [vm.validators.required]], |
|||
direction: [null, [vm.validators.required]] |
|||
})); |
|||
}; |
|||
|
|||
function addOldRelation() { |
|||
vm.oldRelations().push(vm.fb.group({ |
|||
relatedEntity: [{value: null, disabled: true}, [vm.validators.required]], |
|||
relationType: [{value: null, disabled: true}, [vm.validators.required]], |
|||
direction: [{value: null, disabled: true}, [vm.validators.required]] |
|||
})); |
|||
} |
|||
|
|||
vm.removeRelation = function(index) { |
|||
vm.relations().removeAt(index); |
|||
vm.relations().markAsDirty(); |
|||
}; |
|||
|
|||
vm.removeOldRelation = function(index) { |
|||
vm.oldRelations().removeAt(index); |
|||
vm.relationsToDelete.push(vm.oldRelationsData[index]); |
|||
vm.oldRelations().markAsDirty(); |
|||
}; |
|||
|
|||
vm.save = function() { |
|||
vm.editEntityFormGroup.markAsPristine(); |
|||
widgetContext.rxjs.forkJoin([ |
|||
saveAttributes(entityId), |
|||
saveRelations(entityId), |
|||
saveEntity() |
|||
]).subscribe( |
|||
function () { |
|||
widgetContext.updateAliases(); |
|||
vm.dialogRef.close(null); |
|||
} |
|||
); |
|||
}; |
|||
|
|||
function getEntityAttributes(attributes) { |
|||
for (var i = 0; i < attributes.length; i++) { |
|||
vm.attributes[attributes[i].key] = attributes[i].value; |
|||
} |
|||
} |
|||
|
|||
function getEntityRelations(relations) { |
|||
let relationsFrom = relations[0]; |
|||
let relationsTo = relations[1]; |
|||
for (let i=0; i < relationsFrom.length; i++) { |
|||
let relation = { |
|||
direction: 'FROM', |
|||
relationType: relationsFrom[i].type, |
|||
relatedEntity: relationsFrom[i].to |
|||
}; |
|||
vm.oldRelationsData.push(relation); |
|||
addOldRelation(); |
|||
} |
|||
for (let i=0; i < relationsTo.length; i++) { |
|||
let relation = { |
|||
direction: 'TO', |
|||
relationType: relationsTo[i].type, |
|||
relatedEntity: relationsTo[i].from |
|||
}; |
|||
vm.oldRelationsData.push(relation); |
|||
addOldRelation(); |
|||
} |
|||
} |
|||
|
|||
function getEntityInfo() { |
|||
widgetContext.rxjs.forkJoin([ |
|||
entityRelationService.findInfoByFrom(entityId), |
|||
entityRelationService.findInfoByTo(entityId), |
|||
attributeService.getEntityAttributes(entityId, 'SERVER_SCOPE'), |
|||
entityService.getEntity(entityId.entityType, entityId.id) |
|||
]).subscribe( |
|||
function (data) { |
|||
getEntityRelations(data.slice(0,2)); |
|||
getEntityAttributes(data[2]); |
|||
vm.entity = data[3]; |
|||
vm.editEntityFormGroup.patchValue({ |
|||
entityName: vm.entity.name, |
|||
entityType: vm.entityType, |
|||
entityLabel: vm.entity.label, |
|||
type: vm.entity.type, |
|||
attributes: vm.attributes, |
|||
oldRelations: vm.oldRelationsData |
|||
}, {emitEvent: false}); |
|||
} |
|||
); |
|||
} |
|||
|
|||
function saveEntity() { |
|||
const formValues = vm.editEntityFormGroup.value; |
|||
if (vm.entity.label !== formValues.entityLabel){ |
|||
vm.entity.label = formValues.entityLabel; |
|||
if (formValues.entityType == 'ASSET') { |
|||
return assetService.saveAsset(vm.entity); |
|||
} else if (formValues.entityType == 'DEVICE') { |
|||
return deviceService.saveDevice(vm.entity); |
|||
} |
|||
} |
|||
return widgetContext.rxjs.of([]); |
|||
} |
|||
|
|||
function saveAttributes(entityId) { |
|||
let attributes = vm.editEntityFormGroup.get('attributes').value; |
|||
let attributesArray = []; |
|||
for (let key in attributes) { |
|||
if (attributes[key] !== vm.attributes[key]) { |
|||
attributesArray.push({key: key, value: attributes[key]}); |
|||
} |
|||
} |
|||
if (attributesArray.length > 0) { |
|||
return attributeService.saveEntityAttributes(entityId, "SERVER_SCOPE", attributesArray); |
|||
} |
|||
return widgetContext.rxjs.of([]); |
|||
} |
|||
|
|||
function saveRelations(entityId) { |
|||
let relations = vm.editEntityFormGroup.get('relations').value; |
|||
let tasks = []; |
|||
for(let i=0; i < relations.length; i++) { |
|||
let relation = { |
|||
type: relations[i].relationType, |
|||
typeGroup: 'COMMON' |
|||
}; |
|||
if (relations[i].direction == 'FROM') { |
|||
relation.to = relations[i].relatedEntity; |
|||
relation.from = entityId; |
|||
} else { |
|||
relation.to = entityId; |
|||
relation.from = relations[i].relatedEntity; |
|||
} |
|||
tasks.push(entityRelationService.saveRelation(relation)); |
|||
} |
|||
for (let i=0; i < vm.relationsToDelete.length; i++) { |
|||
let relation = { |
|||
type: vm.relationsToDelete[i].relationType |
|||
}; |
|||
if (vm.relationsToDelete[i].direction == 'FROM') { |
|||
relation.to = vm.relationsToDelete[i].relatedEntity; |
|||
relation.from = entityId; |
|||
} else { |
|||
relation.to = entityId; |
|||
relation.from = vm.relationsToDelete[i].relatedEntity; |
|||
} |
|||
tasks.push(entityRelationService.deleteRelation(relation.from, relation.type, relation.to)); |
|||
} |
|||
if (tasks.length > 0) { |
|||
return widgetContext.rxjs.forkJoin(tasks); |
|||
} |
|||
return widgetContext.rxjs.of([]); |
|||
} |
|||
} |
|||
{:copy-code} |
|||
``` |
|||
@ -1,5 +1,48 @@ |
|||
#### Show cell button action JavaScript Function |
|||
#### Show cell button action function |
|||
|
|||
<div class="divider"></div> |
|||
<br/> |
|||
|
|||
*function (widgetContext, data): boolean* |
|||
|
|||
A JavaScript function evaluating whether to display particular table cell action. |
|||
|
|||
**Parameters:** |
|||
|
|||
<ul> |
|||
<li><b>widgetContext:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/models/widget-component.models.ts#L107" target="_blank">WidgetContext</a></code> - A reference to <a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/models/widget-component.models.ts#L107" target="_blank">WidgetContext</a> that has all necessary API |
|||
and data used by widget instance. |
|||
</li> |
|||
<li><b>data:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-models.ts#L108" target="_blank">FormattedData</a></code> - A <a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-models.ts#L108" target="_blank">FormattedData</a> object of specific table row.<br/> |
|||
Represents basic entity properties (ex. <code>entityId</code>, <code>entityName</code>)<br/>and provides access to other entity attributes/timeseries declared in widget datasource configuration. |
|||
</li> |
|||
</ul> |
|||
|
|||
**Returns:** |
|||
|
|||
`true` if cell action should be displayed, `false` otherwise. |
|||
|
|||
<div class="divider"></div> |
|||
|
|||
##### Examples |
|||
|
|||
* Display action only for customer users: |
|||
|
|||
```javascript |
|||
return widgetContext.currentUser.authority === 'CUSTOMER_USER'; |
|||
{:copy-code} |
|||
``` |
|||
|
|||
* Display action only if the entity in the row is device and has type `thermostat`: |
|||
|
|||
```javascript |
|||
return data && data.entityType === 'DEVICE' && data.Type === 'thermostat'; |
|||
{:copy-code} |
|||
``` |
|||
|
|||
* Display action only if the entity in the row has `temperature` latest timeseries or attribute value greater than 25: |
|||
|
|||
```javascript |
|||
return data.entityName === 'Test device'; {:copy-code} |
|||
return data && data.temperature > 25; |
|||
{:copy-code} |
|||
``` |
|||
|
|||
@ -0,0 +1,59 @@ |
|||
#### Data generation function |
|||
|
|||
<div class="divider"></div> |
|||
<br/> |
|||
|
|||
*function (time, prevValue): any* |
|||
|
|||
A JavaScript function generating datapoint values. |
|||
|
|||
**Parameters:** |
|||
|
|||
<ul> |
|||
<li><b>time:</b> <code>number</code> - timestamp in milliseconds of the current datapoint. |
|||
</li> |
|||
<li><b>prevValue:</b> <code>primitive (number/string/boolean)</code> - A previous datapoint value. |
|||
</li> |
|||
</ul> |
|||
|
|||
**Returns:** |
|||
|
|||
A primitive type (number, string or boolean) presenting newly generated datapoint value. |
|||
|
|||
<div class="divider"></div> |
|||
|
|||
##### Examples |
|||
|
|||
* Generate data with sine function: |
|||
|
|||
```javascript |
|||
return Math.sin(time/5000); |
|||
{:copy-code} |
|||
``` |
|||
|
|||
* Generate true/false sequence: |
|||
|
|||
```javascript |
|||
if (!prevValue) { |
|||
return true; |
|||
} else { |
|||
return false; |
|||
} |
|||
{:copy-code} |
|||
``` |
|||
|
|||
* Generate repeating sequence of predefined values (for ex. latitude): |
|||
|
|||
```javascript |
|||
var lats = [37.7696499, |
|||
37.7699074, |
|||
37.7699536, |
|||
37.7697242, |
|||
37.7695189, |
|||
37.7696889]; |
|||
|
|||
var index = Math.floor((time/3 % 14000) / 1000); |
|||
|
|||
return lats[index]; |
|||
{:copy-code} |
|||
``` |
|||
@ -0,0 +1,56 @@ |
|||
#### Data post-processing function |
|||
|
|||
<div class="divider"></div> |
|||
<br/> |
|||
|
|||
*function (time, value, prevValue, timePrev, prevOrigValue): any* |
|||
|
|||
A JavaScript function doing post-processing on telemetry data. |
|||
|
|||
**Parameters:** |
|||
|
|||
<ul> |
|||
<li><b>time:</b> <code>number</code> - timestamp in milliseconds of the current datapoint. |
|||
</li> |
|||
<li><b>value:</b> <code>primitive (number/string/boolean)</code> - A value of the current datapoint. |
|||
</li> |
|||
<li><b>prevValue:</b> <code>primitive (number/string/boolean)</code> - A value of the previous datapoint after applied post-processing. |
|||
</li> |
|||
<li><b>timePrev:</b> <code>number</code> - timestamp in milliseconds of the previous datapoint value. |
|||
</li> |
|||
<li><b>prevOrigValue:</b> <code>primitive (number/string/boolean)</code> - An original value of the previous datapoint. |
|||
</li> |
|||
</ul> |
|||
|
|||
**Returns:** |
|||
|
|||
A primitive type (number, string or boolean) presenting the new datapoint value. |
|||
|
|||
<div class="divider"></div> |
|||
|
|||
##### Examples |
|||
|
|||
* Multiply all datapoint values by 10: |
|||
|
|||
```javascript |
|||
return value * 10; |
|||
{:copy-code} |
|||
``` |
|||
|
|||
* Round all datapoint values to whole numbers: |
|||
|
|||
```javascript |
|||
return Math.round(value); |
|||
{:copy-code} |
|||
``` |
|||
|
|||
* Get relative difference between data points: |
|||
|
|||
```javascript |
|||
if (prevOrigValue) { |
|||
return (value - prevOrigValue) / prevOrigValue; |
|||
} else { |
|||
return 0; |
|||
} |
|||
{:copy-code} |
|||
``` |
|||
@ -1,12 +1,37 @@ |
|||
#### Tooltip value format JavaScript Function |
|||
#### Tooltip value format function |
|||
|
|||
##### Input arguments |
|||
<div class="divider"></div> |
|||
<br/> |
|||
|
|||
- value - value to format |
|||
*function (value): string* |
|||
|
|||
A JavaScript function used to format datapoint value to be shown on the chart tooltip. |
|||
|
|||
**Parameters:** |
|||
|
|||
<ul> |
|||
<li><b>value:</b> <code>primitive (number/string/boolean)</code> - A value of the datapoint that should be formatted. |
|||
</li> |
|||
</ul> |
|||
|
|||
**Returns:** |
|||
|
|||
A string representing the formatted value. |
|||
|
|||
<div class="divider"></div> |
|||
|
|||
##### Examples |
|||
|
|||
* Present the datapoint value in tooltip in Celsius (°C) units: |
|||
|
|||
```javascript |
|||
return value; |
|||
return value + ' °C'; |
|||
{:copy-code} |
|||
``` |
|||
|
|||
##### Examples |
|||
* Present the datapoint value in tooltip in Amperage (A) units and two decimal places: |
|||
|
|||
```javascript |
|||
return value.toFixed(2) + ' A'; |
|||
{:copy-code} |
|||
``` |
|||
|
|||
Loading…
Reference in new issue