Commit d47371d8fd253a4459aae231b417070f656e961a

Authored by Igor Kulikov
1 parent bd8af111

Implemented Timeseries table widget.

@@ -109,12 +109,12 @@ @@ -109,12 +109,12 @@
109 "sizeX": 8, 109 "sizeX": 8,
110 "sizeY": 6.5, 110 "sizeY": 6.5,
111 "resources": [], 111 "resources": [],
112 - "templateHtml": "<tb-timeseries-table-widget \n table-id=\"tableId\"\n ctx=\"ctx\">\n</tb-timeseries-table-widget>", 112 + "templateHtml": "<tb-timeseries-table-widget \n [ctx]=\"ctx\">\n</tb-timeseries-table-widget>",
113 "templateCss": "", 113 "templateCss": "",
114 - "controllerScript": "self.onInit = function() {\n var scope = self.ctx.$scope;\n var id = self.ctx.$scope.$injector.get('utils').guid();\n scope.tableId = \"table-\"+id;\n scope.ctx = self.ctx;\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.$broadcast('timeseries-table-data-updated', self.ctx.$scope.tableId);\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}",  
115 - "settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"TimeseriesTableSettings\",\n \"properties\": {\n \"showTimestamp\": {\n \"title\": \"Display timestamp column\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"displayPagination\": {\n \"title\": \"Display pagination\",\n \"type\": \"boolean\",\n \"default\": true\n }, \n \"defaultPageSize\": {\n \"title\": \"Default page size\",\n \"type\": \"number\",\n \"default\": 10\n },\n \"hideEmptyLines\": {\n \"title\": \"Hide empty lines\",\n \"type\": \"boolean\",\n \"default\": false\n }\n },\n \"required\": []\n },\n \"form\": [\n \"showTimestamp\",\n \"displayPagination\",\n \"defaultPageSize\",\n \"hideEmptyLines\"\n ]\n}",  
116 - "dataKeySettingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"DataKeySettings\",\n \"properties\": {\n \"useCellStyleFunction\": {\n \"title\": \"Use cell style function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"cellStyleFunction\": {\n \"title\": \"Cell style function: f(value)\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"useCellContentFunction\": {\n \"title\": \"Use cell content function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"cellContentFunction\": {\n \"title\": \"Cell content function: f(value, rowData, filter)\",\n \"type\": \"string\",\n \"default\": \"\"\n }\n },\n \"required\": []\n },\n \"form\": [\n \"useCellStyleFunction\",\n {\n \"key\": \"cellStyleFunction\",\n \"type\": \"javascript\"\n },\n \"useCellContentFunction\",\n {\n \"key\": \"cellContentFunction\",\n \"type\": \"javascript\"\n }\n ]\n}",  
117 - "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature °C\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = (value + 60)/120 * 100;\\n var color = tinycolor.mix('blue', 'red', amount = percent);\\n color.setAlpha(.5);\\n return {\\n paddingLeft: '20px',\\n color: '#ffffff',\\n background: color.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\"},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Humidity, %\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = value;\\n var backgroundColor = tinycolor('blue');\\n backgroundColor.setAlpha(value/100);\\n var color = 'blue';\\n if (value > 50) {\\n color = 'white';\\n }\\n \\n return {\\n paddingLeft: '20px',\\n color: color,\\n background: backgroundColor.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 5) {\\n\\tvalue = 5;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":60000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"showTimestamp\":true,\"displayPagination\":true,\"defaultPageSize\":10},\"title\":\"Timeseries table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":false,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{}}" 114 + "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.timeseriesTableWidget.onDataUpdated();\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}",
  115 + "settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"TimeseriesTableSettings\",\n \"properties\": {\n \"showTimestamp\": {\n \"title\": \"Display timestamp column\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"showMilliseconds\": {\n \"title\": \"Display timestamp milliseconds\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"displayPagination\": {\n \"title\": \"Display pagination\",\n \"type\": \"boolean\",\n \"default\": true\n }, \n \"defaultPageSize\": {\n \"title\": \"Default page size\",\n \"type\": \"number\",\n \"default\": 10\n },\n \"hideEmptyLines\": {\n \"title\": \"Hide empty lines\",\n \"type\": \"boolean\",\n \"default\": false\n }\n },\n \"required\": []\n },\n \"form\": [\n \"showTimestamp\",\n \"showMilliseconds\",\n \"displayPagination\",\n \"defaultPageSize\",\n \"hideEmptyLines\"\n ]\n}",
  116 + "dataKeySettingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"DataKeySettings\",\n \"properties\": {\n \"useCellStyleFunction\": {\n \"title\": \"Use cell style function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"cellStyleFunction\": {\n \"title\": \"Cell style function: f(value)\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"useCellContentFunction\": {\n \"title\": \"Use cell content function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"cellContentFunction\": {\n \"title\": \"Cell content function: f(value, rowData, ctx)\",\n \"type\": \"string\",\n \"default\": \"\"\n }\n },\n \"required\": []\n },\n \"form\": [\n \"useCellStyleFunction\",\n {\n \"key\": \"cellStyleFunction\",\n \"type\": \"javascript\"\n },\n \"useCellContentFunction\",\n {\n \"key\": \"cellContentFunction\",\n \"type\": \"javascript\"\n }\n ]\n}",
  117 + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature °C\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = (value + 60)/120 * 100;\\n var color = tinycolor.mix('blue', 'red', amount = percent);\\n color.setAlpha(.5);\\n return {\\n paddingLeft: '20px',\\n color: '#ffffff',\\n background: color.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\"},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Humidity, %\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = value;\\n var backgroundColor = tinycolor('blue');\\n backgroundColor.setAlpha(value/100);\\n var color = 'blue';\\n if (value > 50) {\\n color = 'white';\\n }\\n \\n return {\\n paddingLeft: '20px',\\n color: color,\\n background: backgroundColor.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 5) {\\n\\tvalue = 5;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":60000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"showTimestamp\":true,\"displayPagination\":true,\"defaultPageSize\":10},\"title\":\"Timeseries table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{},\"showTitleIcon\":false,\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\"}"
118 } 118 }
119 }, 119 },
120 { 120 {
@@ -48,6 +48,16 @@ @@ -48,6 +48,16 @@
48 } 48 }
49 } 49 }
50 50
  51 +tb-widget.tb-widget {
  52 + position: relative;
  53 + height: 100%;
  54 + margin: 0;
  55 + overflow: hidden;
  56 + outline: none;
  57 +
  58 + transition: all .2s ease-in-out;
  59 +}
  60 +
