Browse Source

Add popover directive/services. Add help directive with markdown. Fix leaflet map memory leaks.

pull/5333/head
Igor Kulikov 5 years ago
parent
commit
ff293ae1f4
  1. 4
      application/src/main/data/json/system/widget_bundles/cards.json
  2. 6
      application/src/main/data/json/system/widget_bundles/input_widgets.json
  3. 18
      application/src/main/data/json/system/widget_bundles/maps.json
  4. 8
      ui-ngx/angular.json
  5. 5
      ui-ngx/src/app/core/api/widget-api.models.ts
  6. 71
      ui-ngx/src/app/core/services/help.service.ts
  7. 8
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html
  8. 16
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts
  9. 3
      ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.html
  10. 3
      ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.ts
  11. 4
      ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts
  12. 106
      ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html
  13. 155
      ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts
  14. 3
      ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.models.ts
  15. 84
      ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet-map.ts
  16. 3
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.interface.ts
  17. 6
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget2.ts
  18. 2
      ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.html
  19. 131
      ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.scss
  20. 11
      ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.ts
  21. 62
      ui-ngx/src/app/modules/home/components/widget/widget.component.ts
  22. 12
      ui-ngx/src/app/modules/home/models/dashboard-component.models.ts
  23. 30
      ui-ngx/src/app/modules/home/models/widget-component.models.ts
  24. 127
      ui-ngx/src/app/shared/components/directives/component-outlet.directive.ts
  25. 118
      ui-ngx/src/app/shared/components/directives/sring-template-outlet.directive.ts
  26. 20
      ui-ngx/src/app/shared/components/help-markdown.component.html
  27. 33
      ui-ngx/src/app/shared/components/help-markdown.component.scss
  28. 104
      ui-ngx/src/app/shared/components/help-markdown.component.ts
  29. 30
      ui-ngx/src/app/shared/components/help-popup.component.html
  30. 21
      ui-ngx/src/app/shared/components/help-popup.component.scss
  31. 53
      ui-ngx/src/app/shared/components/help-popup.component.ts
  32. 1
      ui-ngx/src/app/shared/components/js-func.component.html
  33. 16
      ui-ngx/src/app/shared/components/js-func.component.scss
  34. 2
      ui-ngx/src/app/shared/components/js-func.component.ts
  35. 20
      ui-ngx/src/app/shared/components/json-form/json-form.component.ts
  36. 40
      ui-ngx/src/app/shared/components/json-form/react/json-form-ace-editor.tsx
  37. 3
      ui-ngx/src/app/shared/components/json-form/react/json-form-array.tsx
  38. 2
      ui-ngx/src/app/shared/components/json-form/react/json-form-fieldset.tsx
  39. 21
      ui-ngx/src/app/shared/components/json-form/react/json-form-schema-form.tsx
  40. 9
      ui-ngx/src/app/shared/components/json-form/react/json-form.models.ts
  41. 12
      ui-ngx/src/app/shared/components/json-form/react/json-form.scss
  42. 99
      ui-ngx/src/app/shared/components/markdown.factory.ts
  43. 143
      ui-ngx/src/app/shared/components/marked-options.service.ts
  44. 203
      ui-ngx/src/app/shared/components/popover.component.scss
  45. 725
      ui-ngx/src/app/shared/components/popover.component.ts
  46. 95
      ui-ngx/src/app/shared/components/popover.models.ts
  47. 8
      ui-ngx/src/app/shared/models/widget.models.ts
  48. 23
      ui-ngx/src/app/shared/shared.module.ts
  49. 4
      ui-ngx/src/assets/copy-code-icon.svg
  50. 5
      ui-ngx/src/assets/help/en_US/widget/action/show_widget_action_cell_fn.md
  51. 41
      ui-ngx/src/assets/help/en_US/widget/action/show_widget_action_header_fn.md
  52. 12
      ui-ngx/src/assets/help/en_US/widget/lib/tooltip_value_format_fn.md
  53. 28
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  54. 12
      ui-ngx/src/scss/mixins.scss
  55. 360
      ui-ngx/src/styles.scss

4
application/src/main/data/json/system/widget_bundles/cards.json

@ -179,11 +179,11 @@
"sizeY": 3.5, "sizeY": 3.5,
"resources": [], "resources": [],
"templateHtml": "<tb-markdown-widget \n [ctx]=\"ctx\">\n</tb-markdown-widget>", "templateHtml": "<tb-markdown-widget \n [ctx]=\"ctx\">\n</tb-markdown-widget>",
"templateCss": "#container {\n overflow: auto;\n}", "templateCss": "#container tb-markdown-widget {\n height: 100%;\n display: block;\n}\n\n#container tb-markdown-widget .tb-markdown-view {\n height: 100%;\n overflow: auto;\n}\n",
"controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.markdownWidget.onDataUpdated();\n}\n\nself.actionSources = function() {\n return {\n 'elementClick': {\n name: 'widget-action.element-click',\n multiple: true\n }\n };\n}\n\nself.onDestroy = function() {\n}\n\n", "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.markdownWidget.onDataUpdated();\n}\n\nself.actionSources = function() {\n return {\n 'elementClick': {\n name: 'widget-action.element-click',\n multiple: true\n }\n };\n}\n\nself.onDestroy = function() {\n}\n\n",
"settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Markdown card\",\n \"properties\": {\n \"markdownTextPattern\": {\n \"title\": \"Markdown pattern (markdown with variables, for ex. '${entityName} or ${keyName} - some text.')\",\n \"type\": \"string\",\n \"default\": \"# Markdown card \\n - **Current entity**: **${entityName}**. \\n - **Current value**: **${Random}**.\"\n },\n \"markdownCss\": {\n \"title\": \"Markdown CSS\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"useMarkdownTextFunction\": {\n \"title\": \"Use markdown text function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"markdownTextFunction\": {\n \"title\": \"Markdown text function: f(data)\",\n \"type\": \"string\",\n \"default\": \"return '# Some title\\\\n - Entity name: ' + data[0]['entityName'];\"\n }\n },\n \"required\": []\n },\n \"form\": [\n {\n \"key\": \"markdownTextPattern\",\n \"type\": \"markdown\"\n },\n {\n \"key\": \"markdownCss\",\n \"type\": \"css\"\n },\n \"useMarkdownTextFunction\",\n {\n \"key\": \"markdownTextFunction\",\n \"type\": \"javascript\"\n }\n ]\n}\n", "settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Markdown card\",\n \"properties\": {\n \"markdownTextPattern\": {\n \"title\": \"Markdown pattern (markdown with variables, for ex. '${entityName} or ${keyName} - some text.')\",\n \"type\": \"string\",\n \"default\": \"# Markdown card \\n - **Current entity**: **${entityName}**. \\n - **Current value**: **${Random}**.\"\n },\n \"markdownCss\": {\n \"title\": \"Markdown CSS\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"useMarkdownTextFunction\": {\n \"title\": \"Use markdown text function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"markdownTextFunction\": {\n \"title\": \"Markdown text function: f(data)\",\n \"type\": \"string\",\n \"default\": \"return '# Some title\\\\n - Entity name: ' + data[0]['entityName'];\"\n }\n },\n \"required\": []\n },\n \"form\": [\n {\n \"key\": \"markdownTextPattern\",\n \"type\": \"markdown\"\n },\n {\n \"key\": \"markdownCss\",\n \"type\": \"css\"\n },\n \"useMarkdownTextFunction\",\n {\n \"key\": \"markdownTextFunction\",\n \"type\": \"javascript\"\n }\n ]\n}\n",
"dataKeySettingsSchema": "{}\n", "dataKeySettingsSchema": "{}\n",
"defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"16px\",\"settings\":{\"markdownTextPattern\":\"### Markdown card\\n - **Current entity**: ${entityName}.\\n - **Current value**: ${Random}.\",\"markdownTextFunction\":\"return '# Some title\\\\n - Entity name: ' + data[0]['entityName'];\"},\"title\":\"Markdown Card\",\"showTitleIcon\":false,\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"showLegend\":false}" "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"markdownTextPattern\":\"### Markdown card\\n - **Current entity**: ${entityName}.\\n - **Current value**: ${Random}.\",\"markdownTextFunction\":\"return '# Some title\\\\n - Entity name: ' + data[0]['entityName'];\"},\"title\":\"Markdown Card\",\"showTitleIcon\":false,\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"showLegend\":false}"
} }
} }
] ]

6
application/src/main/data/json/system/widget_bundles/input_widgets.json

File diff suppressed because one or more lines are too long

18
application/src/main/data/json/system/widget_bundles/maps.json

File diff suppressed because one or more lines are too long

8
ui-ngx/angular.json

