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,
"resources": [],
"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",
"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",
"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",
"node_modules/leaflet.markercluster/dist/MarkerCluster.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": {
"includePaths": [
@ -92,8 +93,11 @@
"node_modules/systemjs/dist/system.js",
"node_modules/marked/lib/marked.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-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": {
"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 { AlarmDataService } from '@core/api/alarm-data.service';
import { IDashboardController } from '@home/components/dashboard-page/dashboard-page.models';
import { PopoverPlacement } from '@shared/components/popover.models';
export interface TimewindowFunctions {
onUpdateTimewindow: (startTimeMs: number, endTimeMs: number, interval?: number) => void;
@ -89,6 +90,10 @@ export interface WidgetActionsApi {
getActiveEntityInfo: () => SubscriptionEntityInfo;
openDashboardStateInSeparateDialog: (targetDashboardStateId: string, params?: StateParams, dialogTitle?: string,
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 {

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"
[isEditingWidget]="isEditingWidget"
[isMobile]="forceDashboardMobileMode"
[widgetEditMode]="widgetEditMode">
[widgetEditMode]="widgetEditMode"
[parentDashboard]="parentDashboard">
</tb-dashboard-layout>
</mat-drawer>
<mat-drawer-content [fxShow]="layouts.main.show"
@ -190,7 +191,8 @@
[isEdit]="isEdit"
[isEditingWidget]="isEditingWidget"
[isMobile]="forceDashboardMobileMode"
[widgetEditMode]="widgetEditMode">
[widgetEditMode]="widgetEditMode"
[parentDashboard]="parentDashboard">
</tb-dashboard-layout>
</mat-drawer-content>
</mat-drawer-container>
@ -219,7 +221,7 @@
<mat-icon>{{ isEdit ? 'close' : 'edit' }}</mat-icon>
</button>
</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>
</section>
</mat-drawer-content>

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

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

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

@ -57,6 +57,7 @@
[isExportActionEnabled]="isEdit && !widgetEditMode"
[isRemoveActionEnabled]="isEdit && !widgetEditMode"
[callbacks]="this"
[ignoreLoading]="layoutCtx.ignoreLoading">
[ignoreLoading]="layoutCtx.ignoreLoading"
[parentDashboard]="parentDashboard">
</tb-dashboard>
</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()
widgetEditMode: boolean;
@Input()
parentDashboard?: IDashboardComponent = null;
@ViewChild('dashboard', {static: true}) dashboard: IDashboardComponent;
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()
dashboardTimewindow: Timewindow;
@Input()
parentDashboard?: IDashboardComponent = null;
dashboardTimewindowChangedSubject: Subject<Timewindow> = new ReplaySubject<Timewindow>();
dashboardTimewindowChanged = this.dashboardTimewindowChangedSubject.asObservable().pipe(
@ -187,6 +190,7 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo
}
ngOnInit(): void {
this.dashboardWidgets.parentDashboard = this.parentDashboard;
if (!this.dashboardTimewindow) {
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.
-->
<form [formGroup]="widgetActionFormGroup" (ngSubmit)="save()" style="min-width: 480px;">
<form [formGroup]="widgetActionFormGroup" (ngSubmit)="save()" style="width: 800px;">
<mat-toolbar color="primary">
<h2>{{ (isAdd ? 'widget-config.add-action' : 'widget-config.edit-action' ) | translate }}</h2>
<span fxFlex></span>
@ -59,6 +59,7 @@
</mat-checkbox>
<tb-js-func *ngIf="displayShowWidgetActionForm() && widgetActionFormGroup.get('useShowWidgetActionFunction').value"
formControlName="showWidgetActionFunction"
[helpId]="getWidgetActionFunctionHelpId()"
[functionArgs]="['widgetContext', 'data']"
[globalVariables]="functionScopeVariables"
[validationArgs]="[]"
@ -145,37 +146,75 @@
</ng-template>
<ng-template [ngSwitchCase]="widgetActionFormGroup.get('type').value === widgetActionType.openDashboardState ?
widgetActionFormGroup.get('type').value : ''">
<mat-checkbox formControlName="openInSeparateDialog">
{{ 'widget-action.open-in-separate-dialog' | translate }}
</mat-checkbox>
<section *ngIf="actionTypeFormGroup.get('openInSeparateDialog').value">
<mat-form-field class="mat-block">
<mat-label translate>widget-action.dialog-title</mat-label>
<input matInput formControlName="dialogTitle">
</mat-form-field>
<mat-checkbox formControlName="dialogHideDashboardToolbar">
{{ 'widget-action.dialog-hide-dashboard-toolbar' | translate }}
</mat-checkbox>
<mat-form-field class="mat-block">
<mat-label translate>widget-action.dialog-width</mat-label>
<input type="number" min="1" max="100" step="1" matInput formControlName="dialogWidth">
<mat-error *ngIf="actionTypeFormGroup.get('dialogWidth').hasError('min')">
{{ 'widget-action.dialog-size-range-error' | translate }}
</mat-error>
<mat-error *ngIf="actionTypeFormGroup.get('dialogWidth').hasError('max')">
{{ 'widget-action.dialog-size-range-error' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>widget-action.dialog-height</mat-label>
<input type="number" min="1" max="100" step="1" matInput formControlName="dialogHeight">
<mat-error *ngIf="actionTypeFormGroup.get('dialogHeight').hasError('min')">
{{ 'widget-action.dialog-size-range-error' | translate }}
</mat-error>
<mat-error *ngIf="actionTypeFormGroup.get('dialogHeight').hasError('max')">
{{ 'widget-action.dialog-size-range-error' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>widget-action.state-display-type</mat-label>
<mat-select formControlName="stateDisplayType">
<mat-option *ngFor="let displayType of allStateDisplayTypes" [value]="displayType">
{{ stateDisplayTypeName(displayType) }}
</mat-option>
</mat-select>
</mat-form-field>
<section fxLayout="column" [formGroup]="stateDisplayTypeFormGroup" [ngSwitch]="actionTypeFormGroup.get('stateDisplayType').value">
<ng-template [ngSwitchCase]="'separateDialog'">
<mat-form-field class="mat-block">
<mat-label translate>widget-action.dialog-title</mat-label>
<input matInput formControlName="dialogTitle">
</mat-form-field>
<mat-checkbox formControlName="dialogHideDashboardToolbar">
{{ 'widget-action.dialog-hide-dashboard-toolbar' | translate }}
</mat-checkbox>
<mat-form-field class="mat-block">
<mat-label translate>widget-action.dialog-width</mat-label>
<input type="number" min="1" max="100" step="1" matInput formControlName="dialogWidth">
<mat-error *ngIf="stateDisplayTypeFormGroup.get('dialogWidth').hasError('min')">
{{ 'widget-action.dialog-size-range-error' | translate }}
</mat-error>
<mat-error *ngIf="stateDisplayTypeFormGroup.get('dialogWidth').hasError('max')">
{{ 'widget-action.dialog-size-range-error' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>widget-action.dialog-height</mat-label>
<input type="number" min="1" max="100" step="1" matInput formControlName="dialogHeight">
<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>
</ng-template>
<ng-template [ngSwitchCase]="widgetActionType.custom">
@ -209,7 +248,8 @@
</button>
<button mat-raised-button color="primary"
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 }}
</button>
</div>

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

@ -28,7 +28,7 @@ import {
ValidatorFn,
Validators
} from '@angular/forms';
import { Observable, of } from 'rxjs';
import { Observable, of, Subscription } from 'rxjs';
import { Router } from '@angular/router';
import { DialogComponent } from '@app/shared/components/dialog.component';
import {
@ -52,6 +52,8 @@ import { isDefinedAndNotNull } from '@core/utils';
import { MobileActionEditorComponent } from '@home/components/widget/action/mobile-action-editor.component';
import { widgetType } from '@shared/models/widget.models';
import { WidgetService } from '@core/http/widget.service';
import { TranslateService } from '@ngx-translate/core';
import { PopoverPlacement, PopoverPlacements } from '@shared/components/popover.models';
export interface WidgetActionDialogData {
isAdd: boolean;
@ -61,6 +63,18 @@ export interface WidgetActionDialogData {
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({
selector: 'tb-widget-action-dialog',
templateUrl: './widget-action-dialog.component.html',
@ -76,6 +90,8 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia
widgetActionFormGroup: FormGroup;
actionTypeFormGroup: FormGroup;
actionTypeFormGroupSubscriptions: Subscription[] = [];
stateDisplayTypeFormGroup: FormGroup;
isAdd: boolean;
action: WidgetActionDescriptorInfo;
@ -95,12 +111,16 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia
functionScopeVariables: string[];
allStateDisplayTypes = stateDisplayTypes;
allPopoverPlacements = PopoverPlacements;
constructor(protected store: Store<AppState>,
protected router: Router,
private utils: UtilsService,
private dashboardService: DashboardService,
private dashboardUtils: DashboardUtilsService,
private widgetService: WidgetService,
private translate: TranslateService,
@Inject(MAT_DIALOG_DATA) public data: WidgetActionDialogData,
@SkipSelf() private errorStateMatcher: ErrorStateMatcher,
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;
}
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() {
const actionSourceId = this.widgetActionFormGroup.get('actionSourceId').value;
const useShowWidgetActionFunction = this.widgetActionFormGroup.get('useShowWidgetActionFunction').value;
@ -164,6 +194,8 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia
}
private updateActionTypeFormGroup(type?: WidgetActionType, action?: WidgetActionDescriptorInfo) {
this.actionTypeFormGroupSubscriptions.forEach(s => s.unsubscribe());
this.actionTypeFormGroupSubscriptions.length = 0;
this.actionTypeFormGroup = this.fb.group({});
if (type) {
switch (type) {
@ -196,25 +228,16 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia
this.setupSelectedDashboardStateIds(action ? action.targetDashboardId : null);
} else {
if (type === WidgetActionType.openDashboardState) {
const displayType = this.getStateDisplayType(action);
this.actionTypeFormGroup.addControl(
'openInSeparateDialog',
this.fb.control(action ? action.openInSeparateDialog : false, [])
);
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)])
'stateDisplayType',
this.fb.control(this.getStateDisplayType(action), [Validators.required])
);
this.actionTypeFormGroup.addControl(
'dialogHeight',
this.fb.control(action ? action.dialogHeight : null, [Validators.min(1), Validators.max(100)])
this.updateStateDisplayTypeFormGroup(displayType, action);
this.actionTypeFormGroupSubscriptions.push(
this.actionTypeFormGroup.get('stateDisplayType').valueChanges.subscribe((displayTypeValue: stateDisplayType) => {
this.updateStateDisplayTypeFormGroup(displayTypeValue);
})
);
}
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) {
this.selectedDashboardStateIds =
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 {
this.dialogRef.close(null);
}
@ -369,6 +478,16 @@ export class WidgetActionDialogComponent extends DialogComponent<WidgetActionDia
} else {
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;
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({
key: 'tooltipValueFormatter',
type: 'javascript'
type: 'javascript',
helpId: 'widget/lib/tooltip_value_format_fn'
});
schema.form.push('hideZeros');
schema.form.push('showTooltip');

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

@ -16,7 +16,7 @@
import L, {
FeatureGroup,
Icon, LatLng,
Icon,
LatLngBounds,
LatLngTuple,
markerClusterGroup,
@ -80,8 +80,13 @@ export default abstract class LeafletMap {
updatePending = false;
addMarkers: L.Marker[] = [];
addPolygons: L.Polygon[] = [];
// tslint:disable-next-line:no-string-literal
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);
mousePositionOnMap: L.LatLng;
addMarker: L.Control;
addPolygon: L.Control;
protected constructor(public ctx: WidgetContext,
public $container: HTMLElement,
@ -120,13 +125,11 @@ export default abstract class LeafletMap {
addMarkerControl() {
if (this.options.draggableMarker) {
let mousePositionOnMap: L.LatLng;
let addMarker: L.Control;
this.map.on('mousemove', (e: L.LeafletMouseEvent) => {
mousePositionOnMap = e.latlng;
this.mousePositionOnMap = e.latlng;
});
const dragListener = (e: L.DragEndEvent) => {
if (e.type === 'dragend' && mousePositionOnMap) {
if (e.type === 'dragend' && this.mousePositionOnMap) {
const icon = L.icon({
iconRetinaUrl: 'marker-icon-2x.png',
iconUrl: 'marker-icon.png',
@ -137,11 +140,11 @@ export default abstract class LeafletMap {
tooltipAnchor: [16, -28],
shadowSize: [41, 41]
});
const customLatLng = this.convertToCustomFormat(mousePositionOnMap);
mousePositionOnMap.lat = customLatLng[this.options.latKeyName];
mousePositionOnMap.lng = customLatLng[this.options.lngKeyName];
const customLatLng = this.convertToCustomFormat(this.mousePositionOnMap);
this.mousePositionOnMap.lat = customLatLng[this.options.latKeyName];
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);
const datasourcesList = document.createElement('div');
const header = document.createElement('p');
@ -181,9 +184,9 @@ export default abstract class LeafletMap {
popup.setContent(datasourcesList);
newMarker.bindPopup(popup).openPopup();
}
addMarker.setPosition('topright');
this.addMarker.setPosition('topright');
};
L.Control.AddMarker = L.Control.extend({
const addMarker = L.Control.extend({
onAdd() {
const img = L.DomUtil.create('img') as any;
img.src = `assets/add_location.svg`;
@ -201,34 +204,31 @@ export default abstract class LeafletMap {
},
dragMarker: this.dragMarker
} as any);
L.control.addMarker = (opts) => {
return new L.Control.AddMarker(opts);
const addMarkerControl = (opts) => {
return new addMarker(opts);
};
addMarker = L.control.addMarker({ position: 'topright' }).addTo(this.map);
this.addMarker = addMarkerControl({ position: 'topright' }).addTo(this.map);
}
}
addPolygonControl() {
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) => {
mousePositionOnMap = e.latlng;
this.mousePositionOnMap = e.latlng;
});
const dragListener = (e: L.DragEndEvent) => {
if (e.type === 'dragend') {
const polygonOffset = this.options.provider === MapProviders.image ? 10 : 0.01;
let convert = this.convertToCustomFormat(mousePositionOnMap,polygonOffset);
mousePositionOnMap.lat = convert[this.options.latKeyName];
mousePositionOnMap.lng = convert[this.options.lngKeyName];
const convert = this.convertToCustomFormat(this.mousePositionOnMap, polygonOffset);
this.mousePositionOnMap.lat = convert[this.options.latKeyName];
this.mousePositionOnMap.lng = convert[this.options.lngKeyName];
const latlng1 = mousePositionOnMap;
const latlng2 = L.latLng(mousePositionOnMap.lat, mousePositionOnMap.lng + polygonOffset);
const latlng3 = L.latLng(mousePositionOnMap.lat - polygonOffset, mousePositionOnMap.lng);
polygonPoints = [latlng1, latlng2, latlng3];
const latlng1 = this.mousePositionOnMap;
const latlng2 = L.latLng(this.mousePositionOnMap.lat, this.mousePositionOnMap.lng + polygonOffset);
const latlng3 = L.latLng(this.mousePositionOnMap.lat - polygonOffset, this.mousePositionOnMap.lng);
const polygonPoints = [latlng1, latlng2, latlng3];
const newPolygon = L.polygon(polygonPoints).addTo(this.map);
this.addPolygons.push(newPolygon);
@ -270,9 +270,9 @@ export default abstract class LeafletMap {
popup.setContent(datasourcesList);
newPolygon.bindPopup(popup).openPopup();
}
addPolygon.setPosition('topright');
this.addPolygon.setPosition('topright');
};
L.Control.AddPolygon = L.Control.extend({
const addPolygon = L.Control.extend({
onAdd() {
const img = L.DomUtil.create('img') as any;
img.src = `assets/add_polygon.svg`;
@ -290,10 +290,10 @@ export default abstract class LeafletMap {
},
dragPolygonVertex: this.dragPolygonVertex
} as any);
L.control.addPolygon = (opts) => {
return new L.Control.AddPolygon(opts);
const addPolygonControl = (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) {
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) {
this.map.panTo(this.options.defaultCenterPosition);
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')) {
return;
}
if(this.options.provider !== MapProviders.image) {
for (let key in e.layer._latlngs[0]) {
if (this.options.provider !== MapProviders.image) {
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);
}
}
@ -776,4 +789,11 @@ export default abstract class LeafletMap {
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;
resize();
update();
onInit();
onDestroy();
destroy();
}
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);
this.map = new MapClass(this.ctx, $element, this.settings);
(this.ctx as any).mapInstance = this.map;
this.map.saveMarkerLocation = this.setMarkerLocation;
this.map.savePolygonLocation = this.savePolygonLocation;
this.pageLink = {
@ -335,7 +334,10 @@ export class MapWidgetController implements MapWidgetInterface {
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.
-->
<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.
///
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 { WidgetContext } from '@home/models/widget-component.models';
import { Store } from '@ngrx/store';
@ -44,20 +44,21 @@ type MarkdownTextFunction = (data: FormattedData[]) => string;
@Component({
selector: 'tb-markdown-widget ',
templateUrl: './markdown-widget.component.html',
styleUrls: ['./markdown-widget.component.scss']
templateUrl: './markdown-widget.component.html'
})
export class MarkdownWidgetComponent extends PageComponent implements OnInit {
settings: MarkdownWidgetSettings;
markdownTextFunction: MarkdownTextFunction;
@HostBinding('class')
markdownClass: string;
@Input()
ctx: WidgetContext;
markdownText: string;
markdownClass: string;
constructor(protected store: Store<AppState>,
private utils: UtilsService,
@ -75,7 +76,7 @@ export class MarkdownWidgetComponent extends PageComponent implements OnInit {
const cssParser = new cssjs();
cssParser.testMode = false;
this.markdownClass += '-' + hashCode(cssString);
cssParser.cssPreviewNamespace = 'tb-markdown-view.' + this.markdownClass;
cssParser.cssPreviewNamespace = this.markdownClass;
cssParser.createStyleElement(this.markdownClass, cssString);
}
}

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

@ -28,7 +28,7 @@ import {
NgZone,
OnChanges,
OnDestroy,
OnInit,
OnInit, Renderer2,
SimpleChanges,
ViewChild,
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 { MobileService } from '@core/services/mobile.service';
import { DialogService } from '@core/services/dialog.service';
import { TbPopoverService } from '@shared/components/popover.component';
import { DashboardPageComponent } from '@home/components/dashboard-page/dashboard-page.component';
import { PopoverPlacement } from '@shared/components/popover.models';
@Component({
selector: 'tb-widget',
@ -176,6 +179,8 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
private elementRef: ElementRef,
private injector: Injector,
private dialog: MatDialog,
private renderer: Renderer2,
private popoverService: TbPopoverService,
@Inject(EMBED_DASHBOARD_DIALOG_TOKEN) private embedDashboardDialogComponent: ComponentType<any>,
private widgetService: WidgetService,
private resources: ResourcesService,
@ -293,7 +298,8 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
handleWidgetAction: this.handleWidgetAction.bind(this),
elementClick: this.elementClick.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 = [];
@ -1053,7 +1059,11 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI
const params = deepClone(this.widgetContext.stateController.getStateParams());
this.updateEntityParams(params, targetEntityParamName, targetEntityId, entityName, entityLabel);
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,
descriptor.dialogHideDashboardToolbar, descriptor.dialogWidth, descriptor.dialogHeight);
} 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,
hideDashboardToolbar = true, dialogWidth?: number, dialogHeight?: number) {
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>;
widgetLayouts: WidgetLayouts;
parentDashboard?: IDashboardComponent;
[Symbol.iterator](): Iterator<DashboardWidget> {
return this.activeDashboardWidgets[Symbol.iterator]();
}
@ -166,7 +168,7 @@ export class DashboardWidgets implements Iterable<DashboardWidget> {
switch (record.operation) {
case 'add':
this.dashboardWidgets.push(
new DashboardWidget(this.dashboard, record.widget, record.widgetLayout)
new DashboardWidget(this.dashboard, record.widget, record.widgetLayout, this.parentDashboard)
);
break;
case 'remove':
@ -181,7 +183,8 @@ export class DashboardWidgets implements Iterable<DashboardWidget> {
const prevDashboardWidget = this.dashboardWidgets[index];
if (!isEqual(prevDashboardWidget.widget, record.widget) ||
!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].selected = prevDashboardWidget.selected;
} else {
@ -332,7 +335,7 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget {
customHeaderActions: Array<WidgetHeaderAction>;
widgetActions: Array<WidgetAction>;
widgetContext = new WidgetContext(this.dashboard, this, this.widget);
widgetContext = new WidgetContext(this.dashboard, this, this.widget, this.parentDashboard);
widgetId: string;
@ -374,7 +377,8 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget {
constructor(
private dashboard: IDashboardComponent,
public widget: Widget,
public widgetLayout?: WidgetLayout) {
public widgetLayout?: WidgetLayout,
private parentDashboard?: IDashboardComponent) {
if (!widget.id) {
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 { catchError, map, mergeMap, switchMap } from 'rxjs/operators';
import { FormattedData } from '@home/components/widget/lib/maps/map-models';
import { TbPopoverComponent } from '@shared/components/popover.component';
export interface IWidgetAction {
name: string;
@ -108,10 +109,11 @@ export class WidgetContext {
constructor(public dashboard: IDashboardComponent,
private dashboardWidget: IDashboardWidget,
private widget: Widget) {}
private widget: Widget,
public parentDashboard?: IDashboardComponent) {}
get stateController(): IStateController {
return this.dashboard.stateController;
return this.parentDashboard ? this.parentDashboard.stateController : this.dashboard.stateController;
}
get aliasController(): IAliasController {
@ -255,6 +257,8 @@ export class WidgetContext {
store?: Store<AppState>;
private popoverComponents: TbPopoverComponent[] = [];
rxjs = {
forkJoin,
of,
@ -264,6 +268,28 @@ export class WidgetContext {
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,
verticalPosition: NotificationVerticalPosition = 'bottom',
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()">
{{'js-func.tidy' | translate }}
</button>
<div *ngIf="helpId" [tb-help-popup]="helpId"></div>
<fieldset style="width: initial">
<div matTooltip="{{(fullscreen ? 'fullscreen.exit' : 'fullscreen.expand') | translate}}"
matTooltipPosition="above"

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

@ -41,20 +41,26 @@
}
.tb-js-func-toolbar {
& > * {
&:not(:last-child) {
margin-right: 4px;
}
}
button.mat-button, button.mat-icon-button, button.mat-icon-button.tb-mat-32 {
background: rgba(220, 220, 220, .35);
align-items: center;
vertical-align: middle;
min-width: 32px;
min-height: 15px;
padding: 4px;
margin: 0;
font-size: .8rem;
line-height: 15px;
color: #7b7b7b;
background: rgba(220, 220, 220, .35);
&:not(:last-child) {
margin-right: 4px;
&:not(.tb-help-popup-button) {
color: #7b7b7b;
}
}
.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() helpId: string;
private noValidateValue: boolean;
get noValidate(): boolean {
return this.noValidateValue;

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

@ -18,13 +18,13 @@ import {
ChangeDetectorRef,
Component,
ElementRef,
forwardRef,
forwardRef, Injector,
Input,
OnChanges,
OnDestroy,
OnInit,
OnInit, Renderer2,
SimpleChanges,
ViewChild,
ViewChild, ViewContainerRef,
ViewEncapsulation
} from '@angular/core';
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 { Observable } from 'rxjs/internal/Observable';
import { forkJoin, from } from 'rxjs';
import { MouseEvent } from 'react';
import { TbPopoverService } from '@shared/components/popover.component';
import { HelpMarkdownComponent } from '@shared/components/help-markdown.component';
const tinycolor = tinycolor_;
@ -90,7 +93,8 @@ export class JsonFormComponent implements OnInit, ControlValueAccessor, Validato
onModelChange: this.onModelChange.bind(this),
onColorClick: this.onColorClick.bind(this),
onIconClick: this.onIconClick.bind(this),
onToggleFullscreen: this.onToggleFullscreen.bind(this)
onToggleFullscreen: this.onToggleFullscreen.bind(this),
onHelpClick: this.onHelpClick.bind(this)
};
data: JsonFormComponentData;
@ -114,6 +118,9 @@ export class JsonFormComponent implements OnInit, ControlValueAccessor, Validato
constructor(public elementRef: ElementRef,
private translate: TranslateService,
private dialogs: DialogService,
private popoverService: TbPopoverService,
private renderer: Renderer2,
private viewContainerRef: ViewContainerRef,
protected store: Store<AppState>,
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() {
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 { JsonFormFieldProps, JsonFormFieldState } from '@shared/components/json-form/react/json-form.models';
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 { from } from 'rxjs';
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(() => {
return loadAceDependencies().pipe(
@ -39,6 +43,8 @@ interface ThingsboardAceEditorProps extends JsonFormFieldProps {
interface ThingsboardAceEditorState extends JsonFormFieldState {
isFull: boolean;
helpVisible: boolean;
helpReady: boolean;
focused: boolean;
}
@ -53,11 +59,14 @@ class ThingsboardAceEditor extends React.Component<ThingsboardAceEditorProps, Th
this.onBlur = this.onBlur.bind(this);
this.onFocus = this.onFocus.bind(this);
this.onTidy = this.onTidy.bind(this);
this.onHelp = this.onHelp.bind(this);
this.onLoad = this.onLoad.bind(this);
this.onToggleFull = this.onToggleFull.bind(this);
const value = props.value ? props.value + '' : '';
this.state = {
isFull: false,
helpVisible: false,
helpReady: true,
value,
focused: false
};
@ -84,7 +93,7 @@ class ThingsboardAceEditor extends React.Component<ThingsboardAceEditorProps, Th
onTidy() {
if (!this.props.form.readonly) {
let value = this.state.value;
const value = this.state.value;
this.props.onTidy(value).subscribe(
(processedValue) => {
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) {
this.aceEditor = editor;
}
@ -161,6 +188,15 @@ class ThingsboardAceEditor extends React.Component<ThingsboardAceEditorProps, Th
<label>{this.props.mode}</label>
{ this.props.onTidy ? <Button style={ styles.tidyButtonStyle }
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 }
className='tidy-button' onClick={this.onToggleFull}>
{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 copy = this.copyWithIndex(form, i);
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(
<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() {
const forms = (this.props.form.items as JsonFormData[]).map((form: JsonFormData, index) => {
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 (

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 ThingsboardFieldSet from './json-form-fieldset';
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 * as tinycolor_ from 'tinycolor2';
import { GroupInfo } from '@shared/models/widget.models';
import ThingsboardMarkdown from '@shared/components/json-form/react/json-form-markdown';
import { MouseEvent } from 'react';
const tinycolor = tinycolor_;
@ -77,6 +85,7 @@ class ThingsboardSchemaForm extends React.Component<JsonFormProps, any> {
this.onColorClick = this.onColorClick.bind(this);
this.onIconClick = this.onIconClick.bind(this);
this.onToggleFullscreen = this.onToggleFullscreen.bind(this);
this.onHelpClick = this.onHelpClick.bind(this);
this.hasConditions = false;
}
@ -101,6 +110,10 @@ class ThingsboardSchemaForm extends React.Component<JsonFormProps, any> {
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,
model: any,
@ -108,7 +121,8 @@ class ThingsboardSchemaForm extends React.Component<JsonFormProps, any> {
onChange: onChangeFn,
onColorClick: OnColorClickFn,
onIconClick: OnIconClickFn,
onToggleFullscreen: () => void,
onToggleFullscreen: onToggleFullscreenFn,
onHelpClick: onHelpClickFn,
mapper: {[type: string]: any}): JSX.Element {
const type = form.type;
const Field = this.mapper[type];
@ -127,6 +141,7 @@ class ThingsboardSchemaForm extends React.Component<JsonFormProps, any> {
onColorClick={onColorClick}
onIconClick={onIconClick}
onToggleFullscreen={onToggleFullscreen}
onHelpClick={onHelpClick}
mapper={mapper} builder={this.builder}/>;
}
@ -138,7 +153,7 @@ class ThingsboardSchemaForm extends React.Component<JsonFormProps, any> {
}
const forms = merged.map(function(form, index) {
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));
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 { GroupInfo } from '@shared/models/widget.models';
import { MouseEvent } from 'react';
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,
colorSelectedFn: (color: tinycolor.ColorFormats.RGBA) => void) => void;
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 onHelpClickFn = (event: MouseEvent, helpId: string, helpVisibleFn: (visible: boolean) => void,
helpReadyFn: (ready: boolean) => void) => void;
export interface JsonFormProps {
model?: any;
@ -61,6 +64,7 @@ export interface JsonFormProps {
onColorClick?: OnColorClickFn;
onIconClick?: OnIconClickFn;
onToggleFullscreen?: onToggleFullscreenFn;
onHelpClick?: onHelpClickFn;
mapper?: {[type: string]: any};
}
@ -97,6 +101,7 @@ export interface JsonFormData {
items?: Array<KeyLabelItem> | Array<JsonFormData>;
tabs?: Array<JsonFormData>;
tags?: any;
helpId?: string;
startEmpty?: boolean;
[key: string]: any;
}
@ -108,6 +113,7 @@ export type ComponentBuilderFn = (form: JsonFormData,
onColorClick: OnColorClickFn,
onIconClick: OnIconClickFn,
onToggleFullscreen: onToggleFullscreenFn,
onHelpClick: onHelpClickFn,
mapper: {[type: string]: any}) => JSX.Element;
export interface JsonFormFieldProps {
@ -121,6 +127,7 @@ export interface JsonFormFieldProps {
onIconClick?: OnIconClickFn;
onChangeValidate?: (e: any, forceUpdate?: boolean) => void;
onToggleFullscreen?: onToggleFullscreenFn;
onHelpClick?: onHelpClickFn;
valid?: boolean;
error?: string;
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;
}
}
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 * as moment_ from 'moment';
import { EntityDataPageLink, EntityFilter, KeyFilter } from '@shared/models/query/query.models';
import { PopoverPlacement } from '@shared/components/popover.models';
export enum widgetType {
timeseries = 'timeseries',
@ -456,6 +457,13 @@ export interface WidgetActionDescriptor extends CustomActionDescriptor {
targetDashboardStateId?: string;
openRightLayout?: boolean;
openNewBrowserTab?: boolean;
openInPopover?: boolean;
popoverHideDashboardToolbar?: boolean;
popoverPreferredPlacement?: PopoverPlacement;
popoverHideOnClickOutside?: boolean;
popoverWidth?: string;
popoverHeight?: string;
popoverStyle?: { [klass: string]: any };
openInSeparateDialog?: boolean;
dialogTitle?: string;
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 { CopyButtonComponent } from '@shared/components/button/copy-button.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({
providers: [
@ -164,7 +169,8 @@ import { markedOptionsFactory } from '@shared/components/markdown.factory';
{
provide: MAT_DATE_LOCALE,
useValue: 'en-GB'
}
},
TbPopoverService
],
declarations: [
FooterComponent,
@ -176,7 +182,13 @@ import { markedOptionsFactory } from '@shared/components/markdown.factory';
MatChipDraggableDirective,
TbHotkeysDirective,
TbAnchorComponent,
TbPopoverComponent,
TbStringTemplateOutletDirective,
TbComponentOutletDirective,
TbPopoverDirective,
HelpComponent,
HelpMarkdownComponent,
HelpPopupComponent,
TbCheckboxComponent,
TbSnackBarComponent,
TbErrorComponent,
@ -302,7 +314,7 @@ import { markedOptionsFactory } from '@shared/components/markdown.factory';
sanitize: SecurityContext.NONE,
markedOptions: {
provide: MarkedOptions,
useFactory: markedOptionsFactory
useClass: MarkedOptionsService
}
})
],
@ -316,7 +328,12 @@ import { markedOptionsFactory } from '@shared/components/markdown.factory';
MatChipDraggableDirective,
TbHotkeysDirective,
TbAnchorComponent,
TbStringTemplateOutletDirective,
TbComponentOutletDirective,
TbPopoverDirective,
HelpComponent,
HelpMarkdownComponent,
HelpPopupComponent,
TbCheckboxComponent,
TbErrorComponent,
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"
},
"help": {
"goto-help-page": "Go to help page"
"goto-help-page": "Go to help page",
"show-help": "Show help"
},
"home": {
"home": "Home",
@ -2315,6 +2316,10 @@
"or": "or",
"error": "Login error"
},
"markdown": {
"copy-code": "Click to copy",
"copied": "Copied!"
},
"ota-update": {
"add": "Add package",
"assign-firmware": "Assigned firmware",
@ -2940,12 +2945,33 @@
"set-entity-from-widget": "Set entity from widget",
"target-dashboard": "Target dashboard",
"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-popover": "Open in popover",
"dialog-title": "Dialog title",
"dialog-hide-dashboard-toolbar": "Hide dashboard toolbar in dialog",
"dialog-width": "Dialog width in percents relative to viewport width",
"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.",
"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",
"mobile": {
"action-type": "Mobile action type",

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

@ -45,3 +45,15 @@
background-position: 0 0, 4px 4px;
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;
}
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 {
margin: .8em 0 1.6em;
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 {
/*********************************

Loading…
Cancel
Save