Browse Source

Improve markdown component. Add new helps.

pull/5552/head
Igor Kulikov 5 years ago
parent
commit
b4f230dbc8
  1. 2
      ui-ngx/package.json
  2. 10
      ui-ngx/src/app/core/services/dynamic-component-factory.service.ts
  3. 29
      ui-ngx/src/app/core/services/help.service.ts
  4. 3
      ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.html
  5. 3
      ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.html
  6. 1
      ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html
  7. 2
      ui-ngx/src/app/modules/home/components/widget/data-key-config.component.html
  8. 2
      ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.html
  9. 2
      ui-ngx/src/app/modules/home/components/widget/widget.component.ts
  10. 2
      ui-ngx/src/app/shared/components/help-markdown.component.html
  11. 6
      ui-ngx/src/app/shared/components/help-markdown.component.scss
  12. 14
      ui-ngx/src/app/shared/components/help-markdown.component.ts
  13. 32
      ui-ngx/src/app/shared/components/help-popup.component.html
  14. 46
      ui-ngx/src/app/shared/components/help-popup.component.scss
  15. 38
      ui-ngx/src/app/shared/components/help-popup.component.ts
  16. 5
      ui-ngx/src/app/shared/components/json-form/json-form.component.ts
  17. 21
      ui-ngx/src/app/shared/components/markdown.component.html
  18. 20
      ui-ngx/src/app/shared/components/markdown.component.scss
  19. 154
      ui-ngx/src/app/shared/components/markdown.component.ts
  20. 4
      ui-ngx/src/app/shared/components/marked-options.service.ts
  21. 164
      ui-ngx/src/app/shared/components/popover.component.ts
  22. 190
      ui-ngx/src/app/shared/components/popover.service.ts
  23. 24
      ui-ngx/src/app/shared/components/tokens.ts
  24. 2
      ui-ngx/src/app/shared/models/constants.ts
  25. 9
      ui-ngx/src/app/shared/shared.module.ts
  26. 92
      ui-ngx/src/assets/help/en_US/widget/action/custom_action_fn.md
  27. 46
      ui-ngx/src/assets/help/en_US/widget/action/custom_additional_params.md
  28. 74
      ui-ngx/src/assets/help/en_US/widget/action/custom_pretty_action_fn.md
  29. 160
      ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_create_dialog_html.md
  30. 132
      ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_create_dialog_js.md
  31. 192
      ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_edit_dialog_html.md
  32. 220
      ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_edit_dialog_js.md
  33. 47
      ui-ngx/src/assets/help/en_US/widget/action/show_widget_action_cell_fn.md
  34. 6
      ui-ngx/src/assets/help/en_US/widget/action/show_widget_action_header_fn.md
  35. 59
      ui-ngx/src/assets/help/en_US/widget/config/datakey_generation_fn.md
  36. 56
      ui-ngx/src/assets/help/en_US/widget/config/datakey_postprocess_fn.md
  37. 35
      ui-ngx/src/assets/help/en_US/widget/lib/tooltip_value_format_fn.md
  38. 15
      ui-ngx/src/styles.scss
  39. 52
      ui-ngx/yarn.lock

2
ui-ngx/package.json

@ -73,7 +73,7 @@
"ngx-drag-drop": "^2.0.0",
"ngx-flowchart": "git://github.com/thingsboard/ngx-flowchart.git#master",
"ngx-hm-carousel": "^2.0.0-rc.1",
"ngx-markdown": "^10.1.1",
"ngx-markdown": "^11.1.3",
"ngx-sharebuttons": "^8.0.5",
"ngx-translate-messageformat-compiler": "^4.9.0",
"objectpath": "^2.0.0",

10
ui-ngx/src/app/core/services/dynamic-component-factory.service.ts