@ -79,7 +79,8 @@
"src/app/modules/home/components/widget/lib/maps/markers.scss", "src/app/modules/home/components/widget/lib/maps/markers.scss",
"node_modules/leaflet.markercluster/dist/MarkerCluster.css", "node_modules/leaflet.markercluster/dist/MarkerCluster.css",
"node_modules/leaflet.markercluster/dist/MarkerCluster.Default.css", "node_modules/leaflet.markercluster/dist/MarkerCluster.Default.css",
"node_modules/prismjs/themes/prism.css" "node_modules/prismjs/themes/prism.css",
"node_modules/prismjs/plugins/line-numbers/prism-line-numbers.css"
], ],
"stylePreprocessorOptions": { "stylePreprocessorOptions": {
"includePaths": [ "includePaths": [
@ -92,8 +93,11 @@
"node_modules/systemjs/dist/system.js", "node_modules/systemjs/dist/system.js",
"node_modules/marked/lib/marked.js", "node_modules/marked/lib/marked.js",
"node_modules/prismjs/prism.js", "node_modules/prismjs/prism.js",
"node_modules/prismjs/components/prism-css.min.js",
"node_modules/prismjs/components/prism-bash.min.js", "node_modules/prismjs/components/prism-bash.min.js",
"node_modules/prismjs/components/prism-json.min.js" "node_modules/prismjs/components/prism-json.min.js",
"node_modules/prismjs/components/prism-javascript.min.js",
"node_modules/prismjs/plugins/line-numbers/prism-line-numbers.js"
], ],
"customWebpackConfig": { "customWebpackConfig": {
"path": "./extra-webpack.config.js" "path": "./extra-webpack.config.js"

5
ui-ngx/src/app/core/api/widget-api.models.ts

@ -54,6 +54,7 @@ import { PageData } from '@shared/models/page/page-data';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { AlarmDataService } from '@core/api/alarm-data.service'; import { AlarmDataService } from '@core/api/alarm-data.service';
import { IDashboardController } from '@home/components/dashboard-page/dashboard-page.models'; import { IDashboardController } from '@home/components/dashboard-page/dashboard-page.models';
import { PopoverPlacement } from '@shared/components/popover.models';
export interface TimewindowFunctions { export interface TimewindowFunctions {
onUpdateTimewindow: (startTimeMs: number, endTimeMs: number, interval?: number) => void; onUpdateTimewindow: (startTimeMs: number, endTimeMs: number, interval?: number) => void;
@ -89,6 +90,10 @@ export interface WidgetActionsApi {
getActiveEntityInfo: () => SubscriptionEntityInfo; getActiveEntityInfo: () => SubscriptionEntityInfo;
openDashboardStateInSeparateDialog: (targetDashboardStateId: string, params?: StateParams, dialogTitle?: string, openDashboardStateInSeparateDialog: (targetDashboardStateId: string, params?: StateParams, dialogTitle?: string,
hideDashboardToolbar?: boolean, dialogWidth?: number, dialogHeight?: number) => void; hideDashboardToolbar?: boolean, dialogWidth?: number, dialogHeight?: number) => void;
openDashboardStateInPopover: ($event: Event, targetDashboardStateId: string, params?: StateParams,
hideDashboardToolbar?: boolean, preferredPlacement?: PopoverPlacement,
hideOnClickOutside?: boolean, popoverWidth?: string,
popoverHeight?: string, popoverStyle?: { [klass: string]: any }) => void;
} }
export interface AliasInfo { export interface AliasInfo {

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

@ -0,0 +1,71 @@
///
/// 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 { 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';
const NOT_FOUND_CONTENT = '## Not found';
@Injectable({
providedIn: 'root'
})
export class HelpService {
private helpCache: {[lang: string]: {[key: string]: string}} = {};
constructor(
private translate: TranslateService,
private http: HttpClient
) {}
getHelpContent(key: string): Observable<string> {
const lang = this.translate.currentLang;
if (this.helpCache[lang] && this.helpCache[lang][key]) {
return of(this.helpCache[lang][key]);
} else {
return this.loadHelpContent(lang, key).pipe(
catchError(() => {
const defaultLang = this.translate.getDefaultLang();
if (lang !== defaultLang) {
return this.loadHelpContent(defaultLang, key).pipe(
catchError(() => {
return of(NOT_FOUND_CONTENT);
})
);
} else {
return of(NOT_FOUND_CONTENT);
}
}),
tap((content) => {
let langContent = this.helpCache[lang];
if (!langContent) {
langContent = {};
this.helpCache[lang] = langContent;
}
langContent[key] = content;
})
);
}
}
private loadHelpContent(lang: string, key: string): Observable<string> {
return this.http.get(`/assets/help/${lang}/${key}.md`, {responseType: 'text'} );
}
}

8
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html

@ -176,7 +176,8 @@
[isEdit]="isEdit" [isEdit]="isEdit"
[isEditingWidget]="isEditingWidget" [isEditingWidget]="isEditingWidget"
[isMobile]="forceDashboardMobileMode" [isMobile]="forceDashboardMobileMode"
[widgetEditMode]="widgetEditMode"> [widgetEditMode]="widgetEditMode"
[parentDashboard]="parentDashboard">
</tb-dashboard-layout> </tb-dashboard-layout>
</mat-drawer> </mat-drawer>
<mat-drawer-content [fxShow]="layouts.main.show" <mat-drawer-content [fxShow]="layouts.main.show"
@ -190,7 +191,8 @@
[isEdit]="isEdit" [isEdit]="isEdit"
[isEditingWidget]="isEditingWidget" [isEditingWidget]="isEditingWidget"
[isMobile]="forceDashboardMobileMode" [isMobile]="forceDashboardMobileMode"
[widgetEditMode]="widgetEditMode"> [widgetEditMode]="widgetEditMode"
[parentDashboard]="parentDashboard">
</tb-dashboard-layout> </tb-dashboard-layout>
</mat-drawer-content> </mat-drawer-content>
</mat-drawer-container> </mat-drawer-container>
@ -219,7 +221,7 @@
<mat-icon>{{ isEdit ? 'close' : 'edit' }}</mat-icon> <mat-icon>{{ isEdit ? 'close' : 'edit' }}</mat-icon>
</button> </button>
</section> </section>
<section data-html2canvas-ignore class="tb-powered-by-footer" [ngStyle]="{'color': dashboard.configuration.settings.titleColor}"> <section *ngIf="!embedded" data-html2canvas-ignore class="tb-powered-by-footer" [ngStyle]="{'color': dashboard.configuration.settings.titleColor}">
<span>Powered by <a href="https://thingsboard.io" target="_blank">Thingsboard v.{{ thingsboardVersion }}</a></span> <span>Powered by <a href="https://thingsboard.io" target="_blank">Thingsboard v.{{ thingsboardVersion }}</a></span>
</section> </section>
</mat-drawer-content> </mat-drawer-content>

16
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts

@ -23,7 +23,7 @@ import {
Input, Input,
NgZone, NgZone,
OnDestroy, OnDestroy,
OnInit, OnInit, Optional,
StaticProvider, StaticProvider,
ViewChild, ViewChild,
ViewContainerRef, ViewContainerRef,
@ -80,7 +80,11 @@ import { Observable, of, Subscription } from 'rxjs';
import { FooterFabButtons } from '@shared/components/footer-fab-buttons.component'; import { FooterFabButtons } from '@shared/components/footer-fab-buttons.component';
import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; import { DashboardUtilsService } from '@core/services/dashboard-utils.service';
import { DashboardService } from '@core/http/dashboard.service'; import { DashboardService } from '@core/http/dashboard.service';
import { DashboardContextMenuItem, WidgetContextMenuItem } from '../../models/dashboard-component.models'; import {
DashboardContextMenuItem,
IDashboardComponent,
WidgetContextMenuItem
} from '../../models/dashboard-component.models';
import { WidgetComponentService } from '../../components/widget/widget-component.service'; import { WidgetComponentService } from '../../components/widget/widget-component.service';
import { FormBuilder } from '@angular/forms'; import { FormBuilder } from '@angular/forms';
import { ItemBufferService } from '@core/services/item-buffer.service'; import { ItemBufferService } from '@core/services/item-buffer.service';
@ -167,6 +171,9 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
dashboard: Dashboard; dashboard: Dashboard;
dashboardConfiguration: DashboardConfiguration; dashboardConfiguration: DashboardConfiguration;
@Input()
parentDashboard?: IDashboardComponent = null;
@ViewChild('dashboardContainer') dashboardContainer: ElementRef<HTMLElement>; @ViewChild('dashboardContainer') dashboardContainer: ElementRef<HTMLElement>;
prevDashboard: Dashboard; prevDashboard: Dashboard;
@ -309,12 +316,15 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC
private dialog: MatDialog, private dialog: MatDialog,
private translate: TranslateService, private translate: TranslateService,
private ngZone: NgZone, private ngZone: NgZone,
@Optional() @Inject('embeddedValue') private embeddedValue,
private overlay: Overlay, private overlay: Overlay,
private viewContainerRef: ViewContainerRef, private viewContainerRef: ViewContainerRef,
private cd: ChangeDetectorRef, private cd: ChangeDetectorRef,
private sanitizer: DomSanitizer) { private sanitizer: DomSanitizer) {
super(store); super(store);
if (isDefinedAndNotNull(embeddedValue)) {
this.embedded = embeddedValue;
}
} }
ngOnInit() { ngOnInit() {

3
ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.html

@ -57,6 +57,7 @@
[isExportActionEnabled]="isEdit && !widgetEditMode" [isExportActionEnabled]="isEdit && !widgetEditMode"
[isRemoveActionEnabled]="isEdit && !widgetEditMode" [isRemoveActionEnabled]="isEdit && !widgetEditMode"
[callbacks]="this" [callbacks]="this"
[ignoreLoading]="layoutCtx.ignoreLoading"> [ignoreLoading]="layoutCtx.ignoreLoading"
[parentDashboard]="parentDashboard">
</tb-dashboard> </tb-dashboard>
</div> </div>

3
ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.ts

@ -78,6 +78,9 @@ export class DashboardLayoutComponent extends PageComponent implements ILayoutCo
@Input() @Input()
widgetEditMode: boolean; widgetEditMode: boolean;
@Input()
parentDashboard?: IDashboardComponent = null;
@ViewChild('dashboard', {static: true}) dashboard: IDashboardComponent; @ViewChild('dashboard', {static: true}) dashboard: IDashboardComponent;
private rxSubscriptions = new Array<Subscription>(); private rxSubscriptions = new Array<Subscription>();

4
ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts

@ -133,6 +133,9 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo
@Input() @Input()
dashboardTimewindow: Timewindow; dashboardTimewindow: Timewindow;
@Input()
parentDashboard?: IDashboardComponent = null;
dashboardTimewindowChangedSubject: Subject<Timewindow> = new ReplaySubject<Timewindow>(); dashboardTimewindowChangedSubject: Subject<Timewindow> = new ReplaySubject<Timewindow>();
dashboardTimewindowChanged = this.dashboardTimewindowChangedSubject.asObservable().pipe( dashboardTimewindowChanged = this.dashboardTimewindowChangedSubject.asObservable().pipe(
@ -187,6 +190,7 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo
} }
ngOnInit(): void { ngOnInit(): void {
this.dashboardWidgets.parentDashboard = this.parentDashboard;
if (!this.dashboardTimewindow) { if (!this.dashboardTimewindow) {
this.dashboardTimewindow = this.timeService.defaultTimewindow(); this.dashboardTimewindow = this.timeService.defaultTimewindow();
} }

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

@ -15,7 +15,7 @@
limitations under the License. limitations under the License.
--> -->
<form [formGroup]="widgetActionFormGroup" (ngSubmit)="save()" style="min-width: 480px;"> <form [formGroup]="widgetActionFormGroup" (ngSubmit)="save()" style="width: 800px;">
<mat-toolbar color="primary"> <mat-toolbar color="primary">
<h2>{{ (isAdd ? 'widget-config.add-action' : 'widget-config.edit-action' ) | translate }}</h2> <h2>{{ (isAdd ? 'widget-config.add-action' : 'widget-config.edit-action' ) | translate }}</h2>
<span fxFlex></span> <span fxFlex></span>
@ -59,6 +59,7 @@
</mat-checkbox> </mat-checkbox>
<tb-js-func *ngIf="displayShowWidgetActionForm() && widgetActionFormGroup.get('useShowWidgetActionFunction').value" <tb-js-func *ngIf="displayShowWidgetActionForm() && widgetActionFormGroup.get('useShowWidgetActionFunction').value"
formControlName="showWidgetActionFunction" formControlName="showWidgetActionFunction"
[helpId]="getWidgetActionFunctionHelpId()"
[functionArgs]="['widgetContext', 'data']" [functionArgs]="['widgetContext', 'data']"
[globalVariables]="functionScopeVariables" [globalVariables]="functionScopeVariables"
[validationArgs]="[]" [validationArgs]="[]"
@ -145,37 +146,75 @@
</ng-template> </ng-template>
<ng-template [ngSwitchCase]="widgetActionFormGroup.get('type').value === widgetActionType.openDashboardState ? <ng-template [ngSwitchCase]="widgetActionFormGroup.get('type').value === widgetActionType.openDashboardState ?
widgetActionFormGroup.get('type').value : ''"> widgetActionFormGroup.get('type').value : ''">
<mat-checkbox formControlName="openInSeparateDialog"> <mat-form-field class="mat-block">
{{ 'widget-action.open-in-separate-dialog' | translate }} <mat-label translate>widget-action.state-display-type</mat-label>
</mat-checkbox> <mat-select formControlName="stateDisplayType">
<section *ngIf="actionTypeFormGroup.get('openInSeparateDialog').value"> <mat-option *ngFor="let displayType of allStateDisplayTypes" [value]="displayType">
<mat-form-field class="mat-block"> {{ stateDisplayTypeName(displayType) }}
<mat-label translate>widget-action.dialog-title</mat-label> </mat-option>
<input matInput formControlName="dialogTitle"> </mat-select>
</mat-form-field> </mat-form-field>
<mat-checkbox formControlName="dialogHideDashboardToolbar"> <section fxLayout="column" [formGroup]="stateDisplayTypeFormGroup" [ngSwitch]="actionTypeFormGroup.get('stateDisplayType').value">
{{ 'widget-action.dialog-hide-dashboard-toolbar' | translate }} <ng-template [ngSwitchCase]="'separateDialog'">
</mat-checkbox> <mat-form-field class="mat-block">
<mat-form-field class="mat-block"> <mat-label translate>widget-action.dialog-title</mat-label>
<mat-label translate>widget-action.dialog-width</mat-label> <input matInput formControlName="dialogTitle">
<input type="number" min="1" max="100" step="1" matInput formControlName="dialogWidth"> </mat-form-field>
<mat-error *ngIf="actionTypeFormGroup.get('dialogWidth').hasError('min')"> <mat-checkbox formControlName="dialogHideDashboardToolbar">
{{ 'widget-action.dialog-size-range-error' | translate }} {{ 'widget-action.dialog-hide-dashboard-toolbar' | translate }}
</mat-error> </mat-checkbox>
<mat-error *ngIf="actionTypeFormGroup.get('dialogWidth').hasError('max')"> <mat-form-field class="mat-block">
{{ 'widget-action.dialog-size-range-error' | translate }} <mat-label translate>widget-action.dialog-width</mat-label>
</mat-error> <input type="number" min="1" max="100" step="1" matInput formControlName="dialogWidth">
</mat-form-field> <mat-error *ngIf="stateDisplayTypeFormGroup.get('dialogWidth').hasError('min')">
<mat-form-field class="mat-block"> {{ 'widget-action.dialog-size-range-error' | translate }}
<mat-label translate>widget-action.dialog-height</mat-label> </mat-error>
<input type="number" min="1" max="100" step="1" matInput formControlName="dialogHeight"> <mat-error *ngIf="stateDisplayTypeFormGroup.get('dialogWidth').hasError('max')">
<mat-error *ngIf="actionTypeFormGroup.get('dialogHeight').hasError('min')"> {{ 'widget-action.dialog-size-range-error' | translate }}
{{ 'widget-action.dialog-size-range-error' | translate }} </mat-error>
</mat-error> </mat-form-field>
<mat-error *ngIf="actionTypeFormGroup.get('dialogHeight').hasError('max')"> <mat-form-field class="mat-block">
{{ 'widget-action.dialog-size-range-error' | translate }} <mat-label translate>widget-action.dialog-height</mat-label>
</mat-error> <input type="number" min="1" max="100" step="1" matInput formControlName="dialogHeight">
</mat-form-field> <mat-error *ngIf="stateDisplayTypeFormGroup.get('dialogHeight').hasError('min')">
{{ 'widget-action.dialog-size-range-error' | translate }}
</mat-error>
<mat-error *ngIf="stateDisplayTypeFormGroup.get('dialogHeight').hasError('max')">
{{ 'widget-action.dialog-size-range-error' | translate }}
</mat-error>
</mat-form-field>
</ng-template>
<ng-template [ngSwitchCase]="'popover'">
<mat-form-field class="mat-block">
<mat-label translate>widget-action.popover-preferred-placement</mat-label>
<mat-select formControlName="popoverPreferredPlacement">
<mat-option *ngFor="let placement of allPopoverPlacements" [value]="placement">
{{ popoverPlacementName(placement) }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-checkbox formControlName="popoverHideOnClickOutside">
{{ 'widget-action.popover-hide-on-click-outside' | translate }}
</mat-checkbox>
<mat-checkbox formControlName="popoverHideDashboardToolbar">
{{ 'widget-action.popover-hide-dashboard-toolbar' | translate }}
</mat-checkbox>
<mat-form-field class="mat-block">
<mat-label translate>widget-action.popover-width</mat-label>
<input matInput formControlName="popoverWidth">
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>widget-action.popover-height</mat-label>
<input matInput formControlName="popoverHeight">
</mat-form-field>
<tb-json-object-edit
[editorStyle]="{minHeight: '100px'}"
label="{{ 'widget-action.popover-style' | translate }}"
formControlName="popoverStyle"
></tb-json-object-edit>
</ng-template>
<ng-template [ngSwitchCase]="'normal'">
</ng-template>
</section> </section>
</ng-template> </ng-template>
<ng-template [ngSwitchCase]="widgetActionType.custom"> <ng-template [ngSwitchCase]="widgetActionType.custom">
@ -209,7 +248,8 @@
</button> </button>
<button mat-raised-button color="primary" <button mat-raised-button color="primary"
type="submit" type="submit"
[disabled]="(isLoading$ | async) || widgetActionFormGroup.invalid || actionTypeFormGroup.invalid || (!widgetActionFormGroup.dirty && !actionTypeFormGroup.dirty)"> [disabled]="(isLoading$ | async) || widgetActionFormGroup.invalid || actionTypeFormGroup.invalid || stateDisplayTypeFormGroup?.invalid ||
(!widgetActionFormGroup.dirty && !actionTypeFormGroup.dirty && !stateDisplayTypeFormGroup?.dirty)">
{{ (isAdd ? 'action.add' : 'action.save') | translate }} {{ (isAdd ? 'action.add' : 'action.save') | translate }}
</button> </button>
</div> </div>

155
ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts

@ -28,7 +28,7 @@ import {
ValidatorFn, ValidatorFn,
Validators Validators
} from '@angular/forms'; } from '@angular/forms';
import { Observable, of } from 'rxjs'; import { Observable, of, Subscription } from 'rxjs';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { DialogComponent } from '@app/shared/components/dialog.component'; import { DialogComponent } from '@app/shared/components/dialog.component';
import { import {
@ -52,6 +52,8 @@ import { isDefinedAndNotNull } from '@core/utils';
import { MobileActionEditorComponent } from '@home/components/widget/action/mobile-action-editor.component'; import { MobileActionEditorComponent } from '@home/components/widget/action/mobile-action-editor.component';
import { widgetType } from '@shared/models/widget.models'; import { widgetType } from '@shared/models/widget.models';
import { WidgetService } from '@core/http/widget.service'; import { WidgetService } from '@core/http/widget.service';
import { TranslateService } from '@ngx-translate/core';
import { PopoverPlacement, PopoverPlacements } from '@shared/components/popover.models';
export interface WidgetActionDialogData { export interface WidgetActionDialogData {
isAdd: boolean; isAdd: boolean;
@ -61,6 +63,18 @@ export interface WidgetActionDialogData {
widgetType: widgetType; widgetType: widgetType;
} }
const stateDisplayTypes = ['normal', 'separateDialog', 'popover'] as const;
type stateDisplayTypeTuple = typeof stateDisplayTypes;
export type stateDisplayType = stateDisplayTypeTuple[number];
const stateDisplayTypesTranslations = new Map<stateDisplayType, string>(
[
['normal', 'widget-action.open-normal'],
['separateDialog', 'widget-action.open-in-separate-dialog'],
['popover', 'widget-action.open-in-popover'],
]
);
@Component({ @Component({
selector: 'tb-widget-action-dialog', selector: 'tb-widget-action-dialog',
templateUrl: './widget-action-dialog.component.html', templateUrl: './widget-action-dialog.component.html',
@ -76,6 +90,8 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia
widgetActionFormGroup: FormGroup; widgetActionFormGroup: FormGroup;
actionTypeFormGroup: FormGroup; actionTypeFormGroup: FormGroup;
actionTypeFormGroupSubscriptions: Subscription[] = [];
stateDisplayTypeFormGroup: FormGroup;
isAdd: boolean; isAdd: boolean;
action: WidgetActionDescriptorInfo; action: WidgetActionDescriptorInfo;
@ -95,12 +111,16 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia
functionScopeVariables: string[]; functionScopeVariables: string[];
allStateDisplayTypes = stateDisplayTypes;
allPopoverPlacements = PopoverPlacements;
constructor(protected store: Store<AppState>, constructor(protected store: Store<AppState>,
protected router: Router, protected router: Router,
private utils: UtilsService, private utils: UtilsService,
private dashboardService: DashboardService, private dashboardService: DashboardService,
private dashboardUtils: DashboardUtilsService, private dashboardUtils: DashboardUtilsService,
private widgetService: WidgetService, private widgetService: WidgetService,
private translate: TranslateService,
@Inject(MAT_DIALOG_DATA) public data: WidgetActionDialogData, @Inject(MAT_DIALOG_DATA) public data: WidgetActionDialogData,
@SkipSelf() private errorStateMatcher: ErrorStateMatcher, @SkipSelf() private errorStateMatcher: ErrorStateMatcher,
public dialogRef: MatDialogRef<WidgetActionDialogComponent, WidgetActionDescriptorInfo>, public dialogRef: MatDialogRef<WidgetActionDialogComponent, WidgetActionDescriptorInfo>,
@ -152,6 +172,16 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia
return !!this.data.actionsData.actionSources[this.widgetActionFormGroup.get('actionSourceId').value]?.hasShowCondition; return !!this.data.actionsData.actionSources[this.widgetActionFormGroup.get('actionSourceId').value]?.hasShowCondition;
} }
getWidgetActionFunctionHelpId(): string | undefined {
const actionSourceId = this.widgetActionFormGroup.get('actionSourceId').value;
if (actionSourceId === 'headerButton') {
return 'widget/action/show_widget_action_header_fn';
} else if (actionSourceId === 'actionCellButton') {
return 'widget/action/show_widget_action_cell_fn';
}
return undefined;
}
private updateShowWidgetActionForm() { private updateShowWidgetActionForm() {
const actionSourceId = this.widgetActionFormGroup.get('actionSourceId').value; const actionSourceId = this.widgetActionFormGroup.get('actionSourceId').value;
const useShowWidgetActionFunction = this.widgetActionFormGroup.get('useShowWidgetActionFunction').value; const useShowWidgetActionFunction = this.widgetActionFormGroup.get('useShowWidgetActionFunction').value;
@ -164,6 +194,8 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia
} }
private updateActionTypeFormGroup(type?: WidgetActionType, action?: WidgetActionDescriptorInfo) { private updateActionTypeFormGroup(type?: WidgetActionType, action?: WidgetActionDescriptorInfo) {
this.actionTypeFormGroupSubscriptions.forEach(s => s.unsubscribe());
this.actionTypeFormGroupSubscriptions.length = 0;
this.actionTypeFormGroup = this.fb.group({}); this.actionTypeFormGroup = this.fb.group({});
if (type) { if (type) {
switch (type) { switch (type) {
@ -196,25 +228,16 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia
this.setupSelectedDashboardStateIds(action ? action.targetDashboardId : null); this.setupSelectedDashboardStateIds(action ? action.targetDashboardId : null);
} else { } else {
if (type === WidgetActionType.openDashboardState) { if (type === WidgetActionType.openDashboardState) {
const displayType = this.getStateDisplayType(action);
this.actionTypeFormGroup.addControl( this.actionTypeFormGroup.addControl(
'openInSeparateDialog', 'stateDisplayType',
this.fb.control(action ? action.openInSeparateDialog : false, []) this.fb.control(this.getStateDisplayType(action), [Validators.required])
);
this.actionTypeFormGroup.addControl(
'dialogTitle',
this.fb.control(action ? action.dialogTitle : '', [])
);
this.actionTypeFormGroup.addControl(
'dialogHideDashboardToolbar',
this.fb.control(action && isDefinedAndNotNull(action.dialogHideDashboardToolbar) ? action.dialogHideDashboardToolbar : true, [])
);
this.actionTypeFormGroup.addControl(
'dialogWidth',
this.fb.control(action ? action.dialogWidth : null, [Validators.min(1), Validators.max(100)])
); );
this.actionTypeFormGroup.addControl( this.updateStateDisplayTypeFormGroup(displayType, action);
'dialogHeight', this.actionTypeFormGroupSubscriptions.push(
this.fb.control(action ? action.dialogHeight : null, [Validators.min(1), Validators.max(100)]) this.actionTypeFormGroup.get('stateDisplayType').valueChanges.subscribe((displayTypeValue: stateDisplayType) => {
this.updateStateDisplayTypeFormGroup(displayTypeValue);
})
); );
} }
this.actionTypeFormGroup.addControl( this.actionTypeFormGroup.addControl(
@ -246,6 +269,76 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia
} }
} }
private updateStateDisplayTypeFormGroup(displayType?: stateDisplayType, action?: WidgetActionDescriptorInfo) {
this.stateDisplayTypeFormGroup = this.fb.group({});
if (displayType) {
switch (displayType) {
case 'normal':
break;
case 'separateDialog':
this.stateDisplayTypeFormGroup.addControl(
'dialogTitle',
this.fb.control(action ? action.dialogTitle : '', [])
);
this.stateDisplayTypeFormGroup.addControl(
'dialogHideDashboardToolbar',
this.fb.control(action && isDefinedAndNotNull(action.dialogHideDashboardToolbar)
? action.dialogHideDashboardToolbar : true, [])
);
this.stateDisplayTypeFormGroup.addControl(
'dialogWidth',
this.fb.control(action ? action.dialogWidth : null, [Validators.min(1), Validators.max(100)])
);
this.stateDisplayTypeFormGroup.addControl(
'dialogHeight',
this.fb.control(action ? action.dialogHeight : null, [Validators.min(1), Validators.max(100)])
);
break;
case 'popover':
this.stateDisplayTypeFormGroup.addControl(
'popoverPreferredPlacement',
this.fb.control(action && isDefinedAndNotNull(action.popoverPreferredPlacement)
? action.popoverPreferredPlacement : 'top', [])
);
this.stateDisplayTypeFormGroup.addControl(
'popoverHideOnClickOutside',
this.fb.control(action && isDefinedAndNotNull(action.popoverHideOnClickOutside)
? action.popoverHideOnClickOutside : true, [])
);
this.stateDisplayTypeFormGroup.addControl(
'popoverHideDashboardToolbar',
this.fb.control(action && isDefinedAndNotNull(action.popoverHideDashboardToolbar)
? action.popoverHideDashboardToolbar : true, [])
);
this.stateDisplayTypeFormGroup.addControl(
'popoverWidth',
this.fb.control(action && isDefinedAndNotNull(action.popoverWidth) ? action.popoverWidth : '25vw', [])
);
this.stateDisplayTypeFormGroup.addControl(
'popoverHeight',
this.fb.control(action && isDefinedAndNotNull(action.popoverHeight) ? action.popoverHeight : '25vh', [])
);
this.stateDisplayTypeFormGroup.addControl(
'popoverStyle',
this.fb.control(action && isDefinedAndNotNull(action.popoverStyle) ? action.popoverStyle : {}, [])
);
break;
}
}
}
private getStateDisplayType(action?: WidgetActionDescriptorInfo): stateDisplayType {
let res: stateDisplayType = 'normal';
if (action) {
if (action.openInSeparateDialog) {
res = 'separateDialog';
} else if (action.openInPopover) {
res = 'popover';
}
}
return res;
}
private setupSelectedDashboardStateIds(targetDashboardId?: string) { private setupSelectedDashboardStateIds(targetDashboardId?: string) {
this.selectedDashboardStateIds = this.selectedDashboardStateIds =
this.actionTypeFormGroup.get('targetDashboardId').valueChanges.pipe( this.actionTypeFormGroup.get('targetDashboardId').valueChanges.pipe(
@ -352,6 +445,22 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia
} }
} }
public stateDisplayTypeName(displayType: stateDisplayType): string {
if (displayType) {
return this.translate.instant(stateDisplayTypesTranslations.get(displayType)) + '';
} else {
return '';
}
}
public popoverPlacementName(placement: PopoverPlacement): string {
if (placement) {
return this.translate.instant(`widget-action.popover-placement-${placement}`) + '';
} else {
return '';
}
}
cancel(): void { cancel(): void {
this.dialogRef.close(null); this.dialogRef.close(null);
} }
@ -369,6 +478,16 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia
} else { } else {
result = {...this.widgetActionFormGroup.value, ...this.actionTypeFormGroup.value}; result = {...this.widgetActionFormGroup.value, ...this.actionTypeFormGroup.value};
} }
if (this.actionTypeFormGroup.get('stateDisplayType') &&
this.actionTypeFormGroup.get('stateDisplayType').value !== 'normal') {
result = {...result, ...this.stateDisplayTypeFormGroup.value};
result.openInSeparateDialog = this.actionTypeFormGroup.get('stateDisplayType').value === 'separateDialog';
result.openInPopover = this.actionTypeFormGroup.get('stateDisplayType').value === 'popover';
} else {
result.openInSeparateDialog = false;
result.openInPopover = false;
}
delete (result as any).stateDisplayType;
result.id = this.action.id; result.id = this.action.id;
this.dialogRef.close(result); this.dialogRef.close(result);
} }

3
ui-ngx/src/app/modules/home/components/widget/lib/flot-widget.models.ts

@ -465,7 +465,8 @@ export function flotSettingsSchema(chartType: ChartType): JsonSettingsSchema {
schema.form.push('tooltipCumulative'); schema.form.push('tooltipCumulative');
schema.form.push({ schema.form.push({
key: 'tooltipValueFormatter', key: 'tooltipValueFormatter',
type: 'javascript' type: 'javascript',
helpId: 'widget/lib/tooltip_value_format_fn'
}); });
schema.form.push('hideZeros'); schema.form.push('hideZeros');
schema.form.push('showTooltip'); schema.form.push('showTooltip');

84
ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet-map.ts

@ -16,7 +16,7 @@
import L, { import L, {
FeatureGroup, FeatureGroup,
Icon, LatLng, Icon,
LatLngBounds, LatLngBounds,
LatLngTuple, LatLngTuple,
markerClusterGroup, markerClusterGroup,
@ -80,8 +80,13 @@ export default abstract class LeafletMap {
updatePending = false; updatePending = false;
addMarkers: L.Marker[] = []; addMarkers: L.Marker[] = [];
addPolygons: L.Polygon[] = []; addPolygons: L.Polygon[] = [];
// tslint:disable-next-line:no-string-literal
southWest = new L.LatLng(-Projection.SphericalMercator['MAX_LATITUDE'], -180); southWest = new L.LatLng(-Projection.SphericalMercator['MAX_LATITUDE'], -180);
// tslint:disable-next-line:no-string-literal
northEast = new L.LatLng(Projection.SphericalMercator['MAX_LATITUDE'], 180); northEast = new L.LatLng(Projection.SphericalMercator['MAX_LATITUDE'], 180);
mousePositionOnMap: L.LatLng;
addMarker: L.Control;
addPolygon: L.Control;
protected constructor(public ctx: WidgetContext, protected constructor(public ctx: WidgetContext,
public $container: HTMLElement, public $container: HTMLElement,
@ -120,13 +125,11 @@ export default abstract class LeafletMap {
addMarkerControl() { addMarkerControl() {
if (this.options.draggableMarker) { if (this.options.draggableMarker) {
let mousePositionOnMap: L.LatLng;
let addMarker: L.Control;
this.map.on('mousemove', (e: L.LeafletMouseEvent) => { this.map.on('mousemove', (e: L.LeafletMouseEvent) => {
mousePositionOnMap = e.latlng; this.mousePositionOnMap = e.latlng;
}); });
const dragListener = (e: L.DragEndEvent) => { const dragListener = (e: L.DragEndEvent) => {
if (e.type === 'dragend' && mousePositionOnMap) { if (e.type === 'dragend' && this.mousePositionOnMap) {
const icon = L.icon({ const icon = L.icon({
iconRetinaUrl: 'marker-icon-2x.png', iconRetinaUrl: 'marker-icon-2x.png',
iconUrl: 'marker-icon.png', iconUrl: 'marker-icon.png',
@ -137,11 +140,11 @@ export default abstract class LeafletMap {
tooltipAnchor: [16, -28], tooltipAnchor: [16, -28],
shadowSize: [41, 41] shadowSize: [41, 41]
}); });
const customLatLng = this.convertToCustomFormat(mousePositionOnMap); const customLatLng = this.convertToCustomFormat(this.mousePositionOnMap);
mousePositionOnMap.lat = customLatLng[this.options.latKeyName]; this.mousePositionOnMap.lat = customLatLng[this.options.latKeyName];
mousePositionOnMap.lng = customLatLng[this.options.lngKeyName]; this.mousePositionOnMap.lng = customLatLng[this.options.lngKeyName];
const newMarker = L.marker(mousePositionOnMap, { icon }).addTo(this.map); const newMarker = L.marker(this.mousePositionOnMap, { icon }).addTo(this.map);
this.addMarkers.push(newMarker); this.addMarkers.push(newMarker);
const datasourcesList = document.createElement('div'); const datasourcesList = document.createElement('div');
const header = document.createElement('p'); const header = document.createElement('p');
@ -181,9 +184,9 @@ export default abstract class LeafletMap {
popup.setContent(datasourcesList); popup.setContent(datasourcesList);
newMarker.bindPopup(popup).openPopup(); newMarker.bindPopup(popup).openPopup();
} }
addMarker.setPosition('topright'); this.addMarker.setPosition('topright');
}; };
L.Control.AddMarker = L.Control.extend({ const addMarker = L.Control.extend({
onAdd() { onAdd() {
const img = L.DomUtil.create('img') as any; const img = L.DomUtil.create('img') as any;
img.src = `assets/add_location.svg`; img.src = `assets/add_location.svg`;
@ -201,34 +204,31 @@ export default abstract class LeafletMap {
}, },
dragMarker: this.dragMarker dragMarker: this.dragMarker
} as any); } as any);
L.control.addMarker = (opts) => { const addMarkerControl = (opts) => {
return new L.Control.AddMarker(opts); return new addMarker(opts);
}; };
addMarker = L.control.addMarker({ position: 'topright' }).addTo(this.map); this.addMarker = addMarkerControl({ position: 'topright' }).addTo(this.map);
} }
} }
addPolygonControl() { addPolygonControl() {
if (this.options.showPolygon && this.options.editablePolygon) { if (this.options.showPolygon && this.options.editablePolygon) {
let polygonPoints: L.LatLng[];
let addPolygon: L.Control;
let mousePositionOnMap: LatLng;
this.map.on('mousemove', (e: L.LeafletMouseEvent) => { this.map.on('mousemove', (e: L.LeafletMouseEvent) => {
mousePositionOnMap = e.latlng; this.mousePositionOnMap = e.latlng;
}); });
const dragListener = (e: L.DragEndEvent) => { const dragListener = (e: L.DragEndEvent) => {
if (e.type === 'dragend') { if (e.type === 'dragend') {
const polygonOffset = this.options.provider === MapProviders.image ? 10 : 0.01; const polygonOffset = this.options.provider === MapProviders.image ? 10 : 0.01;
let convert = this.convertToCustomFormat(mousePositionOnMap,polygonOffset); const convert = this.convertToCustomFormat(this.mousePositionOnMap, polygonOffset);
mousePositionOnMap.lat = convert[this.options.latKeyName]; this.mousePositionOnMap.lat = convert[this.options.latKeyName];
mousePositionOnMap.lng = convert[this.options.lngKeyName]; this.mousePositionOnMap.lng = convert[this.options.lngKeyName];
const latlng1 = mousePositionOnMap; const latlng1 = this.mousePositionOnMap;
const latlng2 = L.latLng(mousePositionOnMap.lat, mousePositionOnMap.lng + polygonOffset); const latlng2 = L.latLng(this.mousePositionOnMap.lat, this.mousePositionOnMap.lng + polygonOffset);
const latlng3 = L.latLng(mousePositionOnMap.lat - polygonOffset, mousePositionOnMap.lng); const latlng3 = L.latLng(this.mousePositionOnMap.lat - polygonOffset, this.mousePositionOnMap.lng);
polygonPoints = [latlng1, latlng2, latlng3]; const polygonPoints = [latlng1, latlng2, latlng3];
const newPolygon = L.polygon(polygonPoints).addTo(this.map); const newPolygon = L.polygon(polygonPoints).addTo(this.map);
this.addPolygons.push(newPolygon); this.addPolygons.push(newPolygon);
@ -270,9 +270,9 @@ export default abstract class LeafletMap {
popup.setContent(datasourcesList); popup.setContent(datasourcesList);
newPolygon.bindPopup(popup).openPopup(); newPolygon.bindPopup(popup).openPopup();
} }
addPolygon.setPosition('topright'); this.addPolygon.setPosition('topright');
}; };
L.Control.AddPolygon = L.Control.extend({ const addPolygon = L.Control.extend({
onAdd() { onAdd() {
const img = L.DomUtil.create('img') as any; const img = L.DomUtil.create('img') as any;
img.src = `assets/add_polygon.svg`; img.src = `assets/add_polygon.svg`;
@ -290,10 +290,10 @@ export default abstract class LeafletMap {
}, },
dragPolygonVertex: this.dragPolygonVertex dragPolygonVertex: this.dragPolygonVertex
} as any); } as any);
L.control.addPolygon = (opts) => { const addPolygonControl = (opts) => {
return new L.Control.AddPolygon(opts); return new addPolygon(opts);
}; };
addPolygon = L.control.addPolygon({ position: 'topright' }).addTo(this.map); this.addPolygon = addPolygonControl({ position: 'topright' }).addTo(this.map);
} }
} }
@ -315,6 +315,19 @@ export default abstract class LeafletMap {
public setMap(map: L.Map) { public setMap(map: L.Map) {
this.map = map; this.map = map;
this.map.on('move', () => {
this.ctx.updatePopoverPositions();
});
this.map.on('zoomstart', () => {
this.ctx.setPopoversHidden(true);
});
this.map.on('zoomend', () => {
this.ctx.setPopoversHidden(false);
this.ctx.updatePopoverPositions();
setTimeout(() => {
this.ctx.updatePopoverPositions();
});
});
if (this.options.useDefaultCenterPosition) { if (this.options.useDefaultCenterPosition) {
this.map.panTo(this.options.defaultCenterPosition); this.map.panTo(this.options.defaultCenterPosition);
this.bounds = map.getBounds(); this.bounds = map.getBounds();
@ -742,8 +755,8 @@ export default abstract class LeafletMap {
if (e === undefined || (e.type !== 'editable:vertex:dragend' && e.type !== 'editable:vertex:deleted')) { if (e === undefined || (e.type !== 'editable:vertex:dragend' && e.type !== 'editable:vertex:deleted')) {
return; return;
} }
if(this.options.provider !== MapProviders.image) { if (this.options.provider !== MapProviders.image) {
for (let key in e.layer._latlngs[0]) { for (const key of Object.keys(e.layer._latlngs[0])) {
e.layer._latlngs[0][key] = checkLngLat(e.layer._latlngs[0][key], this.southWest, this.northEast); e.layer._latlngs[0][key] = checkLngLat(e.layer._latlngs[0][key], this.southWest, this.northEast);
} }
} }
@ -776,4 +789,11 @@ export default abstract class LeafletMap {
this.polygons.delete(name); this.polygons.delete(name);
} }
} }
remove(): void {
if (this.map) {
this.map.remove();
this.map = null;
}
}
} }

3
ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.interface.ts

@ -21,8 +21,7 @@ export interface MapWidgetInterface {
map?: any; map?: any;
resize(); resize();
update(); update();
onInit(); destroy();
onDestroy();
} }
export interface MapWidgetStaticInterface { export interface MapWidgetStaticInterface {

6
ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget2.ts

@ -81,7 +81,6 @@ export class MapWidgetController implements MapWidgetInterface {
} }
parseWithTranslation.setTranslate(this.translate); parseWithTranslation.setTranslate(this.translate);
this.map = new MapClass(this.ctx, $element, this.settings); this.map = new MapClass(this.ctx, $element, this.settings);
(this.ctx as any).mapInstance = this.map;
this.map.saveMarkerLocation = this.setMarkerLocation; this.map.saveMarkerLocation = this.setMarkerLocation;
this.map.savePolygonLocation = this.savePolygonLocation; this.map.savePolygonLocation = this.savePolygonLocation;
this.pageLink = { this.pageLink = {
@ -335,7 +334,10 @@ export class MapWidgetController implements MapWidgetInterface {
this.map.onResize(); this.map.onResize();
} }
onDestroy() { destroy() {
if (this.map) {
this.map.remove();
}
} }
} }

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

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

131
ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.scss

@ -1,131 +0,0 @@
/**
* 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.
*/
.tb-markdown-view {
display: block;
height: 100%;
::ng-deep {
h1 {
font-size: 32px;
padding-right: 60px;
}
h1, h2, h3, h4, h5, h6 {
line-height: normal;
font-weight: 500;
margin-bottom: 30px;
padding-bottom: 10px;
border-bottom: 1px solid #ccc;
}
p {
font-size: 16px;
font-weight: 400;
line-height: 1.25em;
margin: 0;
}
p+p {
margin-top: 10px;
}
table {
width: 100%;
border: 1px solid #ccc;
border-spacing: 0;
margin-top: 30px;
margin-bottom: 30px;
}
thead {
background-color: #555;
color: #fff;
}
th, td {
font-size: .85em;
padding: 8px;
margin: 0;
text-align: left;
}
td[align=center], th[align=center] {
text-align: center;
}
td[align=right], th[align=right] {
text-align: right;
}
tr:nth-child(even) {
background-color: #f7f7f7;
}
code:not([class*=language-]) {
background: #f5f5f5;
border-radius: 2px;
color: #dd4a68;
padding: 2px 4px;
}
div.code-wrapper {
position: relative;
button.clipboard-btn {
cursor: pointer;
margin: 0;
border: 0;
outline: none;
position: absolute;
top: 5px;
right: 5px;
background: #fff;
box-shadow: 0 1px 8px 0 rgba(0,0,0,0.2), 0 3px 4px 0 rgba(0,0,0,0.14), 0 3px 3px -2px rgba(0,0,0,0.12);
border-radius: 5px;
opacity: 0;
transition: opacity .3s;
padding: 3px 6px;
line-height: 16px;
img {
width: 18px;
}
&:hover {
background: #f9f9f9;
}
&:active {
background-color: #ececec;
box-shadow: inset 1px -1px 4px 0px rgba(0,0,0,0.4);
}
}
&:hover {
button.clipboard-btn {
opacity: .85;
}
}
}
th, td {
div.code-wrapper {
display: inline-block;
button.clipboard-btn {
top: -5px;
right: -30px;
padding: 0 3px;
}
}
}
}
}

11
ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.ts

@ -14,7 +14,7 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { ChangeDetectorRef, Component, Input, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, HostBinding, Input, OnInit } from '@angular/core';
import { PageComponent } from '@shared/components/page.component'; import { PageComponent } from '@shared/components/page.component';
import { WidgetContext } from '@home/models/widget-component.models'; import { WidgetContext } from '@home/models/widget-component.models';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@ -44,20 +44,21 @@ type MarkdownTextFunction = (data: FormattedData[]) => string;
@Component({ @Component({
selector: 'tb-markdown-widget ', selector: 'tb-markdown-widget ',
templateUrl: './markdown-widget.component.html', templateUrl: './markdown-widget.component.html'
styleUrls: ['./markdown-widget.component.scss']
}) })
export class MarkdownWidgetComponent extends PageComponent implements OnInit { export class MarkdownWidgetComponent extends PageComponent implements OnInit {
settings: MarkdownWidgetSettings; settings: MarkdownWidgetSettings;
markdownTextFunction: MarkdownTextFunction; markdownTextFunction: MarkdownTextFunction;
@HostBinding('class')
markdownClass: string;
@Input() @Input()
ctx: WidgetContext; ctx: WidgetContext;
markdownText: string; markdownText: string;
markdownClass: string;
constructor(protected store: Store<AppState>, constructor(protected store: Store<AppState>,
private utils: UtilsService, private utils: UtilsService,
@ -75,7 +76,7 @@ export class MarkdownWidgetComponent extends PageComponent implements OnInit {
const cssParser = new cssjs(); const cssParser = new cssjs();
cssParser.testMode = false; cssParser.testMode = false;
this.markdownClass += '-' + hashCode(cssString); this.markdownClass += '-' + hashCode(cssString);
cssParser.cssPreviewNamespace = 'tb-markdown-view.' + this.markdownClass; cssParser.cssPreviewNamespace = this.markdownClass;
cssParser.createStyleElement(this.markdownClass, cssString); cssParser.createStyleElement(this.markdownClass, cssString);
} }
} }

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

@ -28,7 +28,7 @@ import {
NgZone, NgZone,
OnChanges, OnChanges,
OnDestroy, OnDestroy,
OnInit, OnInit, Renderer2,
SimpleChanges, SimpleChanges,
ViewChild, ViewChild,
ViewContainerRef, ViewContainerRef,
@ -107,6 +107,9 @@ import { ComponentType } from '@angular/cdk/portal';
import { EMBED_DASHBOARD_DIALOG_TOKEN } from '@home/components/widget/dialog/embed-dashboard-dialog-token'; import { EMBED_DASHBOARD_DIALOG_TOKEN } from '@home/components/widget/dialog/embed-dashboard-dialog-token';
import { MobileService } from '@core/services/mobile.service'; import { MobileService } from '@core/services/mobile.service';
import { DialogService } from '@core/services/dialog.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';
@Component({ @Component({
selector: 'tb-widget', selector: 'tb-widget',
@ -176,6 +179,8 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
private elementRef: ElementRef, private elementRef: ElementRef,
private injector: Injector, private injector: Injector,
private dialog: MatDialog, private dialog: MatDialog,
private renderer: Renderer2,
private popoverService: TbPopoverService,
@Inject(EMBED_DASHBOARD_DIALOG_TOKEN) private embedDashboardDialogComponent: ComponentType<any>, @Inject(EMBED_DASHBOARD_DIALOG_TOKEN) private embedDashboardDialogComponent: ComponentType<any>,
private widgetService: WidgetService, private widgetService: WidgetService,
private resources: ResourcesService, private resources: ResourcesService,
@ -293,7 +298,8 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
handleWidgetAction: this.handleWidgetAction.bind(this), handleWidgetAction: this.handleWidgetAction.bind(this),
elementClick: this.elementClick.bind(this), elementClick: this.elementClick.bind(this),
getActiveEntityInfo: this.getActiveEntityInfo.bind(this), getActiveEntityInfo: this.getActiveEntityInfo.bind(this),
openDashboardStateInSeparateDialog: this.openDashboardStateInSeparateDialog.bind(this) openDashboardStateInSeparateDialog: this.openDashboardStateInSeparateDialog.bind(this),
openDashboardStateInPopover: this.openDashboardStateInPopover.bind(this)
}; };
this.widgetContext.customHeaderActions = []; this.widgetContext.customHeaderActions = [];
@ -1053,7 +1059,11 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
const params = deepClone(this.widgetContext.stateController.getStateParams()); const params = deepClone(this.widgetContext.stateController.getStateParams());
this.updateEntityParams(params, targetEntityParamName, targetEntityId, entityName, entityLabel); this.updateEntityParams(params, targetEntityParamName, targetEntityId, entityName, entityLabel);
if (type === WidgetActionType.openDashboardState) { if (type === WidgetActionType.openDashboardState) {
if (descriptor.openInSeparateDialog && !this.mobileService.isMobileApp()) { if (descriptor.openInPopover) {
this.openDashboardStateInPopover($event, descriptor.targetDashboardStateId, params,
descriptor.popoverHideDashboardToolbar, descriptor.popoverPreferredPlacement,
descriptor.popoverHideOnClickOutside, descriptor.popoverWidth, descriptor.popoverHeight, descriptor.popoverStyle);
} else if (descriptor.openInSeparateDialog && !this.mobileService.isMobileApp()) {
this.openDashboardStateInSeparateDialog(descriptor.targetDashboardStateId, params, descriptor.dialogTitle, this.openDashboardStateInSeparateDialog(descriptor.targetDashboardStateId, params, descriptor.dialogTitle,
descriptor.dialogHideDashboardToolbar, descriptor.dialogWidth, descriptor.dialogHeight); descriptor.dialogHideDashboardToolbar, descriptor.dialogWidth, descriptor.dialogHeight);
} else { } else {
@ -1306,6 +1316,52 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
} }
} }
private openDashboardStateInPopover($event: Event,
targetDashboardStateId: string,
params?: StateParams,
hideDashboardToolbar = true,
preferredPlacement: PopoverPlacement = 'top',
hideOnClickOutside = true,
popoverWidth = '25vw',
popoverHeight = '25vh',
popoverStyle: { [klass: string]: any } = {}) {
const trigger = ($event.target || $event.srcElement || $event.currentTarget) as Element;
if (this.popoverService.hasPopover(trigger)) {
this.popoverService.hidePopover(trigger);
} else {
const dashboard = deepClone(this.widgetContext.stateController.dashboardCtrl.dashboardCtx.getDashboard());
const stateObject: StateObject = {};
stateObject.params = params;
if (targetDashboardStateId) {
stateObject.id = targetDashboardStateId;
}
const injector = Injector.create({
parent: this.widgetContentContainer.injector, providers: [
{
provide: 'embeddedValue',
useValue: true
}
]
});
const component = this.popoverService.displayPopover(trigger, this.renderer,
this.widgetContentContainer, DashboardPageComponent, preferredPlacement, hideOnClickOutside,
injector,
{
embed: true,
syncStateWithQueryParam: false,
hideToolbar: hideDashboardToolbar,
currentState: objToBase64([stateObject]),
dashboard,
parentDashboard: this.widgetContext.parentDashboard ?
this.widgetContext.parentDashboard : this.widgetContext.dashboard
},
popoverStyle,
{width: popoverWidth, height: popoverHeight}
);
this.widgetContext.registerPopoverComponent(component);
}
}
private openDashboardStateInSeparateDialog(targetDashboardStateId: string, params?: StateParams, dialogTitle?: string, private openDashboardStateInSeparateDialog(targetDashboardStateId: string, params?: StateParams, dialogTitle?: string,
hideDashboardToolbar = true, dialogWidth?: number, dialogHeight?: number) { hideDashboardToolbar = true, dialogWidth?: number, dialogHeight?: number) {
const dashboard = deepClone(this.widgetContext.stateController.dashboardCtrl.dashboardCtx.getDashboard()); const dashboard = deepClone(this.widgetContext.stateController.dashboardCtrl.dashboardCtx.getDashboard());

12
ui-ngx/src/app/modules/home/models/dashboard-component.models.ts

@ -101,6 +101,8 @@ export class DashboardWidgets implements Iterable<DashboardWidget> {
widgets: Iterable<Widget>; widgets: Iterable<Widget>;
widgetLayouts: WidgetLayouts; widgetLayouts: WidgetLayouts;
parentDashboard?: IDashboardComponent;
[Symbol.iterator](): Iterator<DashboardWidget> { [Symbol.iterator](): Iterator<DashboardWidget> {
return this.activeDashboardWidgets[Symbol.iterator](); return this.activeDashboardWidgets[Symbol.iterator]();
} }
@ -166,7 +168,7 @@ export class DashboardWidgets implements Iterable<DashboardWidget> {
switch (record.operation) { switch (record.operation) {
case 'add': case 'add':
this.dashboardWidgets.push( this.dashboardWidgets.push(
new DashboardWidget(this.dashboard, record.widget, record.widgetLayout) new DashboardWidget(this.dashboard, record.widget, record.widgetLayout, this.parentDashboard)
); );
break; break;
case 'remove': case 'remove':
@ -181,7 +183,8 @@ export class DashboardWidgets implements Iterable<DashboardWidget> {
const prevDashboardWidget = this.dashboardWidgets[index]; const prevDashboardWidget = this.dashboardWidgets[index];
if (!isEqual(prevDashboardWidget.widget, record.widget) || if (!isEqual(prevDashboardWidget.widget, record.widget) ||
!isEqual(prevDashboardWidget.widgetLayout, record.widgetLayout)) { !isEqual(prevDashboardWidget.widgetLayout, record.widgetLayout)) {
this.dashboardWidgets[index] = new DashboardWidget(this.dashboard, record.widget, record.widgetLayout); this.dashboardWidgets[index] = new DashboardWidget(this.dashboard, record.widget, record.widgetLayout,
this.parentDashboard);
this.dashboardWidgets[index].highlighted = prevDashboardWidget.highlighted; this.dashboardWidgets[index].highlighted = prevDashboardWidget.highlighted;
this.dashboardWidgets[index].selected = prevDashboardWidget.selected; this.dashboardWidgets[index].selected = prevDashboardWidget.selected;
} else { } else {
@ -332,7 +335,7 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget {
customHeaderActions: Array<WidgetHeaderAction>; customHeaderActions: Array<WidgetHeaderAction>;
widgetActions: Array<WidgetAction>; widgetActions: Array<WidgetAction>;
widgetContext = new WidgetContext(this.dashboard, this, this.widget); widgetContext = new WidgetContext(this.dashboard, this, this.widget, this.parentDashboard);
widgetId: string; widgetId: string;
@ -374,7 +377,8 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget {
constructor( constructor(
private dashboard: IDashboardComponent, private dashboard: IDashboardComponent,
public widget: Widget, public widget: Widget,
public widgetLayout?: WidgetLayout) { public widgetLayout?: WidgetLayout,
private parentDashboard?: IDashboardComponent) {
if (!widget.id) { if (!widget.id) {
widget.id = guid(); widget.id = guid();
} }

30
ui-ngx/src/app/modules/home/models/widget-component.models.ts

@ -80,6 +80,7 @@ import { DomSanitizer } from '@angular/platform-browser';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { catchError, map, mergeMap, switchMap } from 'rxjs/operators'; import { catchError, map, mergeMap, switchMap } from 'rxjs/operators';
import { FormattedData } from '@home/components/widget/lib/maps/map-models'; import { FormattedData } from '@home/components/widget/lib/maps/map-models';
import { TbPopoverComponent } from '@shared/components/popover.component';
export interface IWidgetAction { export interface IWidgetAction {
name: string; name: string;
@ -108,10 +109,11 @@ export class WidgetContext {
constructor(public dashboard: IDashboardComponent, constructor(public dashboard: IDashboardComponent,
private dashboardWidget: IDashboardWidget, private dashboardWidget: IDashboardWidget,
private widget: Widget) {} private widget: Widget,
public parentDashboard?: IDashboardComponent) {}
get stateController(): IStateController { get stateController(): IStateController {
return this.dashboard.stateController; return this.parentDashboard ? this.parentDashboard.stateController : this.dashboard.stateController;
} }
get aliasController(): IAliasController { get aliasController(): IAliasController {
@ -255,6 +257,8 @@ export class WidgetContext {
store?: Store<AppState>; store?: Store<AppState>;
private popoverComponents: TbPopoverComponent[] = [];
rxjs = { rxjs = {
forkJoin, forkJoin,
of, of,
@ -264,6 +268,28 @@ export class WidgetContext {
catchError catchError
}; };
registerPopoverComponent(popoverComponent: TbPopoverComponent) {
this.popoverComponents.push(popoverComponent);
popoverComponent.tbDestroy.subscribe(() => {
const index = this.popoverComponents.indexOf(popoverComponent, 0);
if (index > -1) {
this.popoverComponents.splice(index, 1);
}
});
}
updatePopoverPositions() {
this.popoverComponents.forEach(comp => {
comp.updatePosition();
});
}
setPopoversHidden(hidden: boolean) {
this.popoverComponents.forEach(comp => {
comp.tbHidden = hidden;
});
}
showSuccessToast(message: string, duration: number = 1000, showSuccessToast(message: string, duration: number = 1000,
verticalPosition: NotificationVerticalPosition = 'bottom', verticalPosition: NotificationVerticalPosition = 'bottom',
horizontalPosition: NotificationHorizontalPosition = 'left', horizontalPosition: NotificationHorizontalPosition = 'left',

127
ui-ngx/src/app/shared/components/directives/component-outlet.directive.ts

@ -0,0 +1,127 @@
///
/// 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, ComponentRef,
Directive, EventEmitter, Injector,
Input,
OnChanges, Output, Renderer2,
SimpleChange,
SimpleChanges,
TemplateRef,
ViewContainerRef
} from '@angular/core';
@Directive({
// tslint:disable-next-line:directive-selector
selector: '[tbComponentOutlet]',
exportAs: 'tbComponentOutlet'
})
export class TbComponentOutletDirective<_T = unknown> implements OnChanges {
private componentRef: ComponentRef<any> | null = null;
private context = new TbComponentOutletContext();
@Input() tbComponentOutletContext: any | null = null;
@Input() tbComponentStyle: { [klass: string]: any } | null = null;
@Input() tbComponentInjector: Injector | null = null;
@Input() tbComponentOutlet: ComponentFactory<any> = null;
@Output() componentChange = new EventEmitter<ComponentRef<any>>();
static ngTemplateContextGuard<T>(
// tslint:disable-next-line:variable-name
_dir: TbComponentOutletDirective<T>,
// tslint:disable-next-line:variable-name
_ctx: any
): _ctx is TbComponentOutletContext {
return true;
}
private recreateComponent(): void {
this.viewContainer.clear();
this.componentRef = this.viewContainer.createComponent(this.tbComponentOutlet, 0, this.tbComponentInjector);
this.componentChange.next(this.componentRef);
if (this.tbComponentOutletContext) {
for (const propName of Object.keys(this.tbComponentOutletContext)) {
this.componentRef.instance[propName] = this.tbComponentOutletContext[propName];
}
}
if (this.tbComponentStyle) {
for (const propName of Object.keys(this.tbComponentStyle)) {
this.renderer.setStyle(this.componentRef.location.nativeElement, propName, this.tbComponentStyle[propName]);
}
}
}
private updateContext(): void {
const newCtx = this.tbComponentOutletContext;
const oldCtx = this.componentRef.instance as any;
if (newCtx) {
for (const propName of Object.keys(newCtx)) {
oldCtx[propName] = newCtx[propName];
}
}
}
constructor(private viewContainer: ViewContainerRef,
private renderer: Renderer2) {}
ngOnChanges(changes: SimpleChanges): void {
const { tbComponentOutletContext, tbComponentOutlet } = changes;
const shouldRecreateComponent = (): boolean => {
let shouldOutletRecreate = false;
if (tbComponentOutlet) {
if (tbComponentOutlet.firstChange) {
shouldOutletRecreate = true;
} else {
const isPreviousOutletTemplate = tbComponentOutlet.previousValue instanceof ComponentFactory;
const isCurrentOutletTemplate = tbComponentOutlet.currentValue instanceof ComponentFactory;
shouldOutletRecreate = isPreviousOutletTemplate || isCurrentOutletTemplate;
}
}
const hasContextShapeChanged = (ctxChange: SimpleChange): boolean => {
const prevCtxKeys = Object.keys(ctxChange.previousValue || {});
const currCtxKeys = Object.keys(ctxChange.currentValue || {});
if (prevCtxKeys.length === currCtxKeys.length) {
for (const propName of currCtxKeys) {
if (prevCtxKeys.indexOf(propName) === -1) {
return true;
}
}
return false;
} else {
return true;
}
};
const shouldContextRecreate =
tbComponentOutletContext && hasContextShapeChanged(tbComponentOutletContext);
return shouldContextRecreate || shouldOutletRecreate;
};
if (tbComponentOutlet) {
this.context.$implicit = tbComponentOutlet.currentValue;
}
const recreateComponent = shouldRecreateComponent();
if (recreateComponent) {
this.recreateComponent();
} else {
this.updateContext();
}
}
}
export class TbComponentOutletContext {
public $implicit: any;
}

118
ui-ngx/src/app/shared/components/directives/sring-template-outlet.directive.ts

@ -0,0 +1,118 @@
///
/// 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 {
Directive,
EmbeddedViewRef,
Input,
OnChanges,
SimpleChange,
SimpleChanges,
TemplateRef,
ViewContainerRef
} from '@angular/core';
@Directive({
// tslint:disable-next-line:directive-selector
selector: '[tbStringTemplateOutlet]',
exportAs: 'tbStringTemplateOutlet'
})
export class TbStringTemplateOutletDirective<_T = unknown> implements OnChanges {
private embeddedViewRef: EmbeddedViewRef<any> | null = null;
private context = new TbStringTemplateOutletContext();
@Input() tbStringTemplateOutletContext: any | null = null;
@Input() tbStringTemplateOutlet: any | TemplateRef<any> = null;
static ngTemplateContextGuard<T>(
// tslint:disable-next-line:variable-name
_dir: TbStringTemplateOutletDirective<T>,
// tslint:disable-next-line:variable-name
_ctx: any
): _ctx is TbStringTemplateOutletContext {
return true;
}
private recreateView(): void {
this.viewContainer.clear();
const isTemplateRef = this.tbStringTemplateOutlet instanceof TemplateRef;
const templateRef = (isTemplateRef ? this.tbStringTemplateOutlet : this.templateRef) as any;
this.embeddedViewRef = this.viewContainer.createEmbeddedView(
templateRef,
isTemplateRef ? this.tbStringTemplateOutletContext : this.context
);
}
private updateContext(): void {
const isTemplateRef = this.tbStringTemplateOutlet instanceof TemplateRef;
const newCtx = isTemplateRef ? this.tbStringTemplateOutletContext : this.context;
const oldCtx = this.embeddedViewRef.context as any;
if (newCtx) {
for (const propName of Object.keys(newCtx)) {
oldCtx[propName] = newCtx[propName];
}
}
}
constructor(private viewContainer: ViewContainerRef, private templateRef: TemplateRef<any>) {}
ngOnChanges(changes: SimpleChanges): void {
const { tbStringTemplateOutletContext, tbStringTemplateOutlet } = changes;
const shouldRecreateView = (): boolean => {
let shouldOutletRecreate = false;
if (tbStringTemplateOutlet) {
if (tbStringTemplateOutlet.firstChange) {
shouldOutletRecreate = true;
} else {
const isPreviousOutletTemplate = tbStringTemplateOutlet.previousValue instanceof TemplateRef;
const isCurrentOutletTemplate = tbStringTemplateOutlet.currentValue instanceof TemplateRef;
shouldOutletRecreate = isPreviousOutletTemplate || isCurrentOutletTemplate;
}
}
const hasContextShapeChanged = (ctxChange: SimpleChange): boolean => {
const prevCtxKeys = Object.keys(ctxChange.previousValue || {});
const currCtxKeys = Object.keys(ctxChange.currentValue || {});
if (prevCtxKeys.length === currCtxKeys.length) {
for (const propName of currCtxKeys) {
if (prevCtxKeys.indexOf(propName) === -1) {
return true;
}
}
return false;
} else {
return true;
}
};
const shouldContextRecreate =
tbStringTemplateOutletContext && hasContextShapeChanged(tbStringTemplateOutletContext);
return shouldContextRecreate || shouldOutletRecreate;
};
if (tbStringTemplateOutlet) {
this.context.$implicit = tbStringTemplateOutlet.currentValue;
}
const recreateView = shouldRecreateView();
if (recreateView) {
this.recreateView();
} else {
this.updateContext();
}
}
}
export class TbStringTemplateOutletContext {
public $implicit: any;
}

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

@ -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.
-->
<ng-container *ngIf="markdownText$ | async as text;">
<markdown [data]="text" lineNumbers (ready)="onMarkdownReady()" class="tb-help-markdown tb-markdown-view" (click)="markdownClick($event)"></markdown>
</ng-container>

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

@ -0,0 +1,33 @@
/**
* 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 {
.tb-help-markdown {
overflow: auto;
max-width: 50vw;
max-height: 50vh;
margin-top: 30px;
}
}
:host ::ng-deep {
.tb-help-markdown.tb-markdown-view {
h1, h2, h3, h4, h5, h6 {
&:first-child {
padding-top: 0;
}
}
}
}

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

@ -0,0 +1,104 @@
///
/// 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,
EventEmitter,
Input, OnChanges,
OnDestroy, OnInit,
Output, SimpleChanges
} from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { delay, share } from 'rxjs/operators';
import { HelpService } from '@core/services/help.service';
@Component({
selector: 'tb-help-markdown',
templateUrl: './help-markdown.component.html',
styleUrls: ['./help-markdown.component.scss']
})
export class HelpMarkdownComponent implements OnDestroy, OnInit, OnChanges {
@Input() helpId: string;
@Input() visible: boolean;
@Output() markdownReady = new EventEmitter<void>();
markdownText = new BehaviorSubject<string>(null);
markdownText$ = this.markdownText.pipe(
share()
);
isMarkdownReady = false;
private loadHelpPending = false;
constructor(private help: HelpService) {}
ngOnInit(): void {
this.loadHelpWhenVisible();
}
ngOnDestroy(): void {
this.markdownText.complete();
}
ngOnChanges(changes: SimpleChanges): void {
for (const propName of Object.keys(changes)) {
const change = changes[propName];
if (!change.firstChange && change.currentValue !== change.previousValue) {
if (propName === 'visible') {
if (this.loadHelpPending) {
this.loadHelpPending = false;
this.loadHelp();
}
}
if (propName === 'helpId') {
this.markdownText.next(null);
this.loadHelpWhenVisible();
}
}
}
}
private loadHelpWhenVisible() {
if (this.visible) {
this.loadHelp();
} else {
this.loadHelpPending = true;
}
}
private loadHelp() {
if (this.helpId) {
this.help.getHelpContent(this.helpId).subscribe((content) => {
this.markdownText.next(content);
});
}
}
onMarkdownReady() {
this.isMarkdownReady = true;
this.markdownReady.next();
}
markdownClick($event: MouseEvent) {
}
}

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

@ -0,0 +1,30 @@
<!--
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.
-->
<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>

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

@ -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.
*/
.tb-help-popup-button {
}
.tb-help-popup-button-loading {
background: #fff;
border-radius: 50%;
}

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

@ -0,0 +1,53 @@
///
/// 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, ElementRef, Input, OnDestroy, Renderer2, ViewContainerRef, ViewEncapsulation } from '@angular/core';
import { TbPopoverService } from '@shared/components/popover.component';
@Component({
// tslint:disable-next-line:component-selector
selector: '[tb-help-popup]',
templateUrl: './help-popup.component.html',
styleUrls: ['./help-popup.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class HelpPopupComponent implements OnDestroy {
// tslint:disable-next-line:no-input-rename
@Input('tb-help-popup') helpId: string;
popoverVisible = false;
popoverReady = true;
constructor(private elementRef: ElementRef,
private viewContainerRef: ViewContainerRef,
private renderer: Renderer2,
private popoverService: TbPopoverService) {}
toggleHelp() {
this.popoverService.toggleHelpPopover(this.elementRef.nativeElement, this.renderer, this.viewContainerRef,
this.helpId,
(visible) => {
this.popoverVisible = visible;
}, (ready => {
this.popoverReady = ready;
}));
}
ngOnDestroy(): void {
}
}

1
ui-ngx/src/app/shared/components/js-func.component.html

@ -24,6 +24,7 @@
<button type='button' *ngIf="!disabled" mat-button class="tidy" (click)="beautifyJs()"> <button type='button' *ngIf="!disabled" mat-button class="tidy" (click)="beautifyJs()">
{{'js-func.tidy' | translate }} {{'js-func.tidy' | translate }}
</button> </button>
<div *ngIf="helpId" [tb-help-popup]="helpId"></div>
<fieldset style="width: initial"> <fieldset style="width: initial">
<div matTooltip="{{(fullscreen ? 'fullscreen.exit' : 'fullscreen.expand') | translate}}" <div matTooltip="{{(fullscreen ? 'fullscreen.exit' : 'fullscreen.expand') | translate}}"
matTooltipPosition="above" matTooltipPosition="above"

16
ui-ngx/src/app/shared/components/js-func.component.scss

@ -41,20 +41,26 @@
} }
.tb-js-func-toolbar { .tb-js-func-toolbar {
& > * {
&:not(:last-child) {
margin-right: 4px;
}
}
button.mat-button, button.mat-icon-button, button.mat-icon-button.tb-mat-32 { button.mat-button, button.mat-icon-button, button.mat-icon-button.tb-mat-32 {
background: rgba(220, 220, 220, .35);
align-items: center; align-items: center;
vertical-align: middle; vertical-align: middle;
min-width: 32px; min-width: 32px;
min-height: 15px; min-height: 15px;
padding: 4px; padding: 4px;
margin: 0;
font-size: .8rem; font-size: .8rem;
line-height: 15px; line-height: 15px;
color: #7b7b7b; &:not(.tb-help-popup-button) {
background: rgba(220, 220, 220, .35); color: #7b7b7b;
&:not(:last-child) {
margin-right: 4px;
} }
} }
.tb-help-popup-button-loading {
background: #f3f3f3;
}
} }
} }

2
ui-ngx/src/app/shared/components/js-func.component.ts

@ -87,6 +87,8 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
@Input() disableUndefinedCheck = false; @Input() disableUndefinedCheck = false;
@Input() helpId: string;
private noValidateValue: boolean; private noValidateValue: boolean;
get noValidate(): boolean { get noValidate(): boolean {
return this.noValidateValue; return this.noValidateValue;

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

@ -18,13 +18,13 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
ElementRef, ElementRef,
forwardRef, forwardRef, Injector,
Input, Input,
OnChanges, OnChanges,
OnDestroy, OnDestroy,
OnInit, OnInit, Renderer2,
SimpleChanges, SimpleChanges,
ViewChild, ViewChild, ViewContainerRef,
ViewEncapsulation ViewEncapsulation
} from '@angular/core'; } from '@angular/core';
import { ControlValueAccessor, FormControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validator } from '@angular/forms'; import { ControlValueAccessor, FormControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validator } from '@angular/forms';
@ -45,6 +45,9 @@ import { JsonFormComponentData } from './json-form-component.models';
import { GroupInfo } from '@shared/models/widget.models'; import { GroupInfo } from '@shared/models/widget.models';
import { Observable } from 'rxjs/internal/Observable'; import { Observable } from 'rxjs/internal/Observable';
import { forkJoin, from } from 'rxjs'; import { forkJoin, from } from 'rxjs';
import { MouseEvent } from 'react';
import { TbPopoverService } from '@shared/components/popover.component';
import { HelpMarkdownComponent } from '@shared/components/help-markdown.component';
const tinycolor = tinycolor_; const tinycolor = tinycolor_;
@ -90,7 +93,8 @@ export class JsonFormComponent implements OnInit, ControlValueAccessor, Validato
onModelChange: this.onModelChange.bind(this), onModelChange: this.onModelChange.bind(this),
onColorClick: this.onColorClick.bind(this), onColorClick: this.onColorClick.bind(this),
onIconClick: this.onIconClick.bind(this), onIconClick: this.onIconClick.bind(this),
onToggleFullscreen: this.onToggleFullscreen.bind(this) onToggleFullscreen: this.onToggleFullscreen.bind(this),
onHelpClick: this.onHelpClick.bind(this)
}; };
data: JsonFormComponentData; data: JsonFormComponentData;
@ -114,6 +118,9 @@ export class JsonFormComponent implements OnInit, ControlValueAccessor, Validato
constructor(public elementRef: ElementRef, constructor(public elementRef: ElementRef,
private translate: TranslateService, private translate: TranslateService,
private dialogs: DialogService, private dialogs: DialogService,
private popoverService: TbPopoverService,
private renderer: Renderer2,
private viewContainerRef: ViewContainerRef,
protected store: Store<AppState>, protected store: Store<AppState>,
private cd: ChangeDetectorRef) { private cd: ChangeDetectorRef) {
} }
@ -243,6 +250,11 @@ 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);
}
private updateAndRender() { private updateAndRender() {
this.formProps.option.formDefaults.readonly = this.readonly; this.formProps.option.formDefaults.readonly = this.readonly;

40
ui-ngx/src/app/shared/components/json-form/react/json-form-ace-editor.tsx

@ -19,10 +19,14 @@ import reactCSS from 'reactcss';
import Button from '@material-ui/core/Button'; import Button from '@material-ui/core/Button';
import { JsonFormFieldProps, JsonFormFieldState } from '@shared/components/json-form/react/json-form.models'; import { JsonFormFieldProps, JsonFormFieldState } from '@shared/components/json-form/react/json-form.models';
import { IEditorProps } from 'react-ace/src/types'; import { IEditorProps } from 'react-ace/src/types';
import { map, mergeMap } from 'rxjs/operators'; import { mergeMap } from 'rxjs/operators';
import { loadAceDependencies } from '@shared/models/ace/ace.models'; import { loadAceDependencies } from '@shared/models/ace/ace.models';
import { from } from 'rxjs'; import { from } from 'rxjs';
import { Observable } from 'rxjs/internal/Observable'; import { Observable } from 'rxjs/internal/Observable';
import { CircularProgress, IconButton } from '@material-ui/core';
import { MouseEvent } from 'react';
import { Help, HelpOutline } from '@material-ui/icons';
import { position } from 'html2canvas/dist/types/css/property-descriptors/position';
const ReactAce = React.lazy(() => { const ReactAce = React.lazy(() => {
return loadAceDependencies().pipe( return loadAceDependencies().pipe(
@ -39,6 +43,8 @@ interface ThingsboardAceEditorProps extends JsonFormFieldProps {
interface ThingsboardAceEditorState extends JsonFormFieldState { interface ThingsboardAceEditorState extends JsonFormFieldState {
isFull: boolean; isFull: boolean;
helpVisible: boolean;
helpReady: boolean;
focused: boolean; focused: boolean;
} }
@ -53,11 +59,14 @@ class ThingsboardAceEditor extends React.Component<ThingsboardAceEditorProps, Th
this.onBlur = this.onBlur.bind(this); this.onBlur = this.onBlur.bind(this);
this.onFocus = this.onFocus.bind(this); this.onFocus = this.onFocus.bind(this);
this.onTidy = this.onTidy.bind(this); this.onTidy = this.onTidy.bind(this);
this.onHelp = this.onHelp.bind(this);
this.onLoad = this.onLoad.bind(this); this.onLoad = this.onLoad.bind(this);
this.onToggleFull = this.onToggleFull.bind(this); this.onToggleFull = this.onToggleFull.bind(this);
const value = props.value ? props.value + '' : ''; const value = props.value ? props.value + '' : '';
this.state = { this.state = {
isFull: false, isFull: false,
helpVisible: false,
helpReady: true,
value, value,
focused: false focused: false
}; };
@ -84,7 +93,7 @@ class ThingsboardAceEditor extends React.Component<ThingsboardAceEditorProps, Th
onTidy() { onTidy() {
if (!this.props.form.readonly) { if (!this.props.form.readonly) {
let value = this.state.value; const value = this.state.value;
this.props.onTidy(value).subscribe( this.props.onTidy(value).subscribe(
(processedValue) => { (processedValue) => {
this.setState({ this.setState({
@ -100,6 +109,24 @@ class ThingsboardAceEditor extends React.Component<ThingsboardAceEditorProps, Th
} }
} }
onHelp(event: MouseEvent) {
if (this.state.helpVisible && !this.state.helpReady) {
event.preventDefault();
event.stopPropagation();
} else {
this.props.onHelpClick(event, this.props.form.helpId,
(visible) => {
this.setState({
helpVisible: visible
});
}, (ready) => {
this.setState({
helpReady: ready
});
});
}
}
onLoad(editor: IEditorProps) { onLoad(editor: IEditorProps) {
this.aceEditor = editor; this.aceEditor = editor;
} }
@ -161,6 +188,15 @@ class ThingsboardAceEditor extends React.Component<ThingsboardAceEditorProps, Th
<label>{this.props.mode}</label> <label>{this.props.mode}</label>
{ this.props.onTidy ? <Button style={ styles.tidyButtonStyle } { this.props.onTidy ? <Button style={ styles.tidyButtonStyle }
className='tidy-button' onClick={this.onTidy}>Tidy</Button> : null } className='tidy-button' onClick={this.onTidy}>Tidy</Button> : null }
{ this.props.form.helpId ? <div style={ {position: 'relative', display: 'inline-block', marginLeft: '5px'} }>
<IconButton color='primary'
className='help-button' onClick={this.onHelp}>
{this.state.helpVisible ? <Help /> : <HelpOutline /> }
</IconButton>
{ this.state.helpVisible && !this.state.helpReady ?
<div className='tb-absolute-fill help-button-loading'>
<CircularProgress size={18} thickness={4}/>
</div> : null }</div> : null }
<Button style={ styles.tidyButtonStyle } <Button style={ styles.tidyButtonStyle }
className='tidy-button' onClick={this.onToggleFull}> className='tidy-button' onClick={this.onToggleFull}>
{this.state.isFull ? {this.state.isFull ?

3
ui-ngx/src/app/shared/components/json-form/react/json-form-array.tsx

@ -143,7 +143,8 @@ class ThingsboardArray extends React.Component<JsonFormFieldProps, ThingsboardAr
const forms = (this.props.form.items as JsonFormData[]).map((form, index) => { const forms = (this.props.form.items as JsonFormData[]).map((form, index) => {
const copy = this.copyWithIndex(form, i); const copy = this.copyWithIndex(form, i);
return this.props.builder(copy, this.props.model, index, this.props.onChange, return this.props.builder(copy, this.props.model, index, this.props.onChange,
this.props.onColorClick, this.props.onIconClick, this.props.onToggleFullscreen, this.props.mapper); this.props.onColorClick, this.props.onIconClick, this.props.onToggleFullscreen,
this.props.onHelpClick, this.props.mapper);
}); });
arrays.push( arrays.push(
<li key={keys[i]} className='list-group-item'> <li key={keys[i]} className='list-group-item'>

2
ui-ngx/src/app/shared/components/json-form/react/json-form-fieldset.tsx

@ -25,7 +25,7 @@ class ThingsboardFieldSet extends React.Component<JsonFormFieldProps, JsonFormFi
render() { render() {
const forms = (this.props.form.items as JsonFormData[]).map((form: JsonFormData, index) => { const forms = (this.props.form.items as JsonFormData[]).map((form: JsonFormData, index) => {
return this.props.builder(form, this.props.model, index, this.props.onChange, return this.props.builder(form, this.props.model, index, this.props.onChange,
this.props.onColorClick, this.props.onIconClick, this.props.onToggleFullscreen, this.props.mapper); this.props.onColorClick, this.props.onIconClick, this.props.onToggleFullscreen, this.props.onHelpClick, this.props.mapper);
}); });
return ( return (

21
ui-ngx/src/app/shared/components/json-form/react/json-form-schema-form.tsx

@ -33,12 +33,20 @@ import ThingsboardCheckbox from './json-form-checkbox';
import ThingsboardHelp from './json-form-help'; import ThingsboardHelp from './json-form-help';
import ThingsboardFieldSet from './json-form-fieldset'; import ThingsboardFieldSet from './json-form-fieldset';
import ThingsboardIcon from './json-form-icon'; import ThingsboardIcon from './json-form-icon';
import { JsonFormData, JsonFormProps, onChangeFn, OnColorClickFn, OnIconClickFn } from './json-form.models'; import {
JsonFormData,
JsonFormProps,
onChangeFn,
OnColorClickFn, onHelpClickFn,
OnIconClickFn,
onToggleFullscreenFn
} from './json-form.models';
import _ from 'lodash'; import _ from 'lodash';
import * as tinycolor_ from 'tinycolor2'; import * as tinycolor_ from 'tinycolor2';
import { GroupInfo } from '@shared/models/widget.models'; import { GroupInfo } from '@shared/models/widget.models';
import ThingsboardMarkdown from '@shared/components/json-form/react/json-form-markdown'; import ThingsboardMarkdown from '@shared/components/json-form/react/json-form-markdown';
import { MouseEvent } from 'react';
const tinycolor = tinycolor_; const tinycolor = tinycolor_;
@ -77,6 +85,7 @@ class ThingsboardSchemaForm extends React.Component<JsonFormProps, any> {
this.onColorClick = this.onColorClick.bind(this); this.onColorClick = this.onColorClick.bind(this);
this.onIconClick = this.onIconClick.bind(this); this.onIconClick = this.onIconClick.bind(this);
this.onToggleFullscreen = this.onToggleFullscreen.bind(this); this.onToggleFullscreen = this.onToggleFullscreen.bind(this);
this.onHelpClick = this.onHelpClick.bind(this);
this.hasConditions = false; this.hasConditions = false;
} }
@ -101,6 +110,10 @@ class ThingsboardSchemaForm extends React.Component<JsonFormProps, any> {
this.props.onToggleFullscreen(element, fullscreenFinishFn); this.props.onToggleFullscreen(element, fullscreenFinishFn);
} }
onHelpClick(event: MouseEvent, helpId: string, helpVisibleFn: (visible: boolean) => void, helpReadyFn: (ready: boolean) => void) {
this.props.onHelpClick(event, helpId, helpVisibleFn, helpReadyFn);
}
builder(form: JsonFormData, builder(form: JsonFormData,
model: any, model: any,
@ -108,7 +121,8 @@ class ThingsboardSchemaForm extends React.Component<JsonFormProps, any> {
onChange: onChangeFn, onChange: onChangeFn,
onColorClick: OnColorClickFn, onColorClick: OnColorClickFn,
onIconClick: OnIconClickFn, onIconClick: OnIconClickFn,
onToggleFullscreen: () => void, onToggleFullscreen: onToggleFullscreenFn,
onHelpClick: onHelpClickFn,
mapper: {[type: string]: any}): JSX.Element { mapper: {[type: string]: any}): JSX.Element {
const type = form.type; const type = form.type;
const Field = this.mapper[type]; const Field = this.mapper[type];
@ -127,6 +141,7 @@ class ThingsboardSchemaForm extends React.Component<JsonFormProps, any> {
onColorClick={onColorClick} onColorClick={onColorClick}
onIconClick={onIconClick} onIconClick={onIconClick}
onToggleFullscreen={onToggleFullscreen} onToggleFullscreen={onToggleFullscreen}
onHelpClick={onHelpClick}
mapper={mapper} builder={this.builder}/>; mapper={mapper} builder={this.builder}/>;
} }
@ -138,7 +153,7 @@ class ThingsboardSchemaForm extends React.Component<JsonFormProps, any> {
} }
const forms = merged.map(function(form, index) { const forms = merged.map(function(form, index) {
return this.builder(form, this.props.model, index, this.onChange, this.onColorClick, return this.builder(form, this.props.model, index, this.onChange, this.onColorClick,
this.onIconClick, this.onToggleFullscreen, mapper); this.onIconClick, this.onToggleFullscreen, this.onHelpClick, mapper);
}.bind(this)); }.bind(this));
let formClass = 'SchemaForm'; let formClass = 'SchemaForm';

9
ui-ngx/src/app/shared/components/json-form/react/json-form.models.ts

@ -16,6 +16,7 @@
import * as tinycolor_ from 'tinycolor2'; import * as tinycolor_ from 'tinycolor2';
import { GroupInfo } from '@shared/models/widget.models'; import { GroupInfo } from '@shared/models/widget.models';
import { MouseEvent } from 'react';
const tinycolor = tinycolor_; const tinycolor = tinycolor_;
@ -46,8 +47,10 @@ export type onChangeFn = (key: (string | number)[], val: any, forceUpdate?: bool
export type OnColorClickFn = (key: (string | number)[], val: tinycolor.ColorFormats.RGBA, export type OnColorClickFn = (key: (string | number)[], val: tinycolor.ColorFormats.RGBA,
colorSelectedFn: (color: tinycolor.ColorFormats.RGBA) => void) => void; colorSelectedFn: (color: tinycolor.ColorFormats.RGBA) => void) => void;
export type OnIconClickFn = (key: (string | number)[], val: string, export type OnIconClickFn = (key: (string | number)[], val: string,
iconSelectedFn: (icon: string) => void) => void; iconSelectedFn: (icon: string) => void) => void;
export type onToggleFullscreenFn = (element: HTMLElement, fullscreenFinishFn?: () => void) => void; export type onToggleFullscreenFn = (element: HTMLElement, fullscreenFinishFn?: () => void) => void;
export type onHelpClickFn = (event: MouseEvent, helpId: string, helpVisibleFn: (visible: boolean) => void,
helpReadyFn: (ready: boolean) => void) => void;
export interface JsonFormProps { export interface JsonFormProps {
model?: any; model?: any;
@ -61,6 +64,7 @@ export interface JsonFormProps {
onColorClick?: OnColorClickFn; onColorClick?: OnColorClickFn;
onIconClick?: OnIconClickFn; onIconClick?: OnIconClickFn;
onToggleFullscreen?: onToggleFullscreenFn; onToggleFullscreen?: onToggleFullscreenFn;
onHelpClick?: onHelpClickFn;
mapper?: {[type: string]: any}; mapper?: {[type: string]: any};
} }
@ -97,6 +101,7 @@ export interface JsonFormData {
items?: Array<KeyLabelItem> | Array<JsonFormData>; items?: Array<KeyLabelItem> | Array<JsonFormData>;
tabs?: Array<JsonFormData>; tabs?: Array<JsonFormData>;
tags?: any; tags?: any;
helpId?: string;
startEmpty?: boolean; startEmpty?: boolean;
[key: string]: any; [key: string]: any;
} }
@ -108,6 +113,7 @@ export type ComponentBuilderFn = (form: JsonFormData,
onColorClick: OnColorClickFn, onColorClick: OnColorClickFn,
onIconClick: OnIconClickFn, onIconClick: OnIconClickFn,
onToggleFullscreen: onToggleFullscreenFn, onToggleFullscreen: onToggleFullscreenFn,
onHelpClick: onHelpClickFn,
mapper: {[type: string]: any}) => JSX.Element; mapper: {[type: string]: any}) => JSX.Element;
export interface JsonFormFieldProps { export interface JsonFormFieldProps {
@ -121,6 +127,7 @@ export interface JsonFormFieldProps {
onIconClick?: OnIconClickFn; onIconClick?: OnIconClickFn;
onChangeValidate?: (e: any, forceUpdate?: boolean) => void; onChangeValidate?: (e: any, forceUpdate?: boolean) => void;
onToggleFullscreen?: onToggleFullscreenFn; onToggleFullscreen?: onToggleFullscreenFn;
onHelpClick?: onHelpClickFn;
valid?: boolean; valid?: boolean;
error?: string; error?: string;
options?: { options?: {

12
ui-ngx/src/app/shared/components/json-form/react/json-form.scss

@ -190,6 +190,18 @@ $previewSize: 100px !default;
font-size: 12px !important; font-size: 12px !important;
} }
} }
button.help-button {
background: rgba(220, 220, 220, .35);
padding: 4px;
}
div.help-button-loading {
pointer-events: none;
background: #f3f3f3;
border-radius: 50%;
display: flex;
place-content: center;
align-items: center;
}
} }
} }

99
ui-ngx/src/app/shared/components/markdown.factory.ts

@ -1,99 +0,0 @@
///
/// 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 { MarkedOptions, MarkedRenderer } from 'ngx-markdown';
export function markedOptionsFactory(): MarkedOptions {
const renderer = new MarkedRenderer();
const renderer2 = new MarkedRenderer();
const copyCodeBlock = '{:copy-code}';
let id = 1;
renderer.code = (code: string, language: string | undefined, isEscaped: boolean) => {
if (code.endsWith(copyCodeBlock)) {
code = code.substring(0, code.length - copyCodeBlock.length);
const content = renderer2.code(code, language, isEscaped);
id++;
return wrapCopyCode(id, content, code);
} else {
return renderer2.code(code, language, isEscaped);
}
};
renderer.tablecell = (content: string, flags: {
header: boolean;
align: 'center' | 'left' | 'right' | null;
}) => {
if (content.endsWith(copyCodeBlock)) {
content = content.substring(0, content.length - copyCodeBlock.length);
id++;
content = wrapCopyCode(id, content, content);
}
return renderer2.tablecell(content, flags);
};
return {
renderer,
headerIds: true,
gfm: true,
breaks: false,
pedantic: false,
smartLists: true,
smartypants: false,
};
}
function wrapCopyCode(id: number, content: string, code: string): string {
return '<div class="code-wrapper">' + content + '<span id="copyCodeId' + id + '" style="display: none;">' + code + '</span>' +
'<button id="copyCodeBtn' + id + '" onClick="markdownCopyCode(' + id + ')" ' +
'class="clipboard-btn"><img src="https://clipboardjs.com/assets/images/clippy.svg" alt="Copy to clipboard">' +
'</button></div>';
}
(window as any).markdownCopyCode = (id: number) => {
const text = $('#copyCodeId' + id).text();
navigator.clipboard.writeText(text).then(() => {
import('tooltipster').then(
() => {
const copyBtn = $('#copyCodeBtn' + id);
if (!copyBtn.hasClass('tooltipstered')) {
copyBtn.tooltipster(
{
content: 'Copied!',
theme: 'tooltipster-shadow',
delay: 0,
trigger: 'custom',
triggerClose: {
click: true,
tap: true,
scroll: true,
mouseleave: true
},
side: 'bottom',
distance: 12,
trackOrigin: true
}
);
}
const tooltip = copyBtn.tooltipster('instance');
tooltip.open();
}
);
});
};

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

@ -0,0 +1,143 @@
///
/// 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 { MarkedOptions, MarkedRenderer } from 'ngx-markdown';
import { Inject, Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { DOCUMENT } from '@angular/common';
import { WINDOW } from '@core/services/window.service';
const copyCodeBlock = '{:copy-code}';
@Injectable()
export class MarkedOptionsService extends MarkedOptions {
renderer = new MarkedRenderer();
headerIds = true;
gfm = true;
breaks = false;
pedantic = false;
smartLists = true;
smartypants = false;
private renderer2 = new MarkedRenderer();
private id = 1;
constructor(private translate: TranslateService,
@Inject(WINDOW) private readonly window: Window,
@Inject(DOCUMENT) private readonly document: Document) {
super();
this.renderer.code = (code: string, language: string | undefined, isEscaped: boolean) => {
if (code.endsWith(copyCodeBlock)) {
code = code.substring(0, code.length - copyCodeBlock.length);
const content = checkLineNumbers(this.renderer2.code(code, language, isEscaped), code);
this.id++;
return this.wrapCopyCode(this.id, content, code);
} else {
return checkLineNumbers(this.renderer2.code(code, language, isEscaped), code);
}
};
this.renderer.tablecell = (content: string, flags: {
header: boolean;
align: 'center' | 'left' | 'right' | null;
}) => {
if (content.endsWith(copyCodeBlock)) {
content = content.substring(0, content.length - copyCodeBlock.length);
this.id++;
content = this.wrapCopyCode(this.id, content, content);
}
return this.renderer2.tablecell(content, flags);
};
this.document.addEventListener('selectionchange', this.onSelectionChange.bind(this));
(this.window as any).markdownCopyCode = this.markdownCopyCode.bind(this);
}
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>` +
`<button class="clipboard-btn">\n` +
` <p>${this.translate.instant('markdown.copy-code')}</p>\n` +
` <div>\n` +
` <img src="/assets/copy-code-icon.svg" alt="${this.translate.instant('markdown.copy-code')}">\n` +
` </div>\n` +
`</button>` +
`</div>`;
}
private onSelectionChange() {
const codeWrappers = $('.code-wrapper');
codeWrappers.removeClass('noChars');
const selectedChars = this.getSelectedText();
if (!selectedChars) {
codeWrappers.addClass('noChars');
}
}
private getSelectedText(): string {
let text;
if (this.window.getSelection) {
text = this.window.getSelection().toString();
} else if (this.document.getSelection) {
text = this.document.getSelection();
} else if ((this.document as any).selection) {
text = (this.document as any).selection.createRange().text;
}
return text;
}
private markdownCopyCode(id: number) {
const copyWrapper = $('#codeWrapper' + id);
if (copyWrapper.hasClass('noChars')) {
const text = $('#copyCodeId' + id).text();
this.window.navigator.clipboard.writeText(text).then(() => {
import('tooltipster').then(
() => {
if (!copyWrapper.hasClass('tooltipstered')) {
copyWrapper.tooltipster(
{
content: this.translate.instant('markdown.copied'),
// theme: 'tooltipster-shadow',
delay: 0,
trigger: 'custom',
triggerClose: {
click: true,
tap: true,
scroll: true,
mouseleave: true
},
side: 'top',
distance: 12,
trackOrigin: true
}
);
}
const tooltip = copyWrapper.tooltipster('instance');
tooltip.open();
}
);
});
}
}
}
function checkLineNumbers(content: string, code: string): string {
const lineCount = code.trim().split('\n').length;
if (lineCount < 2) {
content = content.replace('<pre>', '<pre class="no-line-numbers">');
}
return content;
}

203
ui-ngx/src/app/shared/components/popover.component.scss

@ -0,0 +1,203 @@
/**
* 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 '../scss/mixins';
$popover-arrow-width: 6;
$popover-distance: $popover-arrow-width + 4;
$zindex-popover: 1030;
$popover-bg: #fff;
$border-radius-base: 6px;
$popover-close-button-size: 30px;
$box-shadow-base: 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 9px 28px 8px rgba(0, 0, 0, 0.05), 0 0 8px rgba(0, 0, 0, 0.15);
.tb-popover {
z-index: $zindex-popover;
font-weight: normal;
white-space: normal;
text-align: left;
cursor: auto;
user-select: text;
&::after {
position: absolute;
background: rgba(255, 255, 255, 0.01);
content: '';
}
&-hidden {
display: none;
}
// Offset the popover to account for the popover arrow
&-placement-top,
&-placement-topLeft,
&-placement-topRight {
padding-bottom: $popover-distance + px;
}
&-placement-right,
&-placement-rightTop,
&-placement-rightBottom {
padding-left: $popover-distance + px;
}
&-placement-bottom,
&-placement-bottomLeft,
&-placement-bottomRight {
padding-top: $popover-distance + px;
}
&-placement-left,
&-placement-leftTop,
&-placement-leftBottom {
padding-right: $popover-distance + px;
}
&-inner {
background-color: $popover-bg;
background-clip: padding-box;
border-radius: $border-radius-base;
box-shadow: $box-shadow-base;
overflow: hidden;
position: relative;
}
&-close-button {
cursor: pointer;
position: absolute;
top: 6px;
right: 6px;
padding: 0;
border: none;
text-align: center;
width: $popover-close-button-size;
height: $popover-close-button-size;
font-size: $popover-close-button-size;
color: #8e8e8e;
text-decoration: none;
font-weight: bold;
background: transparent;
z-index: 10;
&:hover {
color: #313131;
}
}
&-inner-content {
padding: 12px 16px;
color: rgba(0, 0, 0, 0.85);
}
// Arrows
// .popover-arrow is outer, .popover-arrow:after is inner
&-arrow {
position: absolute;
display: block;
width: sqrt($popover-arrow-width * $popover-arrow-width * 2) + px;
height: sqrt($popover-arrow-width * $popover-arrow-width * 2) + px;
background: transparent;
border-style: solid;
border-width: (sqrt($popover-arrow-width * $popover-arrow-width * 2) / 2) + px;
transform: rotate(45deg);
z-index: 10;
}
&-placement-top > &-content > &-arrow,
&-placement-topLeft > &-content > &-arrow,
&-placement-topRight > &-content > &-arrow {
bottom: $popover-distance - $popover-arrow-width + 2.2 + px;
border-top-color: transparent;
border-right-color: $popover-bg;
border-bottom-color: $popover-bg;
border-left-color: transparent;
box-shadow: 3px 3px 7px rgba(0, 0, 0, 0.07);
}
&-placement-top > &-content > &-arrow {
left: 50%;
transform: translateX(-50%) rotate(45deg);
}
&-placement-topLeft > &-content > &-arrow {
left: 16px;
}
&-placement-topRight > &-content > &-arrow {
right: 16px;
}
&-placement-right > &-content > &-arrow,
&-placement-rightTop > &-content > &-arrow,
&-placement-rightBottom > &-content > &-arrow {
left: $popover-distance - $popover-arrow-width + 2 + px;
border-top-color: transparent;
border-right-color: transparent;
border-bottom-color: $popover-bg;
border-left-color: $popover-bg;
box-shadow: -3px 3px 7px rgba(0, 0, 0, 0.07);
}
&-placement-right > &-content > &-arrow {
top: 50%;
transform: translateY(-50%) rotate(45deg);
}
&-placement-rightTop > &-content > &-arrow {
top: 12px;
}
&-placement-rightBottom > &-content > &-arrow {
bottom: 12px;
}
&-placement-bottom > &-content > &-arrow,
&-placement-bottomLeft > &-content > &-arrow,
&-placement-bottomRight > &-content > &-arrow {
top: $popover-distance - $popover-arrow-width + 2 + px;
border-top-color: $popover-bg;
border-right-color: transparent;
border-bottom-color: transparent;
border-left-color: $popover-bg;
box-shadow: -2px -2px 5px rgba(0, 0, 0, 0.06);
}
&-placement-bottom > &-content > &-arrow {
left: 50%;
transform: translateX(-50%) rotate(45deg);
}
&-placement-bottomLeft > &-content > &-arrow {
left: 16px;
}
&-placement-bottomRight > &-content > &-arrow {
right: 16px;
}
&-placement-left > &-content > &-arrow,
&-placement-leftTop > &-content > &-arrow,
&-placement-leftBottom > &-content > &-arrow {
right: $popover-distance - $popover-arrow-width + 2 + px;
border-top-color: $popover-bg;
border-right-color: $popover-bg;
border-bottom-color: transparent;
border-left-color: transparent;
box-shadow: 3px -3px 7px rgba(0, 0, 0, 0.07);
}
&-placement-left > &-content > &-arrow {
top: 50%;
transform: translateY(-50%) rotate(45deg);
}
&-placement-leftTop > &-content > &-arrow {
top: 12px;
}
&-placement-leftBottom > &-content > &-arrow {
bottom: 12px;
}
}

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

@ -0,0 +1,725 @@
///
/// 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 {
AfterViewInit,
ChangeDetectionStrategy, ChangeDetectorRef,
Component, ComponentFactory, ComponentFactoryResolver, ComponentRef,
Directive,
ElementRef,
EventEmitter, Injectable, Injector,
Input, OnChanges, OnDestroy, OnInit, Optional,
Output, Renderer2, SimpleChanges,
TemplateRef, Type, ViewChild, ViewContainerRef, ViewEncapsulation
} from '@angular/core';
import { Direction, Directionality } from '@angular/cdk/bidi';
import {
CdkConnectedOverlay,
CdkOverlayOrigin,
ConnectedOverlayPositionChange,
ConnectionPositionPair
} from '@angular/cdk/overlay';
import { Subject, Subscription } from 'rxjs';
import {
DEFAULT_POPOVER_POSITIONS,
getPlacementName,
popoverMotion, PopoverPlacement, PopoverWithTrigger,
POSITION_MAP, PropertyMapping
} from '@shared/components/popover.models';
import { distinctUntilChanged, takeUntil } from 'rxjs/operators';
import { isDefinedAndNotNull, isNotEmptyStr, onParentScrollOrWindowResize } from '@core/utils';
import { ScrollStrategyOptions } from '@angular/cdk/overlay/scroll/scroll-strategy-options';
import { ScrollStrategy } from '@angular/cdk/overlay/scroll/scroll-strategy';
import { taggedTemplate } from '@angular/compiler/src/output/output_ast';
import { HelpMarkdownComponent } from '@shared/components/help-markdown.component';
export type TbPopoverTrigger = 'click' | 'focus' | 'hover' | null;
@Directive({
selector: '[tb-popover]',
exportAs: 'tbPopover',
host: {
'[class.tb-popover-open]': 'visible'
}
})
export class TbPopoverDirective implements OnChanges, OnDestroy, AfterViewInit {
// tslint:disable:no-input-rename
@Input('tbPopoverContent') content?: string | TemplateRef<void>;
@Input('tbPopoverTrigger') trigger?: TbPopoverTrigger = 'hover';
@Input('tbPopoverPlacement') placement?: string | string[] = 'top';
@Input('tbPopoverOrigin') origin?: ElementRef<HTMLElement>;
@Input('tbPopoverVisible') visible?: boolean;
@Input('tbPopoverMouseEnterDelay') mouseEnterDelay?: number;
@Input('tbPopoverMouseLeaveDelay') mouseLeaveDelay?: number;
@Input('tbPopoverOverlayClassName') overlayClassName?: string;
@Input('tbPopoverOverlayStyle') overlayStyle?: { [klass: string]: any };
@Input() tbPopoverBackdrop = false;
// tslint:disable-next-line:no-output-rename
@Output('tbPopoverVisibleChange') readonly visibleChange = new EventEmitter<boolean>();
componentFactory: ComponentFactory<TbPopoverComponent> = this.resolver.resolveComponentFactory(TbPopoverComponent);
component?: TbPopoverComponent;
private readonly destroy$ = new Subject<void>();
private readonly triggerDisposables: Array<() => void> = [];
private delayTimer?;
private internalVisible = false;
constructor(
private elementRef: ElementRef,
private hostView: ViewContainerRef,
private resolver: ComponentFactoryResolver,
private renderer: Renderer2
) {}
ngOnChanges(changes: SimpleChanges): void {
const { trigger } = changes;
if (trigger && !trigger.isFirstChange()) {
this.registerTriggers();
}
if (this.component) {
this.updatePropertiesByChanges(changes);
}
}
ngAfterViewInit(): void {
this.registerTriggers();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.clearTogglingTimer();
this.removeTriggerListeners();
}
show(): void {
if (!this.component) {
this.createComponent();
}
this.component?.show();
}
hide(): void {
this.component?.hide();
}
updatePosition(): void {
if (this.component) {
this.component.updatePosition();
}
}
private createComponent(): void {
const componentRef = this.hostView.createComponent(this.componentFactory);
this.component = componentRef.instance;
this.renderer.removeChild(
this.renderer.parentNode(this.elementRef.nativeElement),
componentRef.location.nativeElement
);
this.component.setOverlayOrigin({ elementRef: this.origin || this.elementRef });
this.initProperties();
this.component.tbVisibleChange
.pipe(distinctUntilChanged(), takeUntil(this.destroy$))
.subscribe((visible: boolean) => {
this.internalVisible = visible;
this.visibleChange.emit(visible);
});
}
private registerTriggers(): void {
// When the method gets invoked, all properties has been synced to the dynamic component.
// After removing the old API, we can just check the directive's own `nzTrigger`.
const el = this.elementRef.nativeElement;
const trigger = this.trigger;
this.removeTriggerListeners();
if (trigger === 'hover') {
let overlayElement: HTMLElement;
this.triggerDisposables.push(
this.renderer.listen(el, 'mouseenter', () => {
this.delayEnterLeave(true, true, this.mouseEnterDelay);
})
);
this.triggerDisposables.push(
this.renderer.listen(el, 'mouseleave', () => {
this.delayEnterLeave(true, false, this.mouseLeaveDelay);
if (this.component?.overlay.overlayRef && !overlayElement) {
overlayElement = this.component.overlay.overlayRef.overlayElement;
this.triggerDisposables.push(
this.renderer.listen(overlayElement, 'mouseenter', () => {
this.delayEnterLeave(false, true, this.mouseEnterDelay);
})
);
this.triggerDisposables.push(
this.renderer.listen(overlayElement, 'mouseleave', () => {
this.delayEnterLeave(false, false, this.mouseLeaveDelay);
})
);
}
})
);
} else if (trigger === 'focus') {
this.triggerDisposables.push(this.renderer.listen(el, 'focusin', () => this.show()));
this.triggerDisposables.push(this.renderer.listen(el, 'focusout', () => this.hide()));
} else if (trigger === 'click') {
this.triggerDisposables.push(
this.renderer.listen(el, 'click', (e: MouseEvent) => {
e.preventDefault();
if (this.component?.visible) {
this.hide();
} else {
this.show();
}
})
);
}
// Else do nothing because user wants to control the visibility programmatically.
}
private updatePropertiesByChanges(changes: SimpleChanges): void {
this.updatePropertiesByKeys(Object.keys(changes));
}
private updatePropertiesByKeys(keys?: string[]): void {
const mappingProperties: PropertyMapping = {
// common mappings
content: ['tbContent', () => this.content],
trigger: ['tbTrigger', () => this.trigger],
placement: ['tbPlacement', () => this.placement],
visible: ['tbVisible', () => this.visible],
mouseEnterDelay: ['tbMouseEnterDelay', () => this.mouseEnterDelay],
mouseLeaveDelay: ['tbMouseLeaveDelay', () => this.mouseLeaveDelay],
overlayClassName: ['tbOverlayClassName', () => this.overlayClassName],
overlayStyle: ['tbOverlayStyle', () => this.overlayStyle],
tbPopoverBackdrop: ['tbBackdrop', () => this.tbPopoverBackdrop]
};
(keys || Object.keys(mappingProperties).filter(key => !key.startsWith('directive'))).forEach(
(property: any) => {
if (mappingProperties[property]) {
const [name, valueFn] = mappingProperties[property];
this.updateComponentValue(name, valueFn());
}
}
);
this.component?.updateByDirective();
}
private initProperties(): void {
this.updatePropertiesByKeys();
}
private updateComponentValue(key: string, value: any): void {
if (typeof value !== 'undefined') {
// @ts-ignore
this.component[key] = value;
}
}
private delayEnterLeave(isOrigin: boolean, isEnter: boolean, delay: number = -1): void {
if (this.delayTimer) {
this.clearTogglingTimer();
} else if (delay > 0) {
this.delayTimer = setTimeout(() => {
this.delayTimer = undefined;
isEnter ? this.show() : this.hide();
}, delay * 1000);
} else {
// `isOrigin` is used due to the tooltip will not hide immediately
// (may caused by the fade-out animation).
isEnter && isOrigin ? this.show() : this.hide();
}
}
private removeTriggerListeners(): void {
this.triggerDisposables.forEach(dispose => dispose());
this.triggerDisposables.length = 0;
}
private clearTogglingTimer(): void {
if (this.delayTimer) {
clearTimeout(this.delayTimer);
this.delayTimer = undefined;
}
}
}
@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, 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.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',
animations: [popoverMotion],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
styleUrls: ['./popover.component.scss'],
template: `
<ng-template
#overlay="cdkConnectedOverlay"
cdkConnectedOverlay
[cdkConnectedOverlayHasBackdrop]="hasBackdrop"
[cdkConnectedOverlayOrigin]="origin"
[cdkConnectedOverlayPositions]="positions"
[cdkConnectedOverlayOpen]="visible"
[cdkConnectedOverlayPush]="true"
(overlayOutsideClick)="onClickOutside($event)"
(detach)="hide()"
(positionChange)="onPositionChange($event)"
>
<div #popoverRoot [@popoverMotion]="tbAnimationState"
(@popoverMotion.done)="animationDone()">
<div
class="tb-popover"
[class.tb-popover-rtl]="dir === 'rtl'"
[ngClass]="classMap"
[ngStyle]="tbOverlayStyle"
>
<div class="tb-popover-content">
<div class="tb-popover-arrow">
<span class="tb-popover-arrow-content"></span>
</div>
<div class="tb-popover-inner" [ngStyle]="tbPopoverInnerStyle" role="tooltip">
<div class="tb-popover-close-button" (click)="closeButtonClick($event)">×</div>
<div>
<div class="tb-popover-inner-content">
<ng-container *ngIf="tbContent">
<ng-container *tbStringTemplateOutlet="tbContent">{{ tbContent }}</ng-container>
</ng-container>
<ng-container *ngIf="tbComponentFactory"
[tbComponentOutlet]="tbComponentFactory"
[tbComponentInjector]="tbComponentInjector"
[tbComponentOutletContext]="tbComponentContext"
(componentChange)="onComponentChange($event)"
[tbComponentStyle]="tbComponentStyle">
</ng-container>
</div>
</div>
</div>
</div>
</div>
</div>
</ng-template>
`
})
export class TbPopoverComponent implements OnDestroy, OnInit {
@ViewChild('overlay', { static: false }) overlay!: CdkConnectedOverlay;
@ViewChild('popoverRoot', { static: false }) popoverRoot!: ElementRef<HTMLElement>;
tbContent: string | TemplateRef<void> | null = null;
tbComponentFactory: ComponentFactory<any> | null = null;
tbComponentRef: ComponentRef<any> | null = null;
tbComponentContext: any;
tbComponentInjector: Injector | null = null;
tbComponentStyle: { [klass: string]: any } = {};
tbOverlayClassName!: string;
tbOverlayStyle: { [klass: string]: any } = {};
tbPopoverInnerStyle: { [klass: string]: any } = {};
tbBackdrop = false;
tbMouseEnterDelay?: number;
tbMouseLeaveDelay?: number;
tbHideOnClickOutside = true;
tbAnimationState = 'active';
tbVisibleChange = new Subject<boolean>();
tbAnimationDone = new Subject<void>();
tbComponentChange = new Subject<ComponentRef<any>>();
tbDestroy = new Subject<void>();
set tbVisible(value: boolean) {
const visible = value;
if (this.visible !== visible) {
this.visible = visible;
this.tbVisibleChange.next(visible);
}
}
get tbVisible(): boolean {
return this.visible;
}
visible = false;
set tbHidden(value: boolean) {
const hidden = value;
if (this.hidden !== hidden) {
this.hidden = hidden;
if (this.hidden) {
this.renderer.setStyle(this.popoverRoot.nativeElement, 'width', this.popoverRoot.nativeElement.offsetWidth + 'px');
this.renderer.setStyle(this.popoverRoot.nativeElement, 'height', this.popoverRoot.nativeElement.offsetHeight + 'px');
} else {
setTimeout(() => {
this.renderer.removeStyle(this.popoverRoot.nativeElement, 'width');
this.renderer.removeStyle(this.popoverRoot.nativeElement, 'height');
});
}
this.updateStyles();
this.cdr.markForCheck();
}
}
get tbHidden(): boolean {
return this.hidden;
}
hidden = false;
lastIsIntersecting = true;
set tbTrigger(value: TbPopoverTrigger) {
this.trigger = value;
}
get tbTrigger(): TbPopoverTrigger {
return this.trigger;
}
protected trigger: TbPopoverTrigger = 'hover';
set tbPlacement(value: PopoverPlacement | PopoverPlacement[]) {
if (typeof value === 'string') {
this.positions = [POSITION_MAP[value], ...DEFAULT_POPOVER_POSITIONS];
} else {
const preferredPosition = value.map(placement => POSITION_MAP[placement]);
this.positions = [...preferredPosition, ...DEFAULT_POPOVER_POSITIONS];
}
}
get hasBackdrop(): boolean {
return this.tbTrigger === 'click' ? this.tbBackdrop : false;
}
preferredPlacement: PopoverPlacement = 'top';
origin!: CdkOverlayOrigin;
public dir: Direction = 'ltr';
classMap: { [klass: string]: any } = {};
positions: ConnectionPositionPair[] = [...DEFAULT_POPOVER_POSITIONS];
private parentScrollSubscription: Subscription = null;
private intersectionObserver = new IntersectionObserver((entries) => {
if (this.lastIsIntersecting !== entries[0].isIntersecting) {
this.lastIsIntersecting = entries[0].isIntersecting;
this.updateStyles();
this.cdr.markForCheck();
}
}, {threshold: [0.5]});
constructor(
public cdr: ChangeDetectorRef,
private renderer: Renderer2,
@Optional() private directionality: Directionality
) {}
ngOnInit(): void {
this.directionality.change?.pipe(takeUntil(this.tbDestroy)).subscribe((direction: Direction) => {
this.dir = direction;
this.cdr.detectChanges();
});
this.dir = this.directionality.value;
}
ngOnDestroy(): void {
if (this.parentScrollSubscription) {
this.parentScrollSubscription.unsubscribe();
this.parentScrollSubscription = null;
}
if (this.origin) {
const el = this.origin.elementRef.nativeElement;
this.intersectionObserver.unobserve(el);
}
this.intersectionObserver.disconnect();
this.intersectionObserver = null;
this.tbVisibleChange.complete();
this.tbAnimationDone.complete();
this.tbDestroy.next();
this.tbDestroy.complete();
}
closeButtonClick($event: Event) {
if ($event) {
$event.preventDefault();
$event.stopPropagation();
}
this.hide();
}
show(): void {
if (this.tbVisible) {
return;
}
if (!this.isEmpty()) {
this.tbVisible = true;
this.tbVisibleChange.next(true);
this.cdr.detectChanges();
}
if (this.origin && this.overlay && this.overlay.overlayRef) {
if (this.overlay.overlayRef.getDirection() === 'rtl') {
this.overlay.overlayRef.setDirection('ltr');
}
const el = this.origin.elementRef.nativeElement;
this.parentScrollSubscription = onParentScrollOrWindowResize(el).subscribe(() => {
this.overlay.overlayRef.updatePosition();
});
this.intersectionObserver.observe(el);
}
}
hide(): void {
if (!this.tbVisible) {
return;
}
if (this.parentScrollSubscription) {
this.parentScrollSubscription.unsubscribe();
this.parentScrollSubscription = null;
}
if (this.origin) {
const el = this.origin.elementRef.nativeElement;
this.intersectionObserver.unobserve(el);
}
this.tbVisible = false;
this.tbVisibleChange.next(false);
this.cdr.detectChanges();
}
updateByDirective(): void {
this.updateStyles();
this.cdr.detectChanges();
Promise.resolve().then(() => {
this.updatePosition();
this.updateVisibilityByContent();
});
}
updatePosition(): void {
if (this.origin && this.overlay && this.overlay.overlayRef) {
this.overlay.overlayRef.updatePosition();
}
}
onPositionChange(position: ConnectedOverlayPositionChange): void {
this.preferredPlacement = getPlacementName(position);
this.updateStyles();
this.cdr.detectChanges();
}
updateStyles(): void {
this.classMap = {
[this.tbOverlayClassName]: true,
[`tb-popover-placement-${this.preferredPlacement}`]: true,
['tb-popover-hidden']: this.tbHidden || !this.lastIsIntersecting
};
}
setOverlayOrigin(origin: CdkOverlayOrigin): void {
this.origin = origin;
this.cdr.markForCheck();
}
onClickOutside(event: MouseEvent): void {
if (this.tbHideOnClickOutside && !this.origin.elementRef.nativeElement.contains(event.target) && this.tbTrigger !== null) {
this.hide();
}
}
onComponentChange(component: ComponentRef<any>) {
this.tbComponentRef = component;
this.tbComponentChange.next(component);
}
animationDone() {
this.tbAnimationDone.next();
}
private updateVisibilityByContent(): void {
if (this.isEmpty()) {
this.hide();
}
}
private isEmpty(): boolean {
return (this.tbComponentFactory instanceof ComponentFactory || this.tbContent instanceof TemplateRef)
? false : !isNotEmptyStr(this.tbContent);
}
}

95
ui-ngx/src/app/shared/components/popover.models.ts

@ -0,0 +1,95 @@
///
/// 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 { animate, AnimationTriggerMetadata, style, transition, trigger } from '@angular/animations';
import { ConnectedOverlayPositionChange, ConnectionPositionPair } from '@angular/cdk/overlay';
import { TbPopoverComponent } from '@shared/components/popover.component';
export const popoverMotion: AnimationTriggerMetadata = trigger('popoverMotion', [
transition('void => active', [
style({ opacity: 0, transform: 'scale(0.8)' }),
animate(
'0.2s cubic-bezier(0.08, 0.82, 0.17, 1)',
style({
opacity: 1,
transform: 'scale(1)'
})
)
]),
transition('active => void', [
style({ opacity: 1, transform: 'scale(1)' }),
animate(
'0.2s cubic-bezier(0.78, 0.14, 0.15, 0.86)',
style({
opacity: 0,
transform: 'scale(0.8)'
})
)
])
]);
export const PopoverPlacements = ['top', 'topLeft', 'topRight', 'right', 'rightTop', 'rightBottom', 'bottom', 'bottomLeft', 'bottomRight', 'left', 'leftTop', 'leftBottom'] as const;
type PopoverPlacementTuple = typeof PopoverPlacements;
export type PopoverPlacement = PopoverPlacementTuple[number];
export const POSITION_MAP: { [key: string]: ConnectionPositionPair } = {
top: new ConnectionPositionPair({ originX: 'center', originY: 'top' }, { overlayX: 'center', overlayY: 'bottom' }),
topLeft: new ConnectionPositionPair({ originX: 'start', originY: 'top' }, { overlayX: 'start', overlayY: 'bottom' }),
topRight: new ConnectionPositionPair({ originX: 'end', originY: 'top' }, { overlayX: 'end', overlayY: 'bottom' }),
right: new ConnectionPositionPair({ originX: 'end', originY: 'center' }, { overlayX: 'start', overlayY: 'center' }),
rightTop: new ConnectionPositionPair({ originX: 'end', originY: 'top' }, { overlayX: 'start', overlayY: 'top' }),
rightBottom: new ConnectionPositionPair(
{ originX: 'end', originY: 'bottom' },
{ overlayX: 'start', overlayY: 'bottom' }
),
bottom: new ConnectionPositionPair({ originX: 'center', originY: 'bottom' }, { overlayX: 'center', overlayY: 'top' }),
bottomLeft: new ConnectionPositionPair(
{ originX: 'start', originY: 'bottom' },
{ overlayX: 'start', overlayY: 'top' }
),
bottomRight: new ConnectionPositionPair({ originX: 'end', originY: 'bottom' }, { overlayX: 'end', overlayY: 'top' }),
left: new ConnectionPositionPair({ originX: 'start', originY: 'center' }, { overlayX: 'end', overlayY: 'center' }),
leftTop: new ConnectionPositionPair({ originX: 'start', originY: 'top' }, { overlayX: 'end', overlayY: 'top' }),
leftBottom: new ConnectionPositionPair(
{ originX: 'start', originY: 'bottom' },
{ overlayX: 'end', overlayY: 'bottom' }
)
};
export const DEFAULT_POPOVER_POSITIONS = [POSITION_MAP.top, POSITION_MAP.right, POSITION_MAP.bottom, POSITION_MAP.left];
export function getPlacementName(position: ConnectedOverlayPositionChange): PopoverPlacement | undefined {
for (const placement in POSITION_MAP) {
if (
position.connectionPair.originX === POSITION_MAP[placement].originX &&
position.connectionPair.originY === POSITION_MAP[placement].originY &&
position.connectionPair.overlayX === POSITION_MAP[placement].overlayX &&
position.connectionPair.overlayY === POSITION_MAP[placement].overlayY
) {
return placement as PopoverPlacement;
}
}
return undefined;
}
export interface PropertyMapping {
[key: string]: [string, () => unknown];
}
export interface PopoverWithTrigger {
trigger: Element;
popoverComponent: TbPopoverComponent;
}

8
ui-ngx/src/app/shared/models/widget.models.ts

@ -24,6 +24,7 @@ import { DataKeyType } from './telemetry/telemetry.models';
import { EntityId } from '@shared/models/id/entity-id'; import { EntityId } from '@shared/models/id/entity-id';
import * as moment_ from 'moment'; import * as moment_ from 'moment';
import { EntityDataPageLink, EntityFilter, KeyFilter } from '@shared/models/query/query.models'; import { EntityDataPageLink, EntityFilter, KeyFilter } from '@shared/models/query/query.models';
import { PopoverPlacement } from '@shared/components/popover.models';
export enum widgetType { export enum widgetType {
timeseries = 'timeseries', timeseries = 'timeseries',
@ -456,6 +457,13 @@ export interface WidgetActionDescriptor extends CustomActionDescriptor {
targetDashboardStateId?: string; targetDashboardStateId?: string;
openRightLayout?: boolean; openRightLayout?: boolean;
openNewBrowserTab?: boolean; openNewBrowserTab?: boolean;
openInPopover?: boolean;
popoverHideDashboardToolbar?: boolean;
popoverPreferredPlacement?: PopoverPlacement;
popoverHideOnClickOutside?: boolean;
popoverWidth?: string;
popoverHeight?: string;
popoverStyle?: { [klass: string]: any };
openInSeparateDialog?: boolean; openInSeparateDialog?: boolean;
dialogTitle?: string; dialogTitle?: string;
dialogHideDashboardToolbar?: boolean; dialogHideDashboardToolbar?: boolean;

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

@ -146,7 +146,12 @@ import { OtaPackageAutocompleteComponent } from '@shared/components/ota-package/
import { MAT_DATE_LOCALE } from '@angular/material/core'; import { MAT_DATE_LOCALE } from '@angular/material/core';
import { CopyButtonComponent } from '@shared/components/button/copy-button.component'; import { CopyButtonComponent } from '@shared/components/button/copy-button.component';
import { TogglePasswordComponent } from '@shared/components/button/toggle-password.component'; import { TogglePasswordComponent } from '@shared/components/button/toggle-password.component';
import { markedOptionsFactory } from '@shared/components/markdown.factory'; import { HelpPopupComponent } from '@shared/components/help-popup.component';
import { TbPopoverComponent, TbPopoverDirective, TbPopoverService } 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';
@NgModule({ @NgModule({
providers: [ providers: [
@ -164,7 +169,8 @@ import { markedOptionsFactory } from '@shared/components/markdown.factory';
{ {
provide: MAT_DATE_LOCALE, provide: MAT_DATE_LOCALE,
useValue: 'en-GB' useValue: 'en-GB'
} },
TbPopoverService
], ],
declarations: [ declarations: [
FooterComponent, FooterComponent,
@ -176,7 +182,13 @@ import { markedOptionsFactory } from '@shared/components/markdown.factory';
MatChipDraggableDirective, MatChipDraggableDirective,
TbHotkeysDirective, TbHotkeysDirective,
TbAnchorComponent, TbAnchorComponent,
TbPopoverComponent,
TbStringTemplateOutletDirective,
TbComponentOutletDirective,
TbPopoverDirective,
HelpComponent, HelpComponent,
HelpMarkdownComponent,
HelpPopupComponent,
TbCheckboxComponent, TbCheckboxComponent,
TbSnackBarComponent, TbSnackBarComponent,
TbErrorComponent, TbErrorComponent,
@ -302,7 +314,7 @@ import { markedOptionsFactory } from '@shared/components/markdown.factory';
sanitize: SecurityContext.NONE, sanitize: SecurityContext.NONE,
markedOptions: { markedOptions: {
provide: MarkedOptions, provide: MarkedOptions,
useFactory: markedOptionsFactory useClass: MarkedOptionsService
} }
}) })
], ],
@ -316,7 +328,12 @@ import { markedOptionsFactory } from '@shared/components/markdown.factory';
MatChipDraggableDirective, MatChipDraggableDirective,
TbHotkeysDirective, TbHotkeysDirective,
TbAnchorComponent, TbAnchorComponent,
TbStringTemplateOutletDirective,
TbComponentOutletDirective,
TbPopoverDirective,
HelpComponent, HelpComponent,
HelpMarkdownComponent,
HelpPopupComponent,
TbCheckboxComponent, TbCheckboxComponent,
TbErrorComponent, TbErrorComponent,
TbCheatSheetComponent, TbCheatSheetComponent,

4
ui-ngx/src/assets/copy-code-icon.svg

@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 0C1.34315 0 0 1.34315 0 3V13H2V3C2 2.44772 2.44772 2 3 2H13V0H3Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M6 3C4.34315 3 3 4.34315 3 6V14C3 15.6569 4.34315 17 6 17H14C15.6569 17 17 15.6569 17 14V6C17 4.34315 15.6569 3 14 3H6ZM5 6C5 5.44772 5.44772 5 6 5H14C14.5523 5 15 5.44772 15 6V14C15 14.5523 14.5523 15 14 15H6C5.44772 15 5 14.5523 5 14V6Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 500 B

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

@ -0,0 +1,5 @@
#### Show cell button action JavaScript Function
```javascript
return data.entityName === 'Test device'; {:copy-code}
```

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

@ -0,0 +1,41 @@
#### Show widget header action function
<div class="divider"></div>
<br/>
*function (widgetContext, data): boolean*
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/>
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/>
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>
**Returns:**
`true` if header 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 first entity is device and has type `thermostat`:
```javascript
return data[0] && data[0].entityType === 'DEVICE' && data[0].Type === 'thermostat';
{:copy-code}
```

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

@ -0,0 +1,12 @@
#### Tooltip value format JavaScript Function
##### Input arguments
- value - value to format
```javascript
return value;
{:copy-code}
```
##### Examples

28
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -2171,7 +2171,8 @@
"scroll-to-top": "Scroll to top" "scroll-to-top": "Scroll to top"
}, },
"help": { "help": {
"goto-help-page": "Go to help page" "goto-help-page": "Go to help page",
"show-help": "Show help"
}, },
"home": { "home": {
"home": "Home", "home": "Home",
@ -2315,6 +2316,10 @@
"or": "or", "or": "or",
"error": "Login error" "error": "Login error"
}, },
"markdown": {
"copy-code": "Click to copy",
"copied": "Copied!"
},
"ota-update": { "ota-update": {
"add": "Add package", "add": "Add package",
"assign-firmware": "Assigned firmware", "assign-firmware": "Assigned firmware",
@ -2940,12 +2945,33 @@
"set-entity-from-widget": "Set entity from widget", "set-entity-from-widget": "Set entity from widget",
"target-dashboard": "Target dashboard", "target-dashboard": "Target dashboard",
"open-right-layout": "Open right dashboard layout (mobile view)", "open-right-layout": "Open right dashboard layout (mobile view)",
"state-display-type": "Dashboard state display option",
"open-normal": "Normal",
"open-in-separate-dialog": "Open in separate dialog", "open-in-separate-dialog": "Open in separate dialog",
"open-in-popover": "Open in popover",
"dialog-title": "Dialog title", "dialog-title": "Dialog title",
"dialog-hide-dashboard-toolbar": "Hide dashboard toolbar in dialog", "dialog-hide-dashboard-toolbar": "Hide dashboard toolbar in dialog",
"dialog-width": "Dialog width in percents relative to viewport width", "dialog-width": "Dialog width in percents relative to viewport width",
"dialog-height": "Dialog height in percents relative to viewport height", "dialog-height": "Dialog height in percents relative to viewport height",
"dialog-size-range-error": "Dialog size percent value should be in a range from 1 to 100.", "dialog-size-range-error": "Dialog size percent value should be in a range from 1 to 100.",
"popover-preferred-placement": "Preferred popover placement",
"popover-placement-top": "Top",
"popover-placement-topLeft": "Top left",
"popover-placement-topRight": "Top right",
"popover-placement-right": "Right",
"popover-placement-rightTop": "Right top",
"popover-placement-rightBottom": "Right bottom",
"popover-placement-bottom": "Bottom",
"popover-placement-bottomLeft": "Bottom left",
"popover-placement-bottomRight": "Bottom right",
"popover-placement-left": "Left",
"popover-placement-leftTop": "Left top",
"popover-placement-leftBottom": "Left bottom",
"popover-hide-on-click-outside": "Hide popover on outside click",
"popover-hide-dashboard-toolbar": "Hide dashboard toolbar in popover",
"popover-width": "Popover width in browser units (ex. 100px, 25vw)",
"popover-height": "Popover height in browser units (ex. 100px, 25vh)",
"popover-style": "Popover style",
"open-new-browser-tab": "Open in a new browser tab", "open-new-browser-tab": "Open in a new browser tab",
"mobile": { "mobile": {
"action-type": "Mobile action type", "action-type": "Mobile action type",

12
ui-ngx/src/scss/mixins.scss

@ -45,3 +45,15 @@
background-position: 0 0, 4px 4px; background-position: 0 0, 4px 4px;
background-size: 8px 8px; background-size: 8px 8px;
} }
@function sqrt($r) {
$x0: 1;
$x1: $x0;
@for $i from 1 through 10 {
$x1: $x0 - ($x0 * $x0 - abs($r)) / (2 * $x0);
$x0: $x1;
}
@return $x1;
}

360
ui-ngx/src/styles.scss

@ -125,6 +125,20 @@ h4 {
letter-spacing: .01em; letter-spacing: .01em;
} }
h5 {
font-size: 1.4rem;
font-weight: 400;
line-height: 2rem;
letter-spacing: .01em;
}
h6 {
font-size: 1.2rem;
font-weight: 400;
line-height: 1.6rem;
letter-spacing: .01em;
}
p { p {
margin: .8em 0 1.6em; margin: .8em 0 1.6em;
font-size: 1.6rem; font-size: 1.6rem;
@ -469,6 +483,352 @@ mat-label {
} }
} }
.tb-markdown-view {
display: block;
$headings: h1, h2, h3, h4, h5, h6;
h1 {
font-size: 32px;
padding-right: 60px;
}
@each $heading in $headings {
#{$heading} {
color: #0F161D;
line-height: normal;
font-weight: 500;
padding: 30px 32px 10px;
border-bottom: none;
margin: 0;
}
}
p {
font-size: 16px;
font-weight: 400;
line-height: 1.25em;
margin: 0;
}
p + p {
margin-top: 10px;
}
p, div {
padding-left: 32px;
padding-right: 32px;
color: rgba(15, 22, 29, 0.8);
line-height: 1.5em;
}
ul {
padding-left: 62px;
padding-right: 32px;
color: rgba(15, 22, 29, 0.8);
margin-top: 16px;
margin-bottom: 16px;
}
ul {
@each $heading in $headings {
& + #{$heading} {
padding-top: 14px;
}
}
}
li {
padding-bottom: .75em;
line-height: 1.5em;
}
a {
font-weight: 500;
color: #2a7dec;
text-decoration: none;
border: none;
&:hover {
color: #2a7dec;
text-decoration: underline;
border: none;
}
}
table {
margin-left: 32px;
width: calc(100% - 64px);
border: 1px solid rgba(42,125,236,.2);
border-radius: 4px;
border-collapse: unset;
border-spacing: 0;
margin-top: 30px;
margin-bottom: 30px;
overflow: hidden;
table-layout: fixed;
& > thead {
background-color: #f9fbff;
color: rgba(33,37,41,.6);
& > tr {
& > th {
border-bottom: 1px solid rgba(42,125,236,.2);
font-size: 16px;
padding: 12px 16px;
text-align: left;
margin: 0;
word-break: break-word;
@media screen and (max-width: 400px) {
font-size: 12px;
padding: 12px 4px;
}
}
}
}
& > tbody {
& > tr:not(:last-child) {
& > td {
border-bottom: 1px solid rgba(42,125,236,.2);
}
}
& > tr {
& > td {
font-size: 16px;
padding: 12px 16px;
text-align: left;
margin: 0;
word-break: break-word;
color: rgba(15, 22, 29, 0.8);
@media screen and (max-width: 400px) {
font-size: 12px;
padding: 12px 4px;
}
}
}
}
th, td {
font-size: .85em;
padding: 8px;
margin: 0;
text-align: left;
}
td[align=center], th[align=center] {
text-align: center;
}
td[align=right], th[align=right] {
text-align: right;
}
tr td div {
padding-left: 0;
padding-right: 0;
}
}
div.divider {
padding-top: 32px;
border-bottom: 1px solid rgba(15, 22, 29, 0.1);
}
ul + div.divider {
padding-top: 16px;
}
img {
max-width: 100%;
}
button.tb-button {
cursor: pointer;
display: inline-block;
border-radius: 4px;
border: none;
padding: 10px 20px;
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);
text-decoration: none;
font-size: 16px;
font-weight: 500;
transition: background-color .4s cubic-bezier(.25,.8,.25,1);
}
button.tb-button:hover {
background-color: #264363;
color: #fff;
text-decoration: none;
}
#video {
width: 100%;
margin: 0;
position: relative;
}
#video #video_wrapper {
position: relative;
width: 100%;
padding-bottom: 56.25%;
padding-left: 0;
padding-right: 0;
}
#video #video_wrapper iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
@media screen and (min-width: 750px) {
#video {
width: 100%;
display: block;
}
}
@media screen and (min-width: 1025px) {
#video {
width: 50%;
position: relative;
}
}
code:not([class*=language-]) {
color: #eb5757;
font-family: monospace;
font-size: 16px;
}
div.code-wrapper {
position: relative;
button.clipboard-btn {
pointer-events: none;
outline: none;
position: absolute;
width: 206px;
height: 42px;
top: 0;
right: 32px;
background: 0 0;
border: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
p {
padding: 8px;
top: 1px;
transition: .2s;
color: #2a7dec;
background: rgba(255,255,255,.85);
backdrop-filter: blur(4px);
opacity: 0;
font-weight: 500;
right: 32px;
position: absolute;
}
div {
background-color: #fff;
position: absolute;
width: 38px;
height: 38px;
top: 3px;
right: 3px;
padding: 10px;
img {
position: initial;
width: 18px;
height: 18px;
filter: invert(51%) sepia(6%) saturate(172%) hue-rotate(177deg) brightness(94%) contrast(92%);
}
}
}
&:hover {
cursor: pointer;
pre[class*="language-"] {
border: solid 1px #2a7dec;
}
button.clipboard-btn {
p {
opacity: 1;
}
div {
img {
filter: invert(49%) sepia(97%) saturate(3730%) hue-rotate(200deg) brightness(95%) contrast(95%);
}
}
}
}
}
th, td {
div.code-wrapper {
display: inline-block;
width: 100%;
button.clipboard-btn {
top: -10px;
right: 0px;
padding: 0 3px;
}
}
}
pre[class*="language-"] {
font-size: 16px;
border: 1px solid rgba(42,125,236,.2);
border-radius: 4px;
background: 0 0;
padding: 8px 16px;
color: #212529;
.token.atrule, .token.attr-value, .token.keyword {
color: #2a7dec;
}
.token.selector, .token.attr-name, .token.string, .token.char, .token.builtin, .token.inserted {
color: #eb5757;
}
.token.punctuation {
color: #212529;
}
&.line-numbers {
padding-left: 66px;
& > code {
span.line-numbers-rows {
top: -12px;
bottom: -12px;
left: -66px;
width: 50px;
border: none;
padding: 8px 12px 8px 18px;
text-align: right;
background: #f9fbff;
& > span:before {
color: rgba(33,37,41,.6);
padding-right: 0;
}
}
}
&.no-line-numbers {
padding-left: 16px;
& > code {
span.line-numbers-rows {
display: none;
}
}
}
}
}
}
.tb-default, .tb-dark { .tb-default, .tb-dark {
/********************************* /*********************************

Loading…
Cancel
Save