51 div.tb-widget { 61 div.tb-widget {
52 position: relative; 62 position: relative;
53 height: 100%; 63 height: 100%;
@@ -135,7 +135,7 @@ @@ -135,7 +135,7 @@
135 </div> 135 </div>
136 </mat-toolbar> 136 </mat-toolbar>
137 <div fxFlex class="table-container"> 137 <div fxFlex class="table-container">
138 - <mat-table [dataSource]="dataSource" 138 + <mat-table [dataSource]="dataSource" [trackBy]="trackByEntityId"
139 matSort [matSortActive]="pageLink.sortOrder.property" [matSortDirection]="(pageLink.sortOrder.direction + '').toLowerCase()" matSortDisableClear> 139 matSort [matSortActive]="pageLink.sortOrder.property" [matSortDirection]="(pageLink.sortOrder.direction + '').toLowerCase()" matSortDisableClear>
140 <ng-container matColumnDef="select" sticky> 140 <ng-container matColumnDef="select" sticky>
141 <mat-header-cell *matHeaderCellDef> 141 <mat-header-cell *matHeaderCellDef>
@@ -16,13 +16,13 @@ @@ -16,13 +16,13 @@
16 16
17 import { 17 import {
18 AfterViewInit, 18 AfterViewInit,
19 - Component, ComponentFactoryResolver, 19 + ChangeDetectionStrategy,
  20 + Component,
  21 + ComponentFactoryResolver,
20 ElementRef, 22 ElementRef,
21 Input, 23 Input,
22 OnInit, 24 OnInit,
23 - Type,  
24 - ViewChild,  
25 - ChangeDetectionStrategy 25 + ViewChild
26 } from '@angular/core'; 26 } from '@angular/core';
27 import { PageComponent } from '@shared/components/page.component'; 27 import { PageComponent } from '@shared/components/page.component';
28 import { Store } from '@ngrx/store'; 28 import { Store } from '@ngrx/store';
@@ -35,28 +35,24 @@ import { Direction, SortOrder } from '@shared/models/page/sort-order'; @@ -35,28 +35,24 @@ import { Direction, SortOrder } from '@shared/models/page/sort-order';
35 import { forkJoin, fromEvent, merge, Observable } from 'rxjs'; 35 import { forkJoin, fromEvent, merge, Observable } from 'rxjs';
36 import { TranslateService } from '@ngx-translate/core'; 36 import { TranslateService } from '@ngx-translate/core';
37 import { BaseData, HasId } from '@shared/models/base-data'; 37 import { BaseData, HasId } from '@shared/models/base-data';
38 -import { EntityId } from '@shared/models/id/entity-id';  
39 import { ActivatedRoute } from '@angular/router'; 38 import { ActivatedRoute } from '@angular/router';
40 import { 39 import {
41 CellActionDescriptor, 40 CellActionDescriptor,
  41 + EntityActionTableColumn,
  42 + EntityColumn,
42 EntityTableColumn, 43 EntityTableColumn,
43 EntityTableConfig, 44 EntityTableConfig,
44 GroupActionDescriptor, 45 GroupActionDescriptor,
45 - HeaderActionDescriptor,  
46 - EntityColumn, EntityActionTableColumn 46 + HeaderActionDescriptor
47 } from '@home/models/entity/entities-table-config.models'; 47 } from '@home/models/entity/entities-table-config.models';
48 import { EntityTypeTranslation } from '@shared/models/entity-type.models'; 48 import { EntityTypeTranslation } from '@shared/models/entity-type.models';
49 import { DialogService } from '@core/services/dialog.service'; 49 import { DialogService } from '@core/services/dialog.service';
50 import { AddEntityDialogComponent } from './add-entity-dialog.component'; 50 import { AddEntityDialogComponent } from './add-entity-dialog.component';
51 -import {  
52 - AddEntityDialogData,  
53 - EntityAction  
54 -} from '@home/models/entity/entity-component.models';  
55 -import { Timewindow, historyInterval } from '@shared/models/time/time.models';  
56 -import {DomSanitizer, SafeHtml} from '@angular/platform-browser'; 51 +import { AddEntityDialogData, EntityAction } from '@home/models/entity/entity-component.models';
  52 +import { historyInterval, Timewindow } from '@shared/models/time/time.models';
  53 +import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
57 import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; 54 import { TbAnchorComponent } from '@shared/components/tb-anchor.component';
58 -import { instanceOf } from 'prop-types';  
59 -import { isDefined, isDefinedAndNotNull, isUndefined } from '@core/utils'; 55 +import { isDefined, isUndefined } from '@core/utils';
60 56
61 @Component({ 57 @Component({
62 selector: 'tb-entities-table', 58 selector: 'tb-entities-table',
@@ -458,4 +454,8 @@ export class EntitiesTableComponent extends PageComponent implements AfterViewIn @@ -458,4 +454,8 @@ export class EntitiesTableComponent extends PageComponent implements AfterViewIn
458 return column.key; 454 return column.key;
459 } 455 }
460 456
  457 + trackByEntityId(index: number, entity: BaseData<HasId>) {
  458 + return entity.id.id;
  459 + }
  460 +
461 } 461 }
@@ -507,26 +507,22 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit, @@ -507,26 +507,22 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
507 } 507 }
508 508
509 public cellContent(alarm: AlarmInfo, key: EntityColumn): SafeHtml { 509 public cellContent(alarm: AlarmInfo, key: EntityColumn): SafeHtml {
510 - let strContent = '';  
511 if (alarm && key) { 510 if (alarm && key) {
512 const contentInfo = this.contentsInfo[key.def]; 511 const contentInfo = this.contentsInfo[key.def];
513 const value = getAlarmValue(alarm, key); 512 const value = getAlarmValue(alarm, key);
  513 + let content = '';
514 if (contentInfo.useCellContentFunction && contentInfo.cellContentFunction) { 514 if (contentInfo.useCellContentFunction && contentInfo.cellContentFunction) {
515 - if (isDefined(value)) {  
516 - strContent = '' + value;  
517 - }  
518 - var content = strContent;  
519 try { 515 try {
520 content = contentInfo.cellContentFunction(value, alarm, this.ctx); 516 content = contentInfo.cellContentFunction(value, alarm, this.ctx);
521 } catch (e) { 517 } catch (e) {
522 - content = strContent; 518 + content = '' + value;
523 } 519 }
524 } else { 520 } else {
525 content = this.defaultContent(key, value); 521 content = this.defaultContent(key, value);
526 } 522 }
527 - return this.domSanitizer.bypassSecurityTrustHtml(content); 523 + return isDefined(content) ? this.domSanitizer.bypassSecurityTrustHtml(content) : '';
528 } else { 524 } else {
529 - return strContent; 525 + return '';
530 } 526 }
531 } 527 }
532 528
@@ -433,28 +433,24 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni @@ -433,28 +433,24 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni
433 } 433 }
434 434
435 public cellContent(entity: EntityData, key: EntityColumn): SafeHtml { 435 public cellContent(entity: EntityData, key: EntityColumn): SafeHtml {
436 - let strContent = '';  
437 if (entity && key) { 436 if (entity && key) {
438 const contentInfo = this.contentsInfo[key.def]; 437 const contentInfo = this.contentsInfo[key.def];
439 const value = getEntityValue(entity, key); 438 const value = getEntityValue(entity, key);
  439 + let content = '';
440 if (contentInfo.useCellContentFunction && contentInfo.cellContentFunction) { 440 if (contentInfo.useCellContentFunction && contentInfo.cellContentFunction) {
441 - if (isDefined(value)) {  
442 - strContent = '' + value;  
443 - }  
444 - var content = strContent;  
445 try { 441 try {
446 content = contentInfo.cellContentFunction(value, entity, this.ctx); 442 content = contentInfo.cellContentFunction(value, entity, this.ctx);
447 } catch (e) { 443 } catch (e) {
448 - content = strContent; 444 + content = '' + value;
449 } 445 }
450 } else { 446 } else {
451 const decimals = (contentInfo.decimals || contentInfo.decimals === 0) ? contentInfo.decimals : this.ctx.widgetConfig.decimals; 447 const decimals = (contentInfo.decimals || contentInfo.decimals === 0) ? contentInfo.decimals : this.ctx.widgetConfig.decimals;
452 const units = contentInfo.units || this.ctx.widgetConfig.units; 448 const units = contentInfo.units || this.ctx.widgetConfig.units;
453 content = this.ctx.utils.formatValue(value, decimals, units, true); 449 content = this.ctx.utils.formatValue(value, decimals, units, true);
454 } 450 }
455 - return this.domSanitizer.bypassSecurityTrustHtml(content); 451 + return isDefined(content) ? this.domSanitizer.bypassSecurityTrustHtml(content) : '';
456 } else { 452 } else {
457 - return strContent; 453 + return '';
458 } 454 }
459 } 455 }
460 456
@@ -31,7 +31,7 @@ export interface TableWidgetSettings { @@ -31,7 +31,7 @@ export interface TableWidgetSettings {
31 } 31 }
32 32
33 export interface TableWidgetDataKeySettings { 33 export interface TableWidgetDataKeySettings {
34 - columnWidth: string; 34 + columnWidth?: string;
35 useCellStyleFunction: boolean; 35 useCellStyleFunction: boolean;
36 cellStyleFunction: string; 36 cellStyleFunction: string;
37 useCellContentFunction: boolean; 37 useCellContentFunction: boolean;
@@ -168,6 +168,7 @@ export function constructTableCssString(widgetConfig: WidgetConfig): string { @@ -168,6 +168,7 @@ export function constructTableCssString(widgetConfig: WidgetConfig): string {
168 const mdDark = defaultColor.setAlpha(0.87).toRgbString(); 168 const mdDark = defaultColor.setAlpha(0.87).toRgbString();
169 const mdDarkSecondary = defaultColor.setAlpha(0.54).toRgbString(); 169 const mdDarkSecondary = defaultColor.setAlpha(0.54).toRgbString();
170 const mdDarkDisabled = defaultColor.setAlpha(0.26).toRgbString(); 170 const mdDarkDisabled = defaultColor.setAlpha(0.26).toRgbString();
  171 + const mdDarkDisabled2 = defaultColor.setAlpha(0.38).toRgbString();
171 const mdDarkDivider = defaultColor.setAlpha(0.12).toRgbString(); 172 const mdDarkDivider = defaultColor.setAlpha(0.12).toRgbString();
172 173
173 const cssString = 174 const cssString =
@@ -189,6 +190,15 @@ export function constructTableCssString(widgetConfig: WidgetConfig): string { @@ -189,6 +190,15 @@ export function constructTableCssString(widgetConfig: WidgetConfig): string {
189 'mat-toolbar.mat-table-toolbar:not([color="primary"]) button.mat-icon-button mat-icon {\n'+ 190 'mat-toolbar.mat-table-toolbar:not([color="primary"]) button.mat-icon-button mat-icon {\n'+
190 'color: ' + mdDarkSecondary + ';\n'+ 191 'color: ' + mdDarkSecondary + ';\n'+
191 '}\n'+ 192 '}\n'+
  193 + '.mat-tab-label {\n'+
  194 + 'color: ' + mdDark + ';\n'+
  195 + '}\n'+
  196 + '.mat-tab-header-pagination-chevron {\n'+
  197 + 'border-color: ' + mdDark + ';\n'+
  198 + '}\n'+
  199 + '.mat-tab-header-pagination-disabled .mat-tab-header-pagination-chevron {\n'+
  200 + 'border-color: ' + mdDarkDisabled2 + ';\n'+
  201 + '}\n'+
192 '.mat-table .mat-header-row {\n'+ 202 '.mat-table .mat-header-row {\n'+
193 'background-color: ' + origBackgroundColor + ';\n'+ 203 'background-color: ' + origBackgroundColor + ';\n'+
194 '}\n'+ 204 '}\n'+
  1 +<!--
  2 +
  3 + Copyright © 2016-2019 The Thingsboard Authors
  4 +
  5 + Licensed under the Apache License, Version 2.0 (the "License");
  6 + you may not use this file except in compliance with the License.
  7 + You may obtain a copy of the License at
  8 +
  9 + http://www.apache.org/licenses/LICENSE-2.0
  10 +
  11 + Unless required by applicable law or agreed to in writing, software
  12 + distributed under the License is distributed on an "AS IS" BASIS,
  13 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  14 + See the License for the specific language governing permissions and
  15 + limitations under the License.
  16 +
  17 +-->
  18 +<div class="tb-table-widget tb-absolute-fill">
  19 + <div fxFlex fxLayout="column" class="tb-absolute-fill">
  20 + <mat-toolbar class="mat-table-toolbar" [fxShow]="textSearchMode">
  21 + <div class="mat-toolbar-tools">
  22 + <button mat-button mat-icon-button
  23 + matTooltip="{{ 'action.search' | translate }}"
  24 + matTooltipPosition="above">
  25 + <mat-icon>search</mat-icon>
  26 + </button>
  27 + <mat-form-field fxFlex>
  28 + <mat-label>&nbsp;</mat-label>
  29 + <input #searchInput matInput
  30 + [(ngModel)]="textSearch"
  31 + placeholder="{{ 'widget.search-data' | translate }}"/>
  32 + </mat-form-field>
  33 + <button mat-button mat-icon-button (click)="exitFilterMode()"
  34 + matTooltip="{{ 'action.close' | translate }}"
  35 + matTooltipPosition="above">
  36 + <mat-icon>close</mat-icon>
  37 + </button>
  38 + </div>
  39 + </mat-toolbar>
  40 + <mat-tab-group [ngClass]="{'tb-headless': sources.length === 1}" fxFlex
  41 + [(selectedIndex)]="sourceIndex" (selectedIndexChange)="onSourceIndexChanged()">
  42 + <mat-tab *ngFor="let source of sources" label="{{ source.datasource.name }}">
  43 + <div fxFlex class="table-container">
  44 + <mat-table [dataSource]="source.timeseriesDatasource" [trackBy]="trackByRowIndex"
  45 + matSort [matSortActive]="source.pageLink.sortOrder.property" [matSortDirection]="(source.pageLink.sortOrder.direction + '').toLowerCase()" matSortDisableClear>
  46 + <ng-container *ngIf="showTimestamp" [matColumnDef]="'0'">
  47 + <mat-header-cell *matHeaderCellDef mat-sort-header>Timestamp</mat-header-cell>
  48 + <mat-cell *matCellDef="let row;"
  49 + [innerHTML]="cellContent(source, 0, row, row[0])"
  50 + [ngStyle]="cellStyle(source, 0, row[0])">
  51 + </mat-cell>
  52 + </ng-container>
  53 + <ng-container [matColumnDef]="h.index + ''" *ngFor="let h of source.header; trackBy: trackByColumnIndex;">
  54 + <mat-header-cell *matHeaderCellDef mat-sort-header> {{ h.dataKey.label }} </mat-header-cell>
  55 + <mat-cell *matCellDef="let row;"
  56 + [innerHTML]="cellContent(source, h.index, row, row[h.index])"
  57 + [ngStyle]="cellStyle(source, h.index, row[h.index])">
  58 + </mat-cell>
  59 + </ng-container>
  60 + <ng-container matColumnDef="actions" stickyEnd>
  61 + <mat-header-cell *matHeaderCellDef [ngStyle.gt-md]="{ minWidth: (actionCellDescriptors.length * 36) + 'px', maxWidth: (actionCellDescriptors.length * 36) + 'px' }">
  62 + </mat-header-cell>
  63 + <mat-cell *matCellDef="let row" [ngStyle.gt-md]="{ minWidth: (actionCellDescriptors.length * 36) + 'px', maxWidth: (actionCellDescriptors.length * 36) + 'px' }">
  64 + <div fxHide fxShow.gt-md fxFlex fxLayout="row" fxLayoutAlign="end">
  65 + <button mat-button mat-icon-button [disabled]="isLoading$ | async"
  66 + *ngFor="let actionDescriptor of actionCellDescriptors"
  67 + matTooltip="{{ actionDescriptor.displayName }}"
  68 + matTooltipPosition="above"
  69 + (click)="onActionButtonClick($event, row, actionDescriptor)">
  70 + <mat-icon>{{actionDescriptor.icon}}</mat-icon>
  71 + </button>
  72 + </div>
  73 + <div fxHide fxShow.lt-lg>
  74 + <button mat-button mat-icon-button
  75 + (click)="$event.stopPropagation(); ctx.detectChanges();"
  76 + [matMenuTriggerFor]="cellActionsMenu">
  77 + <mat-icon class="material-icons">more_vert</mat-icon>
  78 + </button>
  79 + <mat-menu #cellActionsMenu="matMenu" xPosition="before">
  80 + <button mat-menu-item *ngFor="let actionDescriptor of actionCellDescriptors"
  81 + [disabled]="isLoading$ | async"
  82 + (click)="onActionButtonClick($event, row, actionDescriptor)">
  83 + <mat-icon>{{actionDescriptor.icon}}</mat-icon>
  84 + <span>{{ actionDescriptor.displayName }}</span>
  85 + </button>
  86 + </mat-menu>
  87 + </div>
  88 + </mat-cell>
  89 + </ng-container>
  90 + <mat-header-row *matHeaderRowDef="source.displayedColumns; sticky: true"></mat-header-row>
  91 + <mat-row *matRowDef="let row; columns: source.displayedColumns;"
  92 + (click)="onRowClick($event, row)"></mat-row>
  93 + </mat-table>
  94 + <span [fxShow]="source.timeseriesDatasource.isEmpty() | async"
  95 + fxLayoutAlign="center center"
  96 + class="no-data-found" translate>widget.no-data-found</span>
  97 + </div>
  98 + <mat-divider *ngIf="displayPagination"></mat-divider>
  99 + <mat-paginator *ngIf="displayPagination"
  100 + [length]="source.timeseriesDatasource.total() | async"
  101 + [pageIndex]="source.pageLink.page"
  102 + [pageSize]="source.pageLink.pageSize"
  103 + [pageSizeOptions]="pageSizeOptions"></mat-paginator>
  104 + </mat-tab>
  105 + </mat-tab-group>
  106 + </div>
  107 +</div>
  1 +/**
  2 + * Copyright © 2016-2019 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +:host {
  17 + width: 100%;
  18 + height: 100%;
  19 + .tb-table-widget {
  20 + mat-footer-row, mat-row {
  21 + min-height: 38px;
  22 + }
  23 + mat-header-row {
  24 + min-height: 40px;
  25 + }
  26 + mat-toolbar {
  27 + z-index: 10;
  28 + }
  29 + span.no-data-found {
  30 + height: calc(100% - 44px);
  31 + }
  32 + }
  33 +}
  34 +
  35 +:host ::ng-deep {
  36 + .tb-table-widget {
  37 + .mat-tab-group {
  38 + height: 100%;
  39 + }
  40 + .mat-tab-body-wrapper {
  41 + height: 100%;
  42 + }
  43 + .mat-tab-body-content {
  44 + display: flex;
  45 + flex-direction: column;
  46 + }
  47 + }
  48 +}
  1 +///
  2 +/// Copyright © 2016-2019 The Thingsboard Authors
  3 +///
  4 +/// Licensed under the Apache License, Version 2.0 (the "License");
  5 +/// you may not use this file except in compliance with the License.
  6 +/// You may obtain a copy of the License at
  7 +///
  8 +/// http://www.apache.org/licenses/LICENSE-2.0
  9 +///
  10 +/// Unless required by applicable law or agreed to in writing, software
  11 +/// distributed under the License is distributed on an "AS IS" BASIS,
  12 +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 +/// See the License for the specific language governing permissions and
  14 +/// limitations under the License.
  15 +///
  16 +
  17 +import {
  18 + AfterViewInit,
  19 + Component,
  20 + ElementRef,
  21 + Input,
  22 + NgZone,
  23 + OnInit,
  24 + QueryList,
  25 + ViewChild,
  26 + ViewChildren,
  27 + ViewContainerRef
  28 +} from '@angular/core';
  29 +import { PageComponent } from '@shared/components/page.component';
  30 +import { Store } from '@ngrx/store';
  31 +import { AppState } from '@core/core.state';
  32 +import { WidgetAction, WidgetContext } from '@home/models/widget-component.models';
  33 +import {
  34 + DataKey,
  35 + Datasource,
  36 + DatasourceData,
  37 + DatasourceType,
  38 + WidgetActionDescriptor,
  39 + WidgetConfig
  40 +} from '@shared/models/widget.models';
  41 +import { UtilsService } from '@core/services/utils.service';
  42 +import { TranslateService } from '@ngx-translate/core';
  43 +import { isDefined, isNumber } from '@core/utils';
  44 +import cssjs from '@core/css/css';
  45 +import { PageLink } from '@shared/models/page/page-link';
  46 +import { Direction, SortOrder, sortOrderFromString } from '@shared/models/page/sort-order';
  47 +import { DataSource } from '@angular/cdk/typings/collections';
  48 +import { CollectionViewer } from '@angular/cdk/collections';
  49 +import { BehaviorSubject, fromEvent, merge, Observable, of } from 'rxjs';
  50 +import { emptyPageData, PageData } from '@shared/models/page/page-data';
  51 +import { catchError, debounceTime, distinctUntilChanged, map, tap } from 'rxjs/operators';
  52 +import { MatPaginator } from '@angular/material/paginator';
  53 +import { MatSort } from '@angular/material/sort';
  54 +import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
  55 +import {
  56 + CellContentInfo,
  57 + CellStyleInfo,
  58 + constructTableCssString,
  59 + getCellContentInfo,
  60 + getCellStyleInfo,
  61 + TableWidgetDataKeySettings
  62 +} from '@home/components/widget/lib/table-widget.models';
  63 +import { Overlay } from '@angular/cdk/overlay';
  64 +import { SubscriptionEntityInfo } from '@core/api/widget-api.models';
  65 +import { DatePipe } from '@angular/common';
  66 +
  67 +interface TimeseriesTableWidgetSettings {
  68 + showTimestamp: boolean;
  69 + showMilliseconds: boolean;
  70 + displayPagination: boolean;
  71 + defaultPageSize: number;
  72 + hideEmptyLines: boolean;
  73 +}
  74 +
  75 +interface TimeseriesTableDataKeySettings extends TableWidgetDataKeySettings {
  76 +}
  77 +
  78 +interface TimeseriesRow {
  79 + [col: number]: any;
  80 + formattedTs: string;
  81 +}
  82 +
  83 +interface TimeseriesHeader {
  84 + index: number;
  85 + dataKey: DataKey;
  86 +}
  87 +
  88 +interface TimeseriesTableSource {
  89 + keyStartIndex: number;
  90 + keyEndIndex: number;
  91 + datasource: Datasource;
  92 + rawData: Array<DatasourceData>;
  93 + data: TimeseriesRow[];
  94 + pageLink: PageLink;
  95 + displayedColumns: string[];
  96 + timeseriesDatasource: TimeseriesDatasource;
  97 + header: TimeseriesHeader[],
  98 + stylesInfo: CellStyleInfo[],
  99 + contentsInfo: CellContentInfo[],
  100 + rowDataTemplate: {[key: string]: any}
  101 +}
  102 +
  103 +@Component({
  104 + selector: 'tb-timeseries-table-widget',
  105 + templateUrl: './timeseries-table-widget.component.html',
  106 + styleUrls: ['./timeseries-table-widget.component.scss', './table-widget.scss']
  107 +})
  108 +export class TimeseriesTableWidgetComponent extends PageComponent implements OnInit, AfterViewInit {
  109 +
  110 + @Input()
  111 + ctx: WidgetContext;
  112 +
  113 + @ViewChild('searchInput', {static: false}) searchInputField: ElementRef;
  114 + @ViewChildren(MatPaginator) paginators: QueryList<MatPaginator>;
  115 + @ViewChildren(MatSort) sorts: QueryList<MatSort>;
  116 +
  117 + public displayPagination = true;
  118 + public pageSizeOptions;
  119 + public textSearchMode = false;
  120 + public textSearch: string = null;
  121 + public actionCellDescriptors: WidgetActionDescriptor[];
  122 + public sources: TimeseriesTableSource[];
  123 + public sourceIndex: number;
  124 +
  125 + private settings: TimeseriesTableWidgetSettings;
  126 + private widgetConfig: WidgetConfig;
  127 + private data: Array<DatasourceData>;
  128 + private datasources: Array<Datasource>;
  129 +
  130 + private defaultPageSize = 10;
  131 + private defaultSortOrder = '-0';
  132 + private hideEmptyLines = false;
  133 + private showTimestamp = true;
  134 + private dateFormatFilter: string;
  135 +
  136 + private searchAction: WidgetAction = {
  137 + name: 'action.search',
  138 + show: true,
  139 + icon: 'search',
  140 + onAction: () => {
  141 + this.enterFilterMode();
  142 + }
  143 + };
  144 +
  145 + constructor(protected store: Store<AppState>,
  146 + private elementRef: ElementRef,
  147 + private ngZone: NgZone,
  148 + private overlay: Overlay,
  149 + private viewContainerRef: ViewContainerRef,
  150 + private utils: UtilsService,
  151 + private translate: TranslateService,
  152 + private domSanitizer: DomSanitizer,
  153 + private datePipe: DatePipe) {
  154 + super(store);
  155 + }
  156 +
  157 + ngOnInit(): void {
  158 + this.ctx.$scope.timeseriesTableWidget = this;
  159 + this.settings = this.ctx.settings;
  160 + this.widgetConfig = this.ctx.widgetConfig;
  161 + this.data = this.ctx.data;
  162 + this.datasources = this.ctx.datasources;
  163 + this.initialize();
  164 + this.ctx.updateWidgetParams();
  165 + }
  166 +
  167 + ngAfterViewInit(): void {
  168 + fromEvent(this.searchInputField.nativeElement, 'keyup')
  169 + .pipe(
  170 + debounceTime(150),
  171 + distinctUntilChanged(),
  172 + tap(() => {
  173 + if (this.displayPagination) {
  174 + this.paginators.forEach((paginator) => {
  175 + paginator.pageIndex = 0;
  176 + });
  177 + }
  178 + this.sources.forEach((source) => {
  179 + source.pageLink.textSearch = this.textSearch;
  180 + });
  181 + this.updateAllData();
  182 + })
  183 + )
  184 + .subscribe();
  185 +
  186 + if (this.displayPagination) {
  187 + this.sorts.forEach((sort, index) => {
  188 + sort.sortChange.subscribe(() => this.paginators.toArray()[index].pageIndex = 0);
  189 + });
  190 + }
  191 + this.sorts.forEach((sort, index) => {
  192 + const paginator = this.displayPagination ? this.paginators.toArray()[index] : null;
  193 + sort.sortChange.subscribe(() => this.paginators.toArray()[index].pageIndex = 0);
  194 + (this.displayPagination ? merge(sort.sortChange, paginator.page) : sort.sortChange)
  195 + .pipe(
  196 + tap(() => this.updateData(sort, paginator, index))
  197 + )
  198 + .subscribe();
  199 + });
  200 + this.updateAllData();
  201 + }
  202 +
  203 + public onDataUpdated() {
  204 + this.ngZone.run(() => {
  205 + this.sources.forEach((source) => {
  206 + source.timeseriesDatasource.dataUpdated(this.data);
  207 + });
  208 + this.ctx.detectChanges();
  209 + });
  210 + }
  211 +
  212 + private initialize() {
  213 + this.ctx.widgetActions = [this.searchAction ];
  214 +
  215 + this.actionCellDescriptors = this.ctx.actionsApi.getActionDescriptors('actionCellButton');
  216 +
  217 + this.displayPagination = isDefined(this.settings.displayPagination) ? this.settings.displayPagination : true;
  218 + this.hideEmptyLines = isDefined(this.settings.hideEmptyLines) ? this.settings.hideEmptyLines : false;
  219 + this.showTimestamp = this.settings.showTimestamp !== false;
  220 + this.dateFormatFilter = (this.settings.showMilliseconds !== true) ? 'yyyy-MM-dd HH:mm:ss' : 'yyyy-MM-dd HH:mm:ss.sss';
  221 +
  222 + const pageSize = this.settings.defaultPageSize;
  223 + if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) {
  224 + this.defaultPageSize = pageSize;
  225 + }
  226 + this.pageSizeOptions = [this.defaultPageSize, this.defaultPageSize*2, this.defaultPageSize*3];
  227 +
  228 + let cssString = constructTableCssString(this.widgetConfig);
  229 +
  230 + const origBackgroundColor = this.widgetConfig.backgroundColor || 'rgb(255, 255, 255)';
  231 + cssString += '.tb-table-widget mat-toolbar.mat-table-toolbar:not([color=primary]) {\n'+
  232 + 'background-color: ' + origBackgroundColor + ' !important;\n'+
  233 + '}\n';
  234 +
  235 + const cssParser = new cssjs();
  236 + cssParser.testMode = false;
  237 + const namespace = 'ts-table-' + this.utils.hashCode(cssString);
  238 + cssParser.cssPreviewNamespace = namespace;
  239 + cssParser.createStyleElement(namespace, cssString);
  240 + $(this.elementRef.nativeElement).addClass(namespace);
  241 + this.updateDatasources();
  242 + }
  243 +
  244 + private updateDatasources() {
  245 + this.sources = [];
  246 + this.sourceIndex = 0;
  247 + let keyOffset = 0;
  248 + const pageSize = this.displayPagination ? this.defaultPageSize : Number.POSITIVE_INFINITY;
  249 + if (this.datasources) {
  250 + for (const datasource of this.datasources) {
  251 + const sortOrder: SortOrder = sortOrderFromString(this.defaultSortOrder);
  252 + const source = {} as TimeseriesTableSource;
  253 + source.keyStartIndex = keyOffset;
  254 + keyOffset += datasource.dataKeys.length;
  255 + source.keyEndIndex = keyOffset;
  256 + source.datasource = datasource;
  257 + source.data = [];
  258 + source.rawData = [];
  259 + source.displayedColumns = [];
  260 + source.pageLink = new PageLink(pageSize, 0, null, sortOrder);
  261 + source.header = [];
  262 + source.stylesInfo = [];
  263 + source.contentsInfo = [];
  264 + source.rowDataTemplate = {};
  265 + source.rowDataTemplate['Timestamp'] = null;
  266 + if (this.showTimestamp) {
  267 + source.displayedColumns.push('0');
  268 + }
  269 + for (let a = 0; a < datasource.dataKeys.length; a++ ) {
  270 + const dataKey = datasource.dataKeys[a];
  271 + const keySettings: TimeseriesTableDataKeySettings = dataKey.settings;
  272 + const index = a + 1;
  273 + source.header.push({
  274 + index,
  275 + dataKey
  276 + });
  277 + source.displayedColumns.push(index + '');
  278 + source.rowDataTemplate[dataKey.label] = null;
  279 + source.stylesInfo.push(getCellStyleInfo(keySettings));
  280 + const cellContentInfo = getCellContentInfo(keySettings, 'value, rowData, ctx');
  281 + cellContentInfo.units = dataKey.units;
  282 + cellContentInfo.decimals = dataKey.decimals;
  283 + source.contentsInfo.push(cellContentInfo);
  284 + }
  285 + source.displayedColumns.push('actions');
  286 + const tsDatasource = new TimeseriesDatasource(source, this.hideEmptyLines, this.dateFormatFilter, this.datePipe);
  287 + tsDatasource.dataUpdated(this.data);
  288 + this.sources.push(source);
  289 + }
  290 + }
  291 + this.updateActiveEntityInfo();
  292 + }
  293 +
  294 + private updateActiveEntityInfo() {
  295 + const source = this.sources[this.sourceIndex];
  296 + let activeEntityInfo: SubscriptionEntityInfo = null;
  297 + if (source) {
  298 + const datasource = source.datasource;
  299 + if (datasource.type === DatasourceType.entity &&
  300 + datasource.entityType && datasource.entityId) {
  301 + activeEntityInfo = {
  302 + entityId: {
  303 + entityType: datasource.entityType,
  304 + id: datasource.entityId
  305 + },
  306 + entityName: datasource.entityName
  307 + };
  308 + }
  309 + }
  310 + this.ctx.activeEntityInfo = activeEntityInfo;
  311 + }
  312 +
  313 + onSourceIndexChanged() {
  314 + this.updateActiveEntityInfo();
  315 + }
  316 +
  317 + private enterFilterMode() {
  318 + this.textSearchMode = true;
  319 + this.textSearch = '';
  320 + this.sources.forEach((source) => {
  321 + source.pageLink.textSearch = this.textSearch;
  322 + });
  323 + this.ctx.hideTitlePanel = true;
  324 + this.ctx.detectChanges(true);
  325 + setTimeout(() => {
  326 + this.searchInputField.nativeElement.focus();
  327 + this.searchInputField.nativeElement.setSelectionRange(0, 0);
  328 + }, 10);
  329 + }
  330 +
  331 + exitFilterMode() {
  332 + this.textSearchMode = false;
  333 + this.textSearch = null;
  334 + this.sources.forEach((source, index) => {
  335 + source.pageLink.textSearch = this.textSearch;
  336 + const sort = this.sorts.toArray()[index];
  337 + let paginator = null;
  338 + if (this.displayPagination) {
  339 + paginator = this.paginators.toArray()[index];
  340 + paginator.pageIndex = 0;
  341 + }
  342 + this.updateData(sort, paginator, index);
  343 + });
  344 + this.ctx.hideTitlePanel = false;
  345 + this.ctx.detectChanges(true);
  346 + }
  347 +
  348 + private updateAllData() {
  349 + this.sources.forEach((source, index) => {
  350 + const sort = this.sorts.toArray()[index];
  351 + const paginator = this.displayPagination ? this.paginators.toArray()[index] : null;
  352 + this.updateData(sort, paginator, index);
  353 + });
  354 + }
  355 +
  356 + private updateData(sort: MatSort, paginator: MatPaginator, index: number) {
  357 + const source = this.sources[index];
  358 + if (this.displayPagination) {
  359 + source.pageLink.page = paginator.pageIndex;
  360 + source.pageLink.pageSize = paginator.pageSize;
  361 + } else {
  362 + source.pageLink.page = 0;
  363 + }
  364 + source.pageLink.sortOrder.property = sort.active;
  365 + source.pageLink.sortOrder.direction = Direction[sort.direction.toUpperCase()];
  366 + source.timeseriesDatasource.loadRows();
  367 + this.ctx.detectChanges();
  368 + }
  369 +
  370 + public trackByColumnIndex(index, header: TimeseriesHeader) {
  371 + return header.index;
  372 + }
  373 +
  374 + public trackByRowIndex(index: number, row: TimeseriesRow) {
  375 + return index;
  376 + }
  377 +
  378 + public cellStyle(source: TimeseriesTableSource, index: number, value: any): any {
  379 + let style: any = {};
  380 + if (index > 0) {
  381 + const styleInfo = source.stylesInfo[index-1];
  382 + if (styleInfo.useCellStyleFunction && styleInfo.cellStyleFunction) {
  383 + try {
  384 + style = styleInfo.cellStyleFunction(value);
  385 + } catch (e) {
  386 + style = {};
  387 + }
  388 + }
  389 + }
  390 + return style;
  391 + }
  392 +
  393 + public cellContent(source: TimeseriesTableSource, index: number, row: TimeseriesRow, value: any): SafeHtml {
  394 + if (index === 0) {
  395 + return row.formattedTs;
  396 + } else {
  397 + let content = '';
  398 + const contentInfo = source.contentsInfo[index-1];
  399 + if (contentInfo.useCellContentFunction && contentInfo.cellContentFunction) {
  400 + try {
  401 + const rowData = source.rowDataTemplate;
  402 + rowData['Timestamp'] = row[0];
  403 + for (let h=0; h < source.header.length; h++) {
  404 + const headerInfo = source.header[h];
  405 + rowData[headerInfo.dataKey.name] = row[headerInfo.index];
  406 + }
  407 + content = contentInfo.cellContentFunction(value, rowData, this.ctx);
  408 + } catch (e) {
  409 + content = '' + value;
  410 + }
  411 + } else {
  412 + const decimals = (contentInfo.decimals || contentInfo.decimals === 0) ? contentInfo.decimals : this.ctx.widgetConfig.decimals;
  413 + const units = contentInfo.units || this.ctx.widgetConfig.units;
  414 + content = this.ctx.utils.formatValue(value, decimals, units, true);
  415 + }
  416 + return isDefined(content) ? this.domSanitizer.bypassSecurityTrustHtml(content) : '';
  417 + }
  418 + }
  419 +
  420 + public onRowClick($event: Event, row: TimeseriesRow) {
  421 + const descriptors = this.ctx.actionsApi.getActionDescriptors('rowClick');
  422 + if (descriptors.length) {
  423 + if ($event) {
  424 + $event.stopPropagation();
  425 + }
  426 + let entityId;
  427 + let entityName;
  428 + if (this.ctx.activeEntityInfo) {
  429 + entityId = this.ctx.activeEntityInfo.entityId;
  430 + entityName = this.ctx.activeEntityInfo.entityName;
  431 + }
  432 + this.ctx.actionsApi.handleWidgetAction($event, descriptors[0], entityId, entityName, row);
  433 + }
  434 + }
  435 +
  436 + public onActionButtonClick($event: Event, row: TimeseriesRow, actionDescriptor: WidgetActionDescriptor) {
  437 + if ($event) {
  438 + $event.stopPropagation();
  439 + }
  440 + let entityId;
  441 + let entityName;
  442 + if (this.ctx.activeEntityInfo) {
  443 + entityId = this.ctx.activeEntityInfo.entityId;
  444 + entityName = this.ctx.activeEntityInfo.entityName;
  445 + }
  446 + this.ctx.actionsApi.handleWidgetAction($event, actionDescriptor, entityId, entityName, row);
  447 + }
  448 +}
  449 +
  450 +class TimeseriesDatasource implements DataSource<TimeseriesRow> {
  451 +
  452 + private rowsSubject = new BehaviorSubject<TimeseriesRow[]>([]);
  453 + private pageDataSubject = new BehaviorSubject<PageData<TimeseriesRow>>(emptyPageData<TimeseriesRow>());
  454 +
  455 + private allRowsSubject = new BehaviorSubject<TimeseriesRow[]>([]);
  456 + private allRows$: Observable<Array<TimeseriesRow>> = this.allRowsSubject.asObservable();
  457 +
  458 + constructor(
  459 + private source: TimeseriesTableSource,
  460 + private hideEmptyLines: boolean,
  461 + private dateFormatFilter: string,
  462 + private datePipe: DatePipe
  463 + ) {
  464 + this.source.timeseriesDatasource = this;
  465 + }
  466 +
  467 + connect(collectionViewer: CollectionViewer): Observable<TimeseriesRow[] | ReadonlyArray<TimeseriesRow>> {
  468 + return this.rowsSubject.asObservable();
  469 + }
  470 +
  471 + disconnect(collectionViewer: CollectionViewer): void {
  472 + this.rowsSubject.complete();
  473 + this.pageDataSubject.complete();
  474 + }
  475 +
  476 + loadRows() {
  477 + this.fetchRows(this.source.pageLink).pipe(
  478 + catchError(() => of(emptyPageData<TimeseriesRow>())),
  479 + ).subscribe(
  480 + (pageData) => {
  481 + this.rowsSubject.next(pageData.data);
  482 + this.pageDataSubject.next(pageData);
  483 + }
  484 + );
  485 + }
  486 +
  487 + dataUpdated(data: DatasourceData[]) {
  488 + this.source.rawData = data.slice(this.source.keyStartIndex, this.source.keyEndIndex);
  489 + this.updateSourceData();
  490 + }
  491 +
  492 + private updateSourceData() {
  493 + this.source.data = this.convertData(this.source.rawData);
  494 + this.allRowsSubject.next(this.source.data);
  495 + }
  496 +
  497 + private convertData(data: DatasourceData[]): TimeseriesRow[] {
  498 + const rowsMap: {[timestamp: number]: TimeseriesRow} = {};
  499 + for (let d = 0; d < data.length; d++) {
  500 + const columnData = data[d].data;
  501 + for (let i = 0; i < columnData.length; i++) {
  502 + const cellData = columnData[i];
  503 + const timestamp = cellData[0];
  504 + let row = rowsMap[timestamp];
  505 + if (!row) {
  506 + row = {
  507 + formattedTs: this.datePipe.transform(timestamp, this.dateFormatFilter)
  508 + };
  509 + row[0] = timestamp;
  510 + for (let c = 0; c < data.length; c++) {
  511 + row[c+1] = undefined;
  512 + }
  513 + rowsMap[timestamp] = row;
  514 + }
  515 + row[d+1] = cellData[1];
  516 + }
  517 + }
  518 + const rows: TimeseriesRow[] = [];
  519 + for (const t of Object.keys(rowsMap)) {
  520 + if (this.hideEmptyLines) {
  521 + let hideLine = true;
  522 + for (let _c = 0; (_c < data.length) && hideLine; _c++) {
  523 + if (rowsMap[t][_c+1])
  524 + hideLine = false;
  525 + }
  526 + if (!hideLine) {
  527 + rows.push(rowsMap[t]);
  528 + }
  529 + } else {
  530 + rows.push(rowsMap[t]);
  531 + }
  532 + }
  533 + return rows;
  534 + }
  535 +
  536 +
  537 + isEmpty(): Observable<boolean> {
  538 + return this.rowsSubject.pipe(
  539 + map((rows) => !rows.length)
  540 + );
  541 + }
  542 +
  543 + total(): Observable<number> {
  544 + return this.pageDataSubject.pipe(
  545 + map((pageData) => pageData.totalElements)
  546 + );
  547 + }
  548 +
  549 + private fetchRows(pageLink: PageLink): Observable<PageData<TimeseriesRow>> {
  550 + return this.allRows$.pipe(
  551 + map((data) => pageLink.filterData(data))
  552 + );
  553 + }
  554 +}
@@ -22,6 +22,7 @@ import { DisplayColumnsPanelComponent } from '@home/components/widget/lib/displa @@ -22,6 +22,7 @@ import { DisplayColumnsPanelComponent } from '@home/components/widget/lib/displa
22 import { AlarmsTableWidgetComponent } from '@home/components/widget/lib/alarms-table-widget.component'; 22 import { AlarmsTableWidgetComponent } from '@home/components/widget/lib/alarms-table-widget.component';
23 import { AlarmStatusFilterPanelComponent } from '@home/components/widget/lib/alarm-status-filter-panel.component'; 23 import { AlarmStatusFilterPanelComponent } from '@home/components/widget/lib/alarm-status-filter-panel.component';
24 import { SharedHomeComponentsModule } from '@home/components/shared-home-components.module'; 24 import { SharedHomeComponentsModule } from '@home/components/shared-home-components.module';
  25 +import { TimeseriesTableWidgetComponent } from '@home/components/widget/lib/timeseries-table-widget.component';
25 26
26 @NgModule({ 27 @NgModule({
27 entryComponents: [ 28 entryComponents: [
@@ -33,7 +34,8 @@ import { SharedHomeComponentsModule } from '@home/components/shared-home-compone @@ -33,7 +34,8 @@ import { SharedHomeComponentsModule } from '@home/components/shared-home-compone
33 DisplayColumnsPanelComponent, 34 DisplayColumnsPanelComponent,
34 AlarmStatusFilterPanelComponent, 35 AlarmStatusFilterPanelComponent,
35 EntitiesTableWidgetComponent, 36 EntitiesTableWidgetComponent,
36 - AlarmsTableWidgetComponent 37 + AlarmsTableWidgetComponent,
  38 + TimeseriesTableWidgetComponent
37 ], 39 ],
38 imports: [ 40 imports: [
39 CommonModule, 41 CommonModule,
@@ -42,7 +44,8 @@ import { SharedHomeComponentsModule } from '@home/components/shared-home-compone @@ -42,7 +44,8 @@ import { SharedHomeComponentsModule } from '@home/components/shared-home-compone
42 ], 44 ],
43 exports: [ 45 exports: [
44 EntitiesTableWidgetComponent, 46 EntitiesTableWidgetComponent,
45 - AlarmsTableWidgetComponent 47 + AlarmsTableWidgetComponent,
  48 + TimeseriesTableWidgetComponent
46 ] 49 ]
47 }) 50 })
48 export class WidgetComponentsModule { } 51 export class WidgetComponentsModule { }
@@ -857,7 +857,7 @@ mat-label { @@ -857,7 +857,7 @@ mat-label {
857 span.no-data-found { 857 span.no-data-found {
858 position: relative; 858 position: relative;
859 display: flex; 859 display: flex;
860 - height: calc(100% - 57px); 860 + height: calc(100% - 60px);
861 text-transform: uppercase; 861 text-transform: uppercase;
862 } 862 }
863 863