@ -55,11 +55,12 @@ export class DynamicComponentFactoryService {
public createDynamicComponentFactory<T>(
componentType: Type<T>,
template: string,
modules?: Type<any>[]): Observable<ComponentFactory<T>> {
modules?: Type<any>[],
preserveWhitespaces?: boolean): Observable<ComponentFactory<T>> {
const dymamicComponentFactorySubject = new ReplaySubject<ComponentFactory<T>>();
import('@angular/compiler').then(
() => {
const comp = this.createDynamicComponent(componentType, template);
const comp = this.createDynamicComponent(componentType, template, preserveWhitespaces);
let moduleImports: Type<any>[] = [CommonModule];
if (modules) {
moduleImports = [...moduleImports, ...modules];
@ -103,10 +104,11 @@ export class DynamicComponentFactoryService {
}
}
private createDynamicComponent<T>(componentType: Type<T>, template: string): Type<T> {
private createDynamicComponent<T>(componentType: Type<T>, template: string, preserveWhitespaces?: boolean): Type<T> {
// noinspection AngularMissingOrInvalidDeclarationInModule
return Component({
template
template,
preserveWhitespaces
})(componentType);
}

29
ui-ngx/src/app/core/services/help.service.ts

@ -18,7 +18,8 @@ import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { TranslateService } from '@ngx-translate/core';
import { Observable, of } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { catchError, mergeMap, tap } from 'rxjs/operators';
import { helpBaseUrl } from '@shared/models/constants';
const NOT_FOUND_CONTENT = '## Not found';
@ -27,6 +28,8 @@ const NOT_FOUND_CONTENT = '## Not found';
})
export class HelpService {
private helpBaseUrl = helpBaseUrl;
private helpCache: {[lang: string]: {[key: string]: string}} = {};
constructor(
@ -52,6 +55,9 @@ export class HelpService {
return of(NOT_FOUND_CONTENT);
}
}),
mergeMap((content) => {
return this.processIncludes(this.processVariables(content));
}),
tap((content) => {
let langContent = this.helpCache[lang];
if (!langContent) {
@ -68,4 +74,25 @@ export class HelpService {
return this.http.get(`/assets/help/${lang}/${key}.md`, {responseType: 'text'} );
}
private processVariables(content: string): string {
const baseUrlReg = /\${baseUrl}/g;
return content.replace(baseUrlReg, this.helpBaseUrl);
}
private processIncludes(content: string): Observable<string> {
const includesRule = /{% include (.*) %}/;
const match = includesRule.exec(content);
if (match) {
const key = match[1];
return this.getHelpContent(key).pipe(
mergeMap((include) => {
content = content.replace(match[0], include);
return this.processIncludes(content);
})
);
} else {
return of(content);
}
}
}

3
ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.html

@ -46,7 +46,8 @@
[functionArgs]="['$event', 'widgetContext', 'entityId', 'entityName', 'htmlTemplate', 'additionalParams', 'entityLabel']"
[disableUndefinedCheck]="true"
[validationArgs]="[]"
[editorCompleter]="customPrettyActionEditorCompleter">
[editorCompleter]="customPrettyActionEditorCompleter"
helpId="widget/action/custom_pretty_action_fn">
</tb-js-func>
</div>
</div>

3
ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.html

@ -96,7 +96,8 @@
[functionArgs]="['$event', 'widgetContext', 'entityId', 'entityName', 'htmlTemplate', 'additionalParams', 'entityLabel']"
[disableUndefinedCheck]="true"
[validationArgs]="[]"
[editorCompleter]="customPrettyActionEditorCompleter">
[editorCompleter]="customPrettyActionEditorCompleter"
helpId="widget/action/custom_pretty_action_fn">
</tb-js-func>
</mat-tab>
</mat-tab-group>

1
ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html

@ -224,6 +224,7 @@
[globalVariables]="functionScopeVariables"
[validationArgs]="[]"
[editorCompleter]="customActionEditorCompleter"
helpId="widget/action/custom_action_fn"
></tb-js-func>
</ng-template>
<ng-template [ngSwitchCase]="widgetActionType.customPretty">

2
ui-ngx/src/app/modules/home/components/widget/data-key-config.component.html

@ -70,6 +70,7 @@
[globalVariables]="functionScopeVariables"
[validationArgs]="[[1, 1],[1, '1']]"
resultType="any"
helpId="widget/config/datakey_generation_fn"
formControlName="funcBody">
</tb-js-func>
</section>
@ -82,6 +83,7 @@
[globalVariables]="functionScopeVariables"
[validationArgs]="[[1, 1, 1, 1, 1],[1, '1', '1', 1, '1']]"
resultType="any"
helpId="widget/config/datakey_postprocess_fn"
formControlName="postFuncBody">
</tb-js-func>
<label *ngIf="dataKeyFormGroup.get('usePostProcessing').value" class="tb-title" style="margin-left: 15px;">

2
ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.html

@ -15,4 +15,4 @@
limitations under the License.
-->
<markdown [data]="markdownText" lineNumbers class="tb-markdown-view" (click)="markdownClick($event)"></markdown>
<tb-markdown [data]="markdownText" lineNumbers (click)="markdownClick($event)"></tb-markdown>

2
ui-ngx/src/app/modules/home/components/widget/widget.component.ts

@ -107,9 +107,9 @@ import { ComponentType } from '@angular/cdk/portal';
import { EMBED_DASHBOARD_DIALOG_TOKEN } from '@home/components/widget/dialog/embed-dashboard-dialog-token';
import { MobileService } from '@core/services/mobile.service';
import { DialogService } from '@core/services/dialog.service';
import { TbPopoverService } from '@shared/components/popover.component';
import { DashboardPageComponent } from '@home/components/dashboard-page/dashboard-page.component';
import { PopoverPlacement } from '@shared/components/popover.models';
import { TbPopoverService } from '@shared/components/popover.service';
@Component({
selector: 'tb-widget',

2
ui-ngx/src/app/shared/components/help-markdown.component.html

@ -16,5 +16,5 @@
-->
<ng-container *ngIf="markdownText$ | async as text;">
<markdown [data]="text" lineNumbers (ready)="onMarkdownReady()" class="tb-help-markdown tb-markdown-view" (click)="markdownClick($event)"></markdown>
<tb-markdown [style]="style" [data]="text" lineNumbers (ready)="onMarkdownReady()" markdownClass="tb-help-markdown" (click)="markdownClick($event)"></tb-markdown>
</ng-container>

6
ui-ngx/src/app/shared/components/help-markdown.component.scss

@ -13,16 +13,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
:host {
:host ::ng-deep {
.tb-help-markdown {
overflow: auto;
max-width: 80vw;
max-height: 80vh;
margin-top: 30px;
}
}
:host ::ng-deep {
.tb-help-markdown.tb-markdown-view {
h1, h2, h3, h4, h5, h6 {
&:first-child {

14
ui-ngx/src/app/shared/components/help-markdown.component.ts

@ -22,7 +22,7 @@ import {
Output, SimpleChanges
} from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { delay, share } from 'rxjs/operators';
import { share } from 'rxjs/operators';
import { HelpService } from '@core/services/help.service';
@Component({
@ -34,8 +34,12 @@ export class HelpMarkdownComponent implements OnDestroy, OnInit, OnChanges {
@Input() helpId: string;
@Input() helpContent: string;
@Input() visible: boolean;
@Input() style: { [klass: string]: any } = {};
@Output() markdownReady = new EventEmitter<void>();
markdownText = new BehaviorSubject<string>(null);
@ -44,8 +48,6 @@ export class HelpMarkdownComponent implements OnDestroy, OnInit, OnChanges {
share()
);
isMarkdownReady = false;
private loadHelpPending = false;
constructor(private help: HelpService) {}
@ -68,7 +70,7 @@ export class HelpMarkdownComponent implements OnDestroy, OnInit, OnChanges {
this.loadHelp();
}
}
if (propName === 'helpId') {
if (propName === 'helpId' || propName === 'helpContent') {
this.markdownText.next(null);
this.loadHelpWhenVisible();
}
@ -89,16 +91,16 @@ export class HelpMarkdownComponent implements OnDestroy, OnInit, OnChanges {
this.help.getHelpContent(this.helpId).subscribe((content) => {
this.markdownText.next(content);
});
} else if (this.helpContent) {
this.markdownText.next(this.helpContent);
}
}
onMarkdownReady() {
this.isMarkdownReady = true;
this.markdownReady.next();
}
markdownClick($event: MouseEvent) {
}
}

32
ui-ngx/src/app/shared/components/help-popup.component.html

@ -15,16 +15,22 @@
limitations under the License.
-->
<div style="position: relative;">
<button color="primary" mat-button mat-icon-button
class="tb-help-popup-button tb-mat-32"
type="button"
(click)="toggleHelp()"
matTooltip="{{'help.show-help' | translate}}"
matTooltipPosition="above">
<mat-icon class="material-icons">{{popoverVisible ? 'help' : 'help_outline'}}</mat-icon>
</button>
<div *ngIf="popoverVisible && !popoverReady" fxFlex fxLayoutAlign="center center" class="tb-absolute-fill tb-help-popup-button-loading">
<mat-spinner mode="indeterminate" diameter="20" strokeWidth="2"></mat-spinner>
</div>
</div>
<button #toggleHelpButton
*ngIf="!triggerText"
color="primary" mat-button mat-icon-button
class="tb-help-popup-button tb-mat-32"
type="button"
(click)="toggleHelp()"
matTooltip="{{'help.show-help' | translate}}"
matTooltipPosition="above">
<mat-icon class="material-icons">{{popoverVisible ? 'help' : 'help_outline'}}</mat-icon>
<mat-spinner *ngIf="popoverVisible && !popoverReady" class="tb-help-popup-button-loading" mode="indeterminate" diameter="20" strokeWidth="2"></mat-spinner>
</button>
<span #toggleHelpSpan
*ngIf="triggerText"
class="tb-help-popup-span"
(click)="toggleHelp()"
[ngClass]="{'active': popoverVisible, 'ready': popoverReady}">
<span>{{triggerText}}</span>
<mat-progress-bar *ngIf="popoverVisible && !popoverReady" color="primary" mode="indeterminate"></mat-progress-bar>
</span>

46
ui-ngx/src/app/shared/components/help-popup.component.scss

@ -14,8 +14,54 @@
* limitations under the License.
*/
.tb-help-popup-button {
position: relative;
}
.tb-help-popup-button-loading {
background: #fff;
border-radius: 50%;
width: 32px !important;
height: 32px !important;
position: absolute;
top: 0;
left: 0;
&.mat-progress-spinner {
svg {
top: 6px;
left: 6px;
}
}
}
.tb-help-popup-span {
position: relative;
cursor: pointer;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
color: #2a7dec;
font-weight: 500;
&.active.ready {
text-decoration: underline;
}
&:hover {
&:not(.active) {
text-decoration: underline;
}
}
&:after {
display: inline-block;
font-family: "FontAwesome";
content: '\f08e';
padding-left: 5px;
font-size: 14px;
font-weight: 600;
}
.mat-progress-bar {
position: absolute;
height: 2px;
left: 0;
right: 0;
bottom: 0;
}
}

38
ui-ngx/src/app/shared/components/help-popup.component.ts

@ -14,8 +14,18 @@
/// limitations under the License.
///
import { Component, ElementRef, Input, OnDestroy, Renderer2, ViewContainerRef, ViewEncapsulation } from '@angular/core';
import { TbPopoverService } from '@shared/components/popover.component';
import {
Component,
ElementRef,
Input,
OnDestroy,
Renderer2,
ViewChild,
ViewContainerRef,
ViewEncapsulation
} from '@angular/core';
import { TbPopoverService } from '@shared/components/popover.service';
import { PopoverPlacement } from '@shared/components/popover.models';
@Component({
// tslint:disable-next-line:component-selector
@ -26,25 +36,41 @@ import { TbPopoverService } from '@shared/components/popover.component';
})
export class HelpPopupComponent implements OnDestroy {
@ViewChild('toggleHelpButton', {read: ElementRef, static: false}) toggleHelpButton: ElementRef;
@ViewChild('toggleHelpSpan', {read: ElementRef, static: false}) toggleHelpSpan: ElementRef;
// tslint:disable-next-line:no-input-rename
@Input('tb-help-popup') helpId: string;
// tslint:disable-next-line:no-input-rename
@Input('trigger-text') triggerText: string;
// tslint:disable-next-line:no-input-rename
@Input('tb-help-popup-placement') helpPopupPlacement: PopoverPlacement;
// tslint:disable-next-line:no-input-rename
@Input('tb-help-popup-style') helpPopupStyle: { [klass: string]: any } = {};
popoverVisible = false;
popoverReady = true;
constructor(private elementRef: ElementRef,
private viewContainerRef: ViewContainerRef,
constructor(private viewContainerRef: ViewContainerRef,
private renderer: Renderer2,
private popoverService: TbPopoverService) {}
toggleHelp() {
this.popoverService.toggleHelpPopover(this.elementRef.nativeElement, this.renderer, this.viewContainerRef,
const trigger = this.triggerText ? this.toggleHelpSpan.nativeElement : this.toggleHelpButton.nativeElement;
this.popoverService.toggleHelpPopover(trigger, this.renderer, this.viewContainerRef,
this.helpId,
'',
(visible) => {
this.popoverVisible = visible;
}, (ready => {
this.popoverReady = ready;
}));
}),
this.helpPopupPlacement,
{},
this.helpPopupStyle);
}
ngOnDestroy(): void {

5
ui-ngx/src/app/shared/components/json-form/json-form.component.ts

@ -46,8 +46,7 @@ import { GroupInfo } from '@shared/models/widget.models';
import { Observable } from 'rxjs/internal/Observable';
import { forkJoin, from } from 'rxjs';
import { MouseEvent } from 'react';
import { TbPopoverService } from '@shared/components/popover.component';
import { HelpMarkdownComponent } from '@shared/components/help-markdown.component';
import { TbPopoverService } from '@shared/components/popover.service';
const tinycolor = tinycolor_;
@ -252,7 +251,7 @@ export class JsonFormComponent implements OnInit, ControlValueAccessor, Validato
private onHelpClick(event: MouseEvent, helpId: string, helpVisibleFn: (visible: boolean) => void, helpReadyFn: (ready: boolean) => void) {
const trigger = event.currentTarget as Element;
this.popoverService.toggleHelpPopover(trigger, this.renderer, this.viewContainerRef, helpId, helpVisibleFn, helpReadyFn);
this.popoverService.toggleHelpPopover(trigger, this.renderer, this.viewContainerRef, helpId, '', helpVisibleFn, helpReadyFn);
}
private updateAndRender() {

21
ui-ngx/src/app/shared/components/markdown.component.html

@ -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>

20
ui-ngx/src/app/shared/components/markdown.component.scss

@ -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;
}
}

154
ui-ngx/src/app/shared/components/markdown.component.ts

@ -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, '&#123;').replace(/}/g, '&#125;');
}
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();
}
}
}
}

4
ui-ngx/src/app/shared/components/marked-options.service.ts

@ -85,7 +85,7 @@ export class MarkedOptionsService extends MarkedOptions {
private wrapCopyCode(id: number, content: string, code: string): string {
return `<div class="code-wrapper noChars" id="codeWrapper${id}" onClick="markdownCopyCode(${id})">${content}` +
`<span id="copyCodeId${id}" style="display: none;">${code}</span>` +
`<span id="copyCodeId${id}" style="display: none;">${encodeURIComponent(code)}</span>` +
`<button class="clipboard-btn">\n` +
` <p>${this.translate.instant('markdown.copy-code')}</p>\n` +
` <div>\n` +
@ -119,7 +119,7 @@ export class MarkedOptionsService extends MarkedOptions {
private markdownCopyCode(id: number) {
const copyWrapper = $('#codeWrapper' + id);
if (copyWrapper.hasClass('noChars')) {
const text = $('#copyCodeId' + id).text();
const text = decodeURIComponent($('#copyCodeId' + id).text());
this.window.navigator.clipboard.writeText(text).then(() => {
import('tooltipster').then(
() => {

164
ui-ngx/src/app/shared/components/popover.component.ts

@ -25,7 +25,6 @@ import {
Directive,
ElementRef,
EventEmitter,
Injectable,
Injector,
Input,
OnChanges,
@ -36,7 +35,6 @@ import {
Renderer2,
SimpleChanges,
TemplateRef,
Type,
ViewChild,
ViewContainerRef,
ViewEncapsulation
@ -54,13 +52,11 @@ import {
getPlacementName,
popoverMotion,
PopoverPlacement,
PopoverWithTrigger,
POSITION_MAP,
PropertyMapping
} from '@shared/components/popover.models';
import { distinctUntilChanged, takeUntil } from 'rxjs/operators';
import { isNotEmptyStr, onParentScrollOrWindowResize } from '@core/utils';
import { HelpMarkdownComponent } from '@shared/components/help-markdown.component';
export type TbPopoverTrigger = 'click' | 'focus' | 'hover' | null;
@ -285,162 +281,6 @@ export class TbPopoverDirective implements OnChanges, OnDestroy, AfterViewInit {
}
}
@Injectable()
export class TbPopoverService {
private popoverWithTriggers: PopoverWithTrigger[] = [];
componentFactory: ComponentFactory<TbPopoverComponent> = this.resolver.resolveComponentFactory(TbPopoverComponent);
constructor(private resolver: ComponentFactoryResolver) {
}
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: string,
visibleFn: (visible: boolean) => void, readyFn: (ready: boolean) => void) {
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 = { opacity: '0' };
component.setOverlayOrigin({ elementRef: originElementRef });
component.tbPlacement = 'bottom';
component.tbComponentFactory = this.resolver.resolveComponentFactory(HelpMarkdownComponent);
component.tbComponentInjector = injector;
component.tbComponentContext = {
helpId,
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 = { opacity: '1' };
component.tbAnimationState = 'active';
component.updatePosition();
readyFn(true);
setTimeout(() => {
component.updatePosition();
});
};
const setupHelpMarkdownComponent = (helpMarkdownComponent: HelpMarkdownComponent) => {
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);
}
}
@Component({
selector: 'tb-popover',
exportAs: 'tbPopoverComponent',
@ -703,10 +543,12 @@ export class TbPopoverComponent implements OnDestroy, OnInit {
updateStyles(): void {
this.classMap = {
[this.tbOverlayClassName]: true,
[`tb-popover-placement-${this.preferredPlacement}`]: true,
['tb-popover-hidden']: this.tbHidden || !this.lastIsIntersecting
};
if (this.tbOverlayClassName) {
this.classMap[this.tbOverlayClassName] = true;
}
}
setOverlayOrigin(origin: CdkOverlayOrigin): void {

190
ui-ngx/src/app/shared/components/popover.service.ts

@ -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);
}
}

24
ui-ngx/src/app/shared/components/tokens.ts

@ -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');

2
ui-ngx/src/app/shared/models/constants.ts

@ -54,7 +54,7 @@ export const MediaBreakpoints = {
'gt-xl': 'screen and (min-width: 5001px)'
};
const helpBaseUrl = 'https://thingsboard.io';
export const helpBaseUrl = 'https://thingsboard.io';
export const HelpLinks = {
linksMap: {

9
ui-ngx/src/app/shared/shared.module.ts

@ -147,11 +147,14 @@ import { MAT_DATE_LOCALE } from '@angular/material/core';
import { CopyButtonComponent } from '@shared/components/button/copy-button.component';
import { TogglePasswordComponent } from '@shared/components/button/toggle-password.component';
import { HelpPopupComponent } from '@shared/components/help-popup.component';
import { TbPopoverComponent, TbPopoverDirective, TbPopoverService } from '@shared/components/popover.component';
import { TbPopoverComponent, TbPopoverDirective } from '@shared/components/popover.component';
import { TbStringTemplateOutletDirective } from '@shared/components/directives/sring-template-outlet.directive';
import { TbComponentOutletDirective} from '@shared/components/directives/component-outlet.directive';
import { HelpMarkdownComponent } from '@shared/components/help-markdown.component';
import { MarkedOptionsService } from '@shared/components/marked-options.service';
import { TbPopoverService } from '@shared/components/popover.service';
import { HELP_MARKDOWN_COMPONENT_TOKEN, SHARED_MODULE_TOKEN } from '@shared/components/tokens';
import { TbMarkdownComponent } from '@shared/components/markdown.component';
export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) {
return markedOptionsService;
@ -174,6 +177,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
provide: MAT_DATE_LOCALE,
useValue: 'en-GB'
},
{ provide: HELP_MARKDOWN_COMPONENT_TOKEN, useValue: HelpMarkdownComponent },
{ provide: SHARED_MODULE_TOKEN, useValue: SharedModule },
TbPopoverService
],
declarations: [
@ -190,6 +195,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
TbStringTemplateOutletDirective,
TbComponentOutletDirective,
TbPopoverDirective,
TbMarkdownComponent,
HelpComponent,
HelpMarkdownComponent,
HelpPopupComponent,
@ -336,6 +342,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
TbStringTemplateOutletDirective,
TbComponentOutletDirective,
TbPopoverDirective,
TbMarkdownComponent,
HelpComponent,
HelpMarkdownComponent,
HelpPopupComponent,

92
ui-ngx/src/assets/help/en_US/widget/action/custom_action_fn.md

@ -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}
```

46
ui-ngx/src/assets/help/en_US/widget/action/custom_additional_params.md

@ -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>

74
ui-ngx/src/assets/help/en_US/widget/action/custom_pretty_action_fn.md

@ -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>

160
ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_create_dialog_html.md

@ -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}
```

132
ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_create_dialog_js.md

@ -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}
```

192
ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_edit_dialog_html.md

@ -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}
```

220
ui-ngx/src/assets/help/en_US/widget/action/examples/custom_pretty_edit_dialog_js.md

@ -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}
```

47
ui-ngx/src/assets/help/en_US/widget/action/show_widget_action_cell_fn.md

@ -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}
```

6
ui-ngx/src/assets/help/en_US/widget/action/show_widget_action_header_fn.md

@ -5,15 +5,15 @@
*function (widgetContext, data): boolean*
JavaScript function evaluating whether to display particular widget header action.
A JavaScript function evaluating whether to display particular widget header action.
**Parameters:**
<ul>
<li><b>widgetContext</b> - 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<br/>
<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> - An array of <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> objects.<br/>
<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> - An array of <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> objects.<br/>
Each object 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>

59
ui-ngx/src/assets/help/en_US/widget/config/datakey_generation_fn.md

@ -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}
```

56
ui-ngx/src/assets/help/en_US/widget/config/datakey_postprocess_fn.md

@ -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}
```

35
ui-ngx/src/assets/help/en_US/widget/lib/tooltip_value_format_fn.md

@ -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}
```

15
ui-ngx/src/styles.scss

@ -516,12 +516,15 @@ mat-label {
}
p, div {
padding-left: 32px;
padding-right: 32px;
color: rgba(15, 22, 29, 0.8);
line-height: 1.5em;
}
p, div {
padding-left: 32px;
}
ul {
padding-left: 62px;
padding-right: 32px;
@ -541,6 +544,12 @@ mat-label {
li {
padding-bottom: .75em;
line-height: 1.5em;
ul {
margin-bottom: 0;
}
p {
padding-left: 0;
}
}
a {
@ -651,7 +660,7 @@ mat-label {
line-height: 24px;
color: #fff;
background-color: #305680;
box-shadow: 0px 1px 5px rgba(0, 0, 0, 0.12), 0px 2px 2px rgba(0, 0, 0, 0.14), 0px 3px 1px -2px rgba(0, 0, 0, 0.2);
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.12), 0 2px 2px rgba(0, 0, 0, 0.14), 0 3px 1px -2px rgba(0, 0, 0, 0.2);
text-decoration: none;
font-size: 16px;
font-weight: 500;
@ -777,7 +786,7 @@ mat-label {
button.clipboard-btn {
top: -10px;
right: 0px;
right: 0;
padding: 0 3px;
}
}

52
ui-ngx/yarn.lock

@ -1824,10 +1824,10 @@
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.170.tgz#0d67711d4bf7f4ca5147e9091b847479b87925d6"
integrity sha512-bpcvu/MKHHeYX+qeEN8GE7DIravODWdACVA1ctevD8CN24RhPZIKMn9ntfAsrvLfSX3cR5RrBKAbYm9bGs0A+Q==
"@types/marked@^1.1.0":
version "1.2.2"
resolved "https://registry.yarnpkg.com/@types/marked/-/marked-1.2.2.tgz#1f858a0e690247ecf3b2eef576f98f86e8d960d4"
integrity sha512-wLfw1hnuuDYrFz97IzJja0pdVsC0oedtS4QsKH1/inyW9qkLQbXgMUqEQT0MVtUBx3twjWeInUfjQbhBVLECXw==
"@types/marked@^2.0.0":
version "2.0.5"
resolved "https://registry.yarnpkg.com/@types/marked/-/marked-2.0.5.tgz#453e27f1e97199d45bb25297b0dd2b9bbc1e05ea"
integrity sha512-shRZ7XnYFD/8n8zSjKvFdto1QNSf4tONZIlNEZGrJe8GsOE8DL/hG1Hbl8gZlfLnjS7+f5tZGIaTgfpyW38h4w==
"@types/minimatch@*":
version "3.0.3"
@ -3268,6 +3268,11 @@ commander@^2.11.0, commander@^2.12.1, commander@^2.19.0, commander@^2.20.0:
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
commander@^6.0.0:
version "6.2.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c"
integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==
commander@^7.1.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
@ -6171,12 +6176,12 @@ karma@~6.3.2:
ua-parser-js "^0.7.23"
yargs "^16.1.1"
katex@^0.12.0:
version "0.12.0"
resolved "https://registry.yarnpkg.com/katex/-/katex-0.12.0.tgz#2fb1c665dbd2b043edcf8a1f5c555f46beaa0cb9"
integrity sha512-y+8btoc/CK70XqcHqjxiGWBOeIL8upbS0peTPXTvgrh21n1RiWWcIpSWM+4uXq+IAgNh9YYQWdc7LVDPDAEEAg==
katex@^0.13.0:
version "0.13.18"
resolved "https://registry.yarnpkg.com/katex/-/katex-0.13.18.tgz#ba89e8e4b70cc2325e25e019a62b9fe71e5c2931"
integrity sha512-a3dC4NSVSDU3O1WZbTnOiA8rVNJ2lSiomOl0kmckCIGObccIHXof7gAseIY0o1gjEspe+34ZeSEX2D1ChFKIvA==
dependencies:
commander "^2.19.0"
commander "^6.0.0"
killable@^1.0.1:
version "1.0.1"
@ -6484,10 +6489,10 @@ map-visit@^1.0.0:
dependencies:
object-visit "^1.0.0"
marked@^1.1.0:
version "1.2.9"
resolved "https://registry.yarnpkg.com/marked/-/marked-1.2.9.tgz#53786f8b05d4c01a2a5a76b7d1ec9943d29d72dc"
integrity sha512-H8lIX2SvyitGX+TRdtS06m1jHMijKN/XjfH6Ooii9fvxMlh8QdqBfBDkGUpMWH2kQNrtixjzYUa3SH8ROTgRRw==
marked@^2.0.0:
version "2.1.3"
resolved "https://registry.yarnpkg.com/marked/-/marked-2.1.3.tgz#bd017cef6431724fd4b27e0657f5ceb14bff3753"
integrity sha512-/Q+7MGzaETqifOMWYEA7HVMaZb4XbcRfaOzcSsHZEith83KGlvaSG33u0SKu89Mj5h+T8V2hM+8O45Qc5XTgwA==
material-design-icons@^3.0.1:
version "3.0.1"
@ -6941,16 +6946,16 @@ ngx-hm-carousel@^2.0.0-rc.1:
hammerjs "^2.0.8"
resize-observer-polyfill "^1.5.1"
ngx-markdown@^10.1.1:
version "10.1.1"
resolved "https://registry.yarnpkg.com/ngx-markdown/-/ngx-markdown-10.1.1.tgz#17840c773db7ced4b18ccbf2e8cb06182e422de3"
integrity sha512-bUVgN6asb35d5U4xM5CNfo7pSpuwqJSdTgK0PhNZzLiaiyPIK2owtLF6sWGhxTThJu+LngJPjj4MQ+AFe/s8XQ==
ngx-markdown@^11.1.3:
version "11.1.3"
resolved "https://registry.yarnpkg.com/ngx-markdown/-/ngx-markdown-11.1.3.tgz#4d01c2dc425e5a46d1b6aa8517d9a1c1feaa1efd"
integrity sha512-z32q8l76ubrcP62L03mdvrizwueLBHV10LkT8MEDnFcjmY+8J1PytxFJ9EBTJpvc+CaPolgAoi7felN2XJZTSg==
dependencies:
"@types/marked" "^1.1.0"
"@types/marked" "^2.0.0"
emoji-toolkit "^6.0.1"
katex "^0.12.0"
marked "^1.1.0"
prismjs "^1.20.0"
katex "^0.13.0"
marked "^2.0.0"
prismjs "^1.23.0"
tslib "^2.0.0"
ngx-sharebuttons@^8.0.5:
@ -7985,11 +7990,6 @@ pretty-bytes@^5.3.0:
resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb"
integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==
prismjs@^1.20.0:
version "1.24.1"
resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.24.1.tgz#c4d7895c4d6500289482fa8936d9cdd192684036"
integrity sha512-mNPsedLuk90RVJioIky8ANZEwYm5w9LcvCXrxHlwf4fNVSn8jEipMybMkWUyyF0JhnC+C4VcOVSBuHRKs1L5Ow==
prismjs@^1.23.0:
version "1.23.0"
resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.23.0.tgz#d3b3967f7d72440690497652a9d40ff046067f33"

Loading…
Cancel
Save