diff --git a/application/src/main/data/json/system/widget_bundles/cards.json b/application/src/main/data/json/system/widget_bundles/cards.json index 0a6b0b0d47..bf40d495df 100644 --- a/application/src/main/data/json/system/widget_bundles/cards.json +++ b/application/src/main/data/json/system/widget_bundles/cards.json @@ -109,12 +109,12 @@ "sizeX": 8, "sizeY": 6.5, "resources": [], - "templateHtml": "\n", + "templateHtml": "\n", "templateCss": "", - "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}", - "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}", - "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}", - "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\":{}}" + "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}", + "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}", + "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}", + "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\"}" } }, { diff --git a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.scss b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.scss index 60c0f7c65e..7012daa1f9 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.scss +++ b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.scss @@ -48,6 +48,16 @@ } } +tb-widget.tb-widget { + position: relative; + height: 100%; + margin: 0; + overflow: hidden; + outline: none; + + transition: all .2s ease-in-out; +} + div.tb-widget { position: relative; height: 100%; diff --git a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.html b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.html index 66b3ed430f..f6d0a108fc 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.html +++ b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.html @@ -135,7 +135,7 @@
- diff --git a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts index c09ce4ac5e..b52044a601 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts @@ -16,13 +16,13 @@ import { AfterViewInit, - Component, ComponentFactoryResolver, + ChangeDetectionStrategy, + Component, + ComponentFactoryResolver, ElementRef, Input, OnInit, - Type, - ViewChild, - ChangeDetectionStrategy + ViewChild } from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; @@ -35,28 +35,24 @@ import { Direction, SortOrder } from '@shared/models/page/sort-order'; import { forkJoin, fromEvent, merge, Observable } from 'rxjs'; import { TranslateService } from '@ngx-translate/core'; import { BaseData, HasId } from '@shared/models/base-data'; -import { EntityId } from '@shared/models/id/entity-id'; import { ActivatedRoute } from '@angular/router'; import { CellActionDescriptor, + EntityActionTableColumn, + EntityColumn, EntityTableColumn, EntityTableConfig, GroupActionDescriptor, - HeaderActionDescriptor, - EntityColumn, EntityActionTableColumn + HeaderActionDescriptor } from '@home/models/entity/entities-table-config.models'; import { EntityTypeTranslation } from '@shared/models/entity-type.models'; import { DialogService } from '@core/services/dialog.service'; import { AddEntityDialogComponent } from './add-entity-dialog.component'; -import { - AddEntityDialogData, - EntityAction -} from '@home/models/entity/entity-component.models'; -import { Timewindow, historyInterval } from '@shared/models/time/time.models'; -import {DomSanitizer, SafeHtml} from '@angular/platform-browser'; +import { AddEntityDialogData, EntityAction } from '@home/models/entity/entity-component.models'; +import { historyInterval, Timewindow } from '@shared/models/time/time.models'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; -import { instanceOf } from 'prop-types'; -import { isDefined, isDefinedAndNotNull, isUndefined } from '@core/utils'; +import { isDefined, isUndefined } from '@core/utils'; @Component({ selector: 'tb-entities-table', @@ -458,4 +454,8 @@ export class EntitiesTableComponent extends PageComponent implements AfterViewIn return column.key; } + trackByEntityId(index: number, entity: BaseData) { + return entity.id.id; + } + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/alarms-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/alarms-table-widget.component.ts index 7a143c80ac..d0d8b41337 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/alarms-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/alarms-table-widget.component.ts @@ -507,26 +507,22 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit, } public cellContent(alarm: AlarmInfo, key: EntityColumn): SafeHtml { - let strContent = ''; if (alarm && key) { const contentInfo = this.contentsInfo[key.def]; const value = getAlarmValue(alarm, key); + let content = ''; if (contentInfo.useCellContentFunction && contentInfo.cellContentFunction) { - if (isDefined(value)) { - strContent = '' + value; - } - var content = strContent; try { content = contentInfo.cellContentFunction(value, alarm, this.ctx); } catch (e) { - content = strContent; + content = '' + value; } } else { content = this.defaultContent(key, value); } - return this.domSanitizer.bypassSecurityTrustHtml(content); + return isDefined(content) ? this.domSanitizer.bypassSecurityTrustHtml(content) : ''; } else { - return strContent; + return ''; } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.ts index 5e427b0e1f..05ebbdccd0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.ts @@ -433,28 +433,24 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni } public cellContent(entity: EntityData, key: EntityColumn): SafeHtml { - let strContent = ''; if (entity && key) { const contentInfo = this.contentsInfo[key.def]; const value = getEntityValue(entity, key); + let content = ''; if (contentInfo.useCellContentFunction && contentInfo.cellContentFunction) { - if (isDefined(value)) { - strContent = '' + value; - } - var content = strContent; try { content = contentInfo.cellContentFunction(value, entity, this.ctx); } catch (e) { - content = strContent; + content = '' + value; } } else { const decimals = (contentInfo.decimals || contentInfo.decimals === 0) ? contentInfo.decimals : this.ctx.widgetConfig.decimals; const units = contentInfo.units || this.ctx.widgetConfig.units; content = this.ctx.utils.formatValue(value, decimals, units, true); } - return this.domSanitizer.bypassSecurityTrustHtml(content); + return isDefined(content) ? this.domSanitizer.bypassSecurityTrustHtml(content) : ''; } else { - return strContent; + return ''; } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts index db864fddff..f40514a572 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts @@ -31,7 +31,7 @@ export interface TableWidgetSettings { } export interface TableWidgetDataKeySettings { - columnWidth: string; + columnWidth?: string; useCellStyleFunction: boolean; cellStyleFunction: string; useCellContentFunction: boolean; @@ -168,6 +168,7 @@ export function constructTableCssString(widgetConfig: WidgetConfig): string { const mdDark = defaultColor.setAlpha(0.87).toRgbString(); const mdDarkSecondary = defaultColor.setAlpha(0.54).toRgbString(); const mdDarkDisabled = defaultColor.setAlpha(0.26).toRgbString(); + const mdDarkDisabled2 = defaultColor.setAlpha(0.38).toRgbString(); const mdDarkDivider = defaultColor.setAlpha(0.12).toRgbString(); const cssString = @@ -189,6 +190,15 @@ export function constructTableCssString(widgetConfig: WidgetConfig): string { 'mat-toolbar.mat-table-toolbar:not([color="primary"]) button.mat-icon-button mat-icon {\n'+ 'color: ' + mdDarkSecondary + ';\n'+ '}\n'+ + '.mat-tab-label {\n'+ + 'color: ' + mdDark + ';\n'+ + '}\n'+ + '.mat-tab-header-pagination-chevron {\n'+ + 'border-color: ' + mdDark + ';\n'+ + '}\n'+ + '.mat-tab-header-pagination-disabled .mat-tab-header-pagination-chevron {\n'+ + 'border-color: ' + mdDarkDisabled2 + ';\n'+ + '}\n'+ '.mat-table .mat-header-row {\n'+ 'background-color: ' + origBackgroundColor + ';\n'+ '}\n'+ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.html new file mode 100644 index 0000000000..c6820dc351 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.html @@ -0,0 +1,107 @@ + +
+
+ +
+ + +   + + + +
+
+ + +
+ + + Timestamp + + + + + {{ h.dataKey.label }} + + + + + + + +
+ +
+
+ + + + +
+
+
+ + +
+ widget.no-data-found +
+ + +
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.scss new file mode 100644 index 0000000000..963ab754d6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.scss @@ -0,0 +1,48 @@ +/** + * Copyright © 2016-2019 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 { + width: 100%; + height: 100%; + .tb-table-widget { + mat-footer-row, mat-row { + min-height: 38px; + } + mat-header-row { + min-height: 40px; + } + mat-toolbar { + z-index: 10; + } + span.no-data-found { + height: calc(100% - 44px); + } + } +} + +:host ::ng-deep { + .tb-table-widget { + .mat-tab-group { + height: 100%; + } + .mat-tab-body-wrapper { + height: 100%; + } + .mat-tab-body-content { + display: flex; + flex-direction: column; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts new file mode 100644 index 0000000000..c11a4c5f28 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts @@ -0,0 +1,554 @@ +/// +/// Copyright © 2016-2019 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, + Component, + ElementRef, + Input, + NgZone, + OnInit, + QueryList, + ViewChild, + ViewChildren, + ViewContainerRef +} from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { WidgetAction, WidgetContext } from '@home/models/widget-component.models'; +import { + DataKey, + Datasource, + DatasourceData, + DatasourceType, + WidgetActionDescriptor, + WidgetConfig +} from '@shared/models/widget.models'; +import { UtilsService } from '@core/services/utils.service'; +import { TranslateService } from '@ngx-translate/core'; +import { isDefined, isNumber } from '@core/utils'; +import cssjs from '@core/css/css'; +import { PageLink } from '@shared/models/page/page-link'; +import { Direction, SortOrder, sortOrderFromString } from '@shared/models/page/sort-order'; +import { DataSource } from '@angular/cdk/typings/collections'; +import { CollectionViewer } from '@angular/cdk/collections'; +import { BehaviorSubject, fromEvent, merge, Observable, of } from 'rxjs'; +import { emptyPageData, PageData } from '@shared/models/page/page-data'; +import { catchError, debounceTime, distinctUntilChanged, map, tap } from 'rxjs/operators'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort } from '@angular/material/sort'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { + CellContentInfo, + CellStyleInfo, + constructTableCssString, + getCellContentInfo, + getCellStyleInfo, + TableWidgetDataKeySettings +} from '@home/components/widget/lib/table-widget.models'; +import { Overlay } from '@angular/cdk/overlay'; +import { SubscriptionEntityInfo } from '@core/api/widget-api.models'; +import { DatePipe } from '@angular/common'; + +interface TimeseriesTableWidgetSettings { + showTimestamp: boolean; + showMilliseconds: boolean; + displayPagination: boolean; + defaultPageSize: number; + hideEmptyLines: boolean; +} + +interface TimeseriesTableDataKeySettings extends TableWidgetDataKeySettings { +} + +interface TimeseriesRow { + [col: number]: any; + formattedTs: string; +} + +interface TimeseriesHeader { + index: number; + dataKey: DataKey; +} + +interface TimeseriesTableSource { + keyStartIndex: number; + keyEndIndex: number; + datasource: Datasource; + rawData: Array; + data: TimeseriesRow[]; + pageLink: PageLink; + displayedColumns: string[]; + timeseriesDatasource: TimeseriesDatasource; + header: TimeseriesHeader[], + stylesInfo: CellStyleInfo[], + contentsInfo: CellContentInfo[], + rowDataTemplate: {[key: string]: any} +} + +@Component({ + selector: 'tb-timeseries-table-widget', + templateUrl: './timeseries-table-widget.component.html', + styleUrls: ['./timeseries-table-widget.component.scss', './table-widget.scss'] +}) +export class TimeseriesTableWidgetComponent extends PageComponent implements OnInit, AfterViewInit { + + @Input() + ctx: WidgetContext; + + @ViewChild('searchInput', {static: false}) searchInputField: ElementRef; + @ViewChildren(MatPaginator) paginators: QueryList; + @ViewChildren(MatSort) sorts: QueryList; + + public displayPagination = true; + public pageSizeOptions; + public textSearchMode = false; + public textSearch: string = null; + public actionCellDescriptors: WidgetActionDescriptor[]; + public sources: TimeseriesTableSource[]; + public sourceIndex: number; + + private settings: TimeseriesTableWidgetSettings; + private widgetConfig: WidgetConfig; + private data: Array; + private datasources: Array; + + private defaultPageSize = 10; + private defaultSortOrder = '-0'; + private hideEmptyLines = false; + private showTimestamp = true; + private dateFormatFilter: string; + + private searchAction: WidgetAction = { + name: 'action.search', + show: true, + icon: 'search', + onAction: () => { + this.enterFilterMode(); + } + }; + + constructor(protected store: Store, + private elementRef: ElementRef, + private ngZone: NgZone, + private overlay: Overlay, + private viewContainerRef: ViewContainerRef, + private utils: UtilsService, + private translate: TranslateService, + private domSanitizer: DomSanitizer, + private datePipe: DatePipe) { + super(store); + } + + ngOnInit(): void { + this.ctx.$scope.timeseriesTableWidget = this; + this.settings = this.ctx.settings; + this.widgetConfig = this.ctx.widgetConfig; + this.data = this.ctx.data; + this.datasources = this.ctx.datasources; + this.initialize(); + this.ctx.updateWidgetParams(); + } + + ngAfterViewInit(): void { + fromEvent(this.searchInputField.nativeElement, 'keyup') + .pipe( + debounceTime(150), + distinctUntilChanged(), + tap(() => { + if (this.displayPagination) { + this.paginators.forEach((paginator) => { + paginator.pageIndex = 0; + }); + } + this.sources.forEach((source) => { + source.pageLink.textSearch = this.textSearch; + }); + this.updateAllData(); + }) + ) + .subscribe(); + + if (this.displayPagination) { + this.sorts.forEach((sort, index) => { + sort.sortChange.subscribe(() => this.paginators.toArray()[index].pageIndex = 0); + }); + } + this.sorts.forEach((sort, index) => { + const paginator = this.displayPagination ? this.paginators.toArray()[index] : null; + sort.sortChange.subscribe(() => this.paginators.toArray()[index].pageIndex = 0); + (this.displayPagination ? merge(sort.sortChange, paginator.page) : sort.sortChange) + .pipe( + tap(() => this.updateData(sort, paginator, index)) + ) + .subscribe(); + }); + this.updateAllData(); + } + + public onDataUpdated() { + this.ngZone.run(() => { + this.sources.forEach((source) => { + source.timeseriesDatasource.dataUpdated(this.data); + }); + this.ctx.detectChanges(); + }); + } + + private initialize() { + this.ctx.widgetActions = [this.searchAction ]; + + this.actionCellDescriptors = this.ctx.actionsApi.getActionDescriptors('actionCellButton'); + + this.displayPagination = isDefined(this.settings.displayPagination) ? this.settings.displayPagination : true; + this.hideEmptyLines = isDefined(this.settings.hideEmptyLines) ? this.settings.hideEmptyLines : false; + this.showTimestamp = this.settings.showTimestamp !== false; + this.dateFormatFilter = (this.settings.showMilliseconds !== true) ? 'yyyy-MM-dd HH:mm:ss' : 'yyyy-MM-dd HH:mm:ss.sss'; + + const pageSize = this.settings.defaultPageSize; + if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) { + this.defaultPageSize = pageSize; + } + this.pageSizeOptions = [this.defaultPageSize, this.defaultPageSize*2, this.defaultPageSize*3]; + + let cssString = constructTableCssString(this.widgetConfig); + + const origBackgroundColor = this.widgetConfig.backgroundColor || 'rgb(255, 255, 255)'; + cssString += '.tb-table-widget mat-toolbar.mat-table-toolbar:not([color=primary]) {\n'+ + 'background-color: ' + origBackgroundColor + ' !important;\n'+ + '}\n'; + + const cssParser = new cssjs(); + cssParser.testMode = false; + const namespace = 'ts-table-' + this.utils.hashCode(cssString); + cssParser.cssPreviewNamespace = namespace; + cssParser.createStyleElement(namespace, cssString); + $(this.elementRef.nativeElement).addClass(namespace); + this.updateDatasources(); + } + + private updateDatasources() { + this.sources = []; + this.sourceIndex = 0; + let keyOffset = 0; + const pageSize = this.displayPagination ? this.defaultPageSize : Number.POSITIVE_INFINITY; + if (this.datasources) { + for (const datasource of this.datasources) { + const sortOrder: SortOrder = sortOrderFromString(this.defaultSortOrder); + const source = {} as TimeseriesTableSource; + source.keyStartIndex = keyOffset; + keyOffset += datasource.dataKeys.length; + source.keyEndIndex = keyOffset; + source.datasource = datasource; + source.data = []; + source.rawData = []; + source.displayedColumns = []; + source.pageLink = new PageLink(pageSize, 0, null, sortOrder); + source.header = []; + source.stylesInfo = []; + source.contentsInfo = []; + source.rowDataTemplate = {}; + source.rowDataTemplate['Timestamp'] = null; + if (this.showTimestamp) { + source.displayedColumns.push('0'); + } + for (let a = 0; a < datasource.dataKeys.length; a++ ) { + const dataKey = datasource.dataKeys[a]; + const keySettings: TimeseriesTableDataKeySettings = dataKey.settings; + const index = a + 1; + source.header.push({ + index, + dataKey + }); + source.displayedColumns.push(index + ''); + source.rowDataTemplate[dataKey.label] = null; + source.stylesInfo.push(getCellStyleInfo(keySettings)); + const cellContentInfo = getCellContentInfo(keySettings, 'value, rowData, ctx'); + cellContentInfo.units = dataKey.units; + cellContentInfo.decimals = dataKey.decimals; + source.contentsInfo.push(cellContentInfo); + } + source.displayedColumns.push('actions'); + const tsDatasource = new TimeseriesDatasource(source, this.hideEmptyLines, this.dateFormatFilter, this.datePipe); + tsDatasource.dataUpdated(this.data); + this.sources.push(source); + } + } + this.updateActiveEntityInfo(); + } + + private updateActiveEntityInfo() { + const source = this.sources[this.sourceIndex]; + let activeEntityInfo: SubscriptionEntityInfo = null; + if (source) { + const datasource = source.datasource; + if (datasource.type === DatasourceType.entity && + datasource.entityType && datasource.entityId) { + activeEntityInfo = { + entityId: { + entityType: datasource.entityType, + id: datasource.entityId + }, + entityName: datasource.entityName + }; + } + } + this.ctx.activeEntityInfo = activeEntityInfo; + } + + onSourceIndexChanged() { + this.updateActiveEntityInfo(); + } + + private enterFilterMode() { + this.textSearchMode = true; + this.textSearch = ''; + this.sources.forEach((source) => { + source.pageLink.textSearch = this.textSearch; + }); + this.ctx.hideTitlePanel = true; + this.ctx.detectChanges(true); + setTimeout(() => { + this.searchInputField.nativeElement.focus(); + this.searchInputField.nativeElement.setSelectionRange(0, 0); + }, 10); + } + + exitFilterMode() { + this.textSearchMode = false; + this.textSearch = null; + this.sources.forEach((source, index) => { + source.pageLink.textSearch = this.textSearch; + const sort = this.sorts.toArray()[index]; + let paginator = null; + if (this.displayPagination) { + paginator = this.paginators.toArray()[index]; + paginator.pageIndex = 0; + } + this.updateData(sort, paginator, index); + }); + this.ctx.hideTitlePanel = false; + this.ctx.detectChanges(true); + } + + private updateAllData() { + this.sources.forEach((source, index) => { + const sort = this.sorts.toArray()[index]; + const paginator = this.displayPagination ? this.paginators.toArray()[index] : null; + this.updateData(sort, paginator, index); + }); + } + + private updateData(sort: MatSort, paginator: MatPaginator, index: number) { + const source = this.sources[index]; + if (this.displayPagination) { + source.pageLink.page = paginator.pageIndex; + source.pageLink.pageSize = paginator.pageSize; + } else { + source.pageLink.page = 0; + } + source.pageLink.sortOrder.property = sort.active; + source.pageLink.sortOrder.direction = Direction[sort.direction.toUpperCase()]; + source.timeseriesDatasource.loadRows(); + this.ctx.detectChanges(); + } + + public trackByColumnIndex(index, header: TimeseriesHeader) { + return header.index; + } + + public trackByRowIndex(index: number, row: TimeseriesRow) { + return index; + } + + public cellStyle(source: TimeseriesTableSource, index: number, value: any): any { + let style: any = {}; + if (index > 0) { + const styleInfo = source.stylesInfo[index-1]; + if (styleInfo.useCellStyleFunction && styleInfo.cellStyleFunction) { + try { + style = styleInfo.cellStyleFunction(value); + } catch (e) { + style = {}; + } + } + } + return style; + } + + public cellContent(source: TimeseriesTableSource, index: number, row: TimeseriesRow, value: any): SafeHtml { + if (index === 0) { + return row.formattedTs; + } else { + let content = ''; + const contentInfo = source.contentsInfo[index-1]; + if (contentInfo.useCellContentFunction && contentInfo.cellContentFunction) { + try { + const rowData = source.rowDataTemplate; + rowData['Timestamp'] = row[0]; + for (let h=0; h < source.header.length; h++) { + const headerInfo = source.header[h]; + rowData[headerInfo.dataKey.name] = row[headerInfo.index]; + } + content = contentInfo.cellContentFunction(value, rowData, this.ctx); + } catch (e) { + content = '' + value; + } + } else { + const decimals = (contentInfo.decimals || contentInfo.decimals === 0) ? contentInfo.decimals : this.ctx.widgetConfig.decimals; + const units = contentInfo.units || this.ctx.widgetConfig.units; + content = this.ctx.utils.formatValue(value, decimals, units, true); + } + return isDefined(content) ? this.domSanitizer.bypassSecurityTrustHtml(content) : ''; + } + } + + public onRowClick($event: Event, row: TimeseriesRow) { + const descriptors = this.ctx.actionsApi.getActionDescriptors('rowClick'); + if (descriptors.length) { + if ($event) { + $event.stopPropagation(); + } + let entityId; + let entityName; + if (this.ctx.activeEntityInfo) { + entityId = this.ctx.activeEntityInfo.entityId; + entityName = this.ctx.activeEntityInfo.entityName; + } + this.ctx.actionsApi.handleWidgetAction($event, descriptors[0], entityId, entityName, row); + } + } + + public onActionButtonClick($event: Event, row: TimeseriesRow, actionDescriptor: WidgetActionDescriptor) { + if ($event) { + $event.stopPropagation(); + } + let entityId; + let entityName; + if (this.ctx.activeEntityInfo) { + entityId = this.ctx.activeEntityInfo.entityId; + entityName = this.ctx.activeEntityInfo.entityName; + } + this.ctx.actionsApi.handleWidgetAction($event, actionDescriptor, entityId, entityName, row); + } +} + +class TimeseriesDatasource implements DataSource { + + private rowsSubject = new BehaviorSubject([]); + private pageDataSubject = new BehaviorSubject>(emptyPageData()); + + private allRowsSubject = new BehaviorSubject([]); + private allRows$: Observable> = this.allRowsSubject.asObservable(); + + constructor( + private source: TimeseriesTableSource, + private hideEmptyLines: boolean, + private dateFormatFilter: string, + private datePipe: DatePipe + ) { + this.source.timeseriesDatasource = this; + } + + connect(collectionViewer: CollectionViewer): Observable> { + return this.rowsSubject.asObservable(); + } + + disconnect(collectionViewer: CollectionViewer): void { + this.rowsSubject.complete(); + this.pageDataSubject.complete(); + } + + loadRows() { + this.fetchRows(this.source.pageLink).pipe( + catchError(() => of(emptyPageData())), + ).subscribe( + (pageData) => { + this.rowsSubject.next(pageData.data); + this.pageDataSubject.next(pageData); + } + ); + } + + dataUpdated(data: DatasourceData[]) { + this.source.rawData = data.slice(this.source.keyStartIndex, this.source.keyEndIndex); + this.updateSourceData(); + } + + private updateSourceData() { + this.source.data = this.convertData(this.source.rawData); + this.allRowsSubject.next(this.source.data); + } + + private convertData(data: DatasourceData[]): TimeseriesRow[] { + const rowsMap: {[timestamp: number]: TimeseriesRow} = {}; + for (let d = 0; d < data.length; d++) { + const columnData = data[d].data; + for (let i = 0; i < columnData.length; i++) { + const cellData = columnData[i]; + const timestamp = cellData[0]; + let row = rowsMap[timestamp]; + if (!row) { + row = { + formattedTs: this.datePipe.transform(timestamp, this.dateFormatFilter) + }; + row[0] = timestamp; + for (let c = 0; c < data.length; c++) { + row[c+1] = undefined; + } + rowsMap[timestamp] = row; + } + row[d+1] = cellData[1]; + } + } + const rows: TimeseriesRow[] = []; + for (const t of Object.keys(rowsMap)) { + if (this.hideEmptyLines) { + let hideLine = true; + for (let _c = 0; (_c < data.length) && hideLine; _c++) { + if (rowsMap[t][_c+1]) + hideLine = false; + } + if (!hideLine) { + rows.push(rowsMap[t]); + } + } else { + rows.push(rowsMap[t]); + } + } + return rows; + } + + + isEmpty(): Observable { + return this.rowsSubject.pipe( + map((rows) => !rows.length) + ); + } + + total(): Observable { + return this.pageDataSubject.pipe( + map((pageData) => pageData.totalElements) + ); + } + + private fetchRows(pageLink: PageLink): Observable> { + return this.allRows$.pipe( + map((data) => pageLink.filterData(data)) + ); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts index b006b317a6..369d2546b0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts @@ -22,6 +22,7 @@ import { DisplayColumnsPanelComponent } from '@home/components/widget/lib/displa import { AlarmsTableWidgetComponent } from '@home/components/widget/lib/alarms-table-widget.component'; import { AlarmStatusFilterPanelComponent } from '@home/components/widget/lib/alarm-status-filter-panel.component'; import { SharedHomeComponentsModule } from '@home/components/shared-home-components.module'; +import { TimeseriesTableWidgetComponent } from '@home/components/widget/lib/timeseries-table-widget.component'; @NgModule({ entryComponents: [ @@ -33,7 +34,8 @@ import { SharedHomeComponentsModule } from '@home/components/shared-home-compone DisplayColumnsPanelComponent, AlarmStatusFilterPanelComponent, EntitiesTableWidgetComponent, - AlarmsTableWidgetComponent + AlarmsTableWidgetComponent, + TimeseriesTableWidgetComponent ], imports: [ CommonModule, @@ -42,7 +44,8 @@ import { SharedHomeComponentsModule } from '@home/components/shared-home-compone ], exports: [ EntitiesTableWidgetComponent, - AlarmsTableWidgetComponent + AlarmsTableWidgetComponent, + TimeseriesTableWidgetComponent ] }) export class WidgetComponentsModule { } diff --git a/ui-ngx/src/styles.scss b/ui-ngx/src/styles.scss index 0e490633e6..d81548ac10 100644 --- a/ui-ngx/src/styles.scss +++ b/ui-ngx/src/styles.scss @@ -857,7 +857,7 @@ mat-label { span.no-data-found { position: relative; display: flex; - height: calc(100% - 57px); + height: calc(100% - 60px); text-transform: uppercase; }