/// /// Copyright © 2016-2020 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, ViewChild, 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, WidgetActionDescriptor, WidgetConfig } from '@shared/models/widget.models'; import { IWidgetSubscription } from '@core/api/widget-api.models'; import { UtilsService } from '@core/services/utils.service'; import { TranslateService } from '@ngx-translate/core'; import { createLabelFromDatasource, deepClone, hashCode, isDefined, isNumber } from '@core/utils'; import cssjs from '@core/css/css'; import { CollectionViewer, DataSource } from '@angular/cdk/collections'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { BehaviorSubject, fromEvent, merge, Observable } from 'rxjs'; import { emptyPageData, PageData } from '@shared/models/page/page-data'; import { EntityId } from '@shared/models/id/entity-id'; import { entityTypeTranslations } from '@shared/models/entity-type.models'; import { debounceTime, distinctUntilChanged, map, tap } from 'rxjs/operators'; import { MatPaginator } from '@angular/material/paginator'; import { MatSort, SortDirection } from '@angular/material/sort'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { CellContentInfo, CellStyleInfo, constructTableCssString, DisplayColumn, EntityColumn, EntityData, entityDataSortOrderFromString, findColumnByEntityKey, findEntityKeyByColumnDef, fromEntityColumnDef, getCellContentInfo, getCellStyleInfo, getColumnWidth, getEntityValue, TableWidgetDataKeySettings, TableWidgetSettings, widthStyle } from '@home/components/widget/lib/table-widget.models'; import { ConnectedPosition, Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; import { ComponentPortal, PortalInjector } from '@angular/cdk/portal'; import { DISPLAY_COLUMNS_PANEL_DATA, DisplayColumnsPanelComponent, DisplayColumnsPanelData } from '@home/components/widget/lib/display-columns-panel.component'; import { dataKeyToEntityKey, Direction, EntityDataPageLink, entityDataPageLinkSortDirection, EntityKeyType, KeyFilter } from '@shared/models/query/query.models'; import { sortItems } from '@shared/models/page/page-link'; import { entityFields } from '@shared/models/entity.models'; import { DatePipe } from '@angular/common'; interface EntitiesTableWidgetSettings extends TableWidgetSettings { entitiesTitle: string; displayEntityName: boolean; entityNameColumnTitle: string; displayEntityLabel: boolean; entityLabelColumnTitle: string; displayEntityType: boolean; } @Component({ selector: 'tb-entities-table-widget', templateUrl: './entities-table-widget.component.html', styleUrls: ['./entities-table-widget.component.scss', './table-widget.scss'] }) export class EntitiesTableWidgetComponent extends PageComponent implements OnInit, AfterViewInit { @Input() ctx: WidgetContext; @ViewChild('searchInput') searchInputField: ElementRef; @ViewChild(MatPaginator) paginator: MatPaginator; @ViewChild(MatSort) sort: MatSort; public displayPagination = true; public pageSizeOptions; public pageLink: EntityDataPageLink; public sortOrderProperty: string; public textSearchMode = false; public columns: Array = []; public displayedColumns: string[] = []; public actionCellDescriptors: WidgetActionDescriptor[]; public entityDatasource: EntityDatasource; private settings: EntitiesTableWidgetSettings; private widgetConfig: WidgetConfig; private subscription: IWidgetSubscription; private defaultPageSize = 10; private defaultSortOrder = 'entityName'; private contentsInfo: {[key: string]: CellContentInfo} = {}; private stylesInfo: {[key: string]: CellStyleInfo} = {}; private columnWidth: {[key: string]: string} = {}; private searchAction: WidgetAction = { name: 'action.search', show: true, icon: 'search', onAction: () => { this.enterFilterMode(); } }; private columnDisplayAction: WidgetAction = { name: 'entity.columns-to-display', show: true, icon: 'view_column', onAction: ($event) => { this.editColumnsToDisplay($event); } }; constructor(protected store: Store, private elementRef: ElementRef, private ngZone: NgZone, private overlay: Overlay, private viewContainerRef: ViewContainerRef, private utils: UtilsService, private datePipe: DatePipe, private translate: TranslateService, private domSanitizer: DomSanitizer) { super(store); this.pageLink = { page: 0, pageSize: this.defaultPageSize, textSearch: null, dynamic: true }; } ngOnInit(): void { this.ctx.$scope.entitiesTableWidget = this; this.settings = this.ctx.settings; this.widgetConfig = this.ctx.widgetConfig; this.subscription = this.ctx.defaultSubscription; this.initializeConfig(); this.updateDatasources(); this.ctx.updateWidgetParams(); } ngAfterViewInit(): void { fromEvent(this.searchInputField.nativeElement, 'keyup') .pipe( debounceTime(150), distinctUntilChanged(), tap(() => { if (this.displayPagination) { this.paginator.pageIndex = 0; } this.updateData(); }) ) .subscribe(); if (this.displayPagination) { this.sort.sortChange.subscribe(() => this.paginator.pageIndex = 0); } (this.displayPagination ? merge(this.sort.sortChange, this.paginator.page) : this.sort.sortChange) .pipe( tap(() => this.updateData()) ) .subscribe(); this.updateData(); } public onDataUpdated() { this.ngZone.run(() => { this.entityDatasource.dataUpdated(); this.ctx.detectChanges(); }); } public pageLinkSortDirection(): SortDirection { return entityDataPageLinkSortDirection(this.pageLink); } private initializeConfig() { this.ctx.widgetActions = [this.searchAction, this.columnDisplayAction]; this.actionCellDescriptors = this.ctx.actionsApi.getActionDescriptors('actionCellButton'); let entitiesTitle: string; if (this.settings.entitiesTitle && this.settings.entitiesTitle.length) { entitiesTitle = this.utils.customTranslation(this.settings.entitiesTitle, this.settings.entitiesTitle); } else { entitiesTitle = this.translate.instant('entity.entities'); } const datasource = this.subscription.datasources[0]; this.ctx.widgetTitle = createLabelFromDatasource(datasource, entitiesTitle); this.searchAction.show = isDefined(this.settings.enableSearch) ? this.settings.enableSearch : true; this.displayPagination = isDefined(this.settings.displayPagination) ? this.settings.displayPagination : true; this.columnDisplayAction.show = isDefined(this.settings.enableSelectColumnDisplay) ? this.settings.enableSelectColumnDisplay : true; 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]; this.pageLink.pageSize = this.displayPagination ? this.defaultPageSize : Number.POSITIVE_INFINITY; const cssString = constructTableCssString(this.widgetConfig); const cssParser = new cssjs(); cssParser.testMode = false; const namespace = 'entities-table-' + hashCode(cssString); cssParser.cssPreviewNamespace = namespace; cssParser.createStyleElement(namespace, cssString); $(this.elementRef.nativeElement).addClass(namespace); } private updateDatasources() { const displayEntityName = isDefined(this.settings.displayEntityName) ? this.settings.displayEntityName : true; const displayEntityLabel = isDefined(this.settings.displayEntityLabel) ? this.settings.displayEntityLabel : false; let entityNameColumnTitle: string; let entityLabelColumnTitle: string; if (this.settings.entityNameColumnTitle && this.settings.entityNameColumnTitle.length) { entityNameColumnTitle = this.utils.customTranslation(this.settings.entityNameColumnTitle, this.settings.entityNameColumnTitle); } else { entityNameColumnTitle = this.translate.instant('entity.entity-name'); } if (this.settings.entityLabelColumnTitle && this.settings.entityLabelColumnTitle.length) { entityLabelColumnTitle = this.utils.customTranslation(this.settings.entityLabelColumnTitle, this.settings.entityLabelColumnTitle); } else { entityLabelColumnTitle = this.translate.instant('entity.entity-label'); } const displayEntityType = isDefined(this.settings.displayEntityType) ? this.settings.displayEntityType : true; if (displayEntityName) { this.columns.push( { name: 'entityName', label: 'entityName', def: 'entityName', title: entityNameColumnTitle, entityKey: { key: 'name', type: EntityKeyType.ENTITY_FIELD } } as EntityColumn ); this.contentsInfo.entityName = { useCellContentFunction: false }; this.stylesInfo.entityName = { useCellStyleFunction: false }; this.columnWidth.entityName = '0px'; } if (displayEntityLabel) { this.columns.push( { name: 'entityLabel', label: 'entityLabel', def: 'entityLabel', title: entityLabelColumnTitle, entityKey: { key: 'label', type: EntityKeyType.ENTITY_FIELD } } as EntityColumn ); this.contentsInfo.entityLabel = { useCellContentFunction: false }; this.stylesInfo.entityLabel = { useCellStyleFunction: false }; this.columnWidth.entityLabel = '0px'; } if (displayEntityType) { this.columns.push( { name: 'entityType', label: 'entityType', def: 'entityType', title: this.translate.instant('entity.entity-type'), entityKey: { key: 'entityType', type: EntityKeyType.ENTITY_FIELD } } as EntityColumn ); this.contentsInfo.entityType = { useCellContentFunction: false }; this.stylesInfo.entityType = { useCellStyleFunction: false }; this.columnWidth.entityType = '0px'; } const dataKeys: Array = []; const datasource = this.subscription.options.datasources ? this.subscription.options.datasources[0] : null; if (datasource && datasource.dataKeys) { datasource.dataKeys.forEach((entityDataKey) => { const dataKey: EntityColumn = deepClone(entityDataKey) as EntityColumn; dataKey.entityKey = dataKeyToEntityKey(entityDataKey); if (dataKey.type === DataKeyType.function) { dataKey.name = dataKey.label; } dataKeys.push(dataKey); dataKey.title = this.utils.customTranslation(dataKey.label, dataKey.label); dataKey.def = 'def' + this.columns.length; const keySettings: TableWidgetDataKeySettings = dataKey.settings; this.stylesInfo[dataKey.def] = getCellStyleInfo(keySettings); this.contentsInfo[dataKey.def] = getCellContentInfo(keySettings, 'value, entity, ctx'); this.contentsInfo[dataKey.def].units = dataKey.units; this.contentsInfo[dataKey.def].decimals = dataKey.decimals; this.columnWidth[dataKey.def] = getColumnWidth(keySettings); this.columns.push(dataKey); }); this.displayedColumns.push(...this.columns.map(column => column.def)); } if (this.settings.defaultSortOrder && this.settings.defaultSortOrder.length) { this.defaultSortOrder = this.settings.defaultSortOrder; } this.pageLink.sortOrder = entityDataSortOrderFromString(this.defaultSortOrder, this.columns); let sortColumn: EntityColumn; if (this.pageLink.sortOrder) { sortColumn = findColumnByEntityKey(this.pageLink.sortOrder.key, this.columns); } this.sortOrderProperty = sortColumn ? sortColumn.def : null; if (this.actionCellDescriptors.length) { this.displayedColumns.push('actions'); } this.entityDatasource = new EntityDatasource( this.translate, dataKeys, this.subscription); } private editColumnsToDisplay($event: Event) { if ($event) { $event.stopPropagation(); } const target = $event.target || $event.currentTarget; const config = new OverlayConfig(); config.backdropClass = 'cdk-overlay-transparent-backdrop'; config.hasBackdrop = true; const connectedPosition: ConnectedPosition = { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top' }; config.positionStrategy = this.overlay.position().flexibleConnectedTo(target as HTMLElement) .withPositions([connectedPosition]); const overlayRef = this.overlay.create(config); overlayRef.backdropClick().subscribe(() => { overlayRef.dispose(); }); const columns: DisplayColumn[] = this.columns.map(column => { return { title: column.title, def: column.def, display: this.displayedColumns.indexOf(column.def) > -1 }; }); const injectionTokens = new WeakMap([ [DISPLAY_COLUMNS_PANEL_DATA, { columns, columnsUpdated: (newColumns) => { this.displayedColumns = newColumns.filter(column => column.display).map(column => column.def); this.displayedColumns.push('actions'); } } as DisplayColumnsPanelData], [OverlayRef, overlayRef] ]); const injector = new PortalInjector(this.viewContainerRef.injector, injectionTokens); overlayRef.attach(new ComponentPortal(DisplayColumnsPanelComponent, this.viewContainerRef, injector)); this.ctx.detectChanges(); } private enterFilterMode() { this.textSearchMode = true; this.pageLink.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.pageLink.textSearch = null; if (this.displayPagination) { this.paginator.pageIndex = 0; } this.updateData(); this.ctx.hideTitlePanel = false; this.ctx.detectChanges(true); } private updateData() { if (this.displayPagination) { this.pageLink.page = this.paginator.pageIndex; this.pageLink.pageSize = this.paginator.pageSize; } else { this.pageLink.page = 0; } this.pageLink.sortOrder = { key: findEntityKeyByColumnDef(this.sort.active, this.columns), direction: Direction[this.sort.direction.toUpperCase()] }; const sortOrderLabel = fromEntityColumnDef(this.sort.active, this.columns); const keyFilters: KeyFilter[] = null; // TODO: this.entityDatasource.loadEntities(this.pageLink, sortOrderLabel, keyFilters); this.ctx.detectChanges(); } public trackByColumnDef(index, column: EntityColumn) { return column.def; } public headerStyle(key: EntityColumn): any { const columnWidth = this.columnWidth[key.def]; return widthStyle(columnWidth); } public cellStyle(entity: EntityData, key: EntityColumn): any { let style: any = {}; if (entity && key) { const styleInfo = this.stylesInfo[key.def]; const value = getEntityValue(entity, key); if (styleInfo.useCellStyleFunction && styleInfo.cellStyleFunction) { try { style = styleInfo.cellStyleFunction(value); } catch (e) { style = {}; } } else { style = {}; } } if (!style.width) { const columnWidth = this.columnWidth[key.def]; style = {...style, ...widthStyle(columnWidth)}; } return style; } public cellContent(entity: EntityData, key: EntityColumn): SafeHtml { if (entity && key) { const contentInfo = this.contentsInfo[key.def]; const value = getEntityValue(entity, key); let content: string; if (contentInfo.useCellContentFunction && contentInfo.cellContentFunction) { try { content = contentInfo.cellContentFunction(value, entity, this.ctx); } catch (e) { content = '' + value; } } else { content = this.defaultContent(key, contentInfo, value); } return isDefined(content) ? this.domSanitizer.bypassSecurityTrustHtml(content) : ''; } else { return ''; } } private defaultContent(key: EntityColumn, contentInfo: CellContentInfo, value: any): any { if (isDefined(value)) { const entityField = entityFields[key.name]; if (entityField) { if (entityField.time) { return this.datePipe.transform(value, 'yyyy-MM-dd HH:mm:ss'); } } const decimals = (contentInfo.decimals || contentInfo.decimals === 0) ? contentInfo.decimals : this.ctx.widgetConfig.decimals; const units = contentInfo.units || this.ctx.widgetConfig.units; return this.ctx.utils.formatValue(value, decimals, units, true); } else { return ''; } } public onRowClick($event: Event, entity: EntityData, isDouble?: boolean) { if ($event) { $event.stopPropagation(); } this.entityDatasource.toggleCurrentEntity(entity); const actionSourceId = isDouble ? 'rowDoubleClick' : 'rowClick'; const descriptors = this.ctx.actionsApi.getActionDescriptors(actionSourceId); if (descriptors.length) { let entityId; let entityName; let entityLabel; if (entity) { entityId = entity.id; entityName = entity.entityName; entityLabel = entity.entityLabel; } this.ctx.actionsApi.handleWidgetAction($event, descriptors[0], entityId, entityName, null, entityLabel); } } public onActionButtonClick($event: Event, entity: EntityData, actionDescriptor: WidgetActionDescriptor) { if ($event) { $event.stopPropagation(); } let entityId; let entityName; let entityLabel; if (entity) { entityId = entity.id; entityName = entity.entityName; entityLabel = entity.entityLabel; } this.ctx.actionsApi.handleWidgetAction($event, actionDescriptor, entityId, entityName, null, entityLabel); } } class EntityDatasource implements DataSource { private entitiesSubject = new BehaviorSubject([]); private pageDataSubject = new BehaviorSubject>(emptyPageData()); private currentEntity: EntityData = null; public dataLoading = true; private appliedPageLink: EntityDataPageLink; private appliedSortOrderLabel: string; constructor( private translate: TranslateService, private dataKeys: Array, private subscription: IWidgetSubscription ) { } connect(collectionViewer: CollectionViewer): Observable> { return this.entitiesSubject.asObservable(); } disconnect(collectionViewer: CollectionViewer): void { this.entitiesSubject.complete(); this.pageDataSubject.complete(); } loadEntities(pageLink: EntityDataPageLink, sortOrderLabel: string, keyFilters: KeyFilter[]) { this.dataLoading = true; this.clear(); this.appliedPageLink = pageLink; this.appliedSortOrderLabel = sortOrderLabel; this.subscription.subscribeForPaginatedData(0, pageLink, keyFilters); } private clear() { this.entitiesSubject.next([]); this.pageDataSubject.next(emptyPageData()); } dataUpdated() { const datasourcesPageData = this.subscription.datasourcePages[0]; const dataPageData = this.subscription.dataPages[0]; let entities = new Array(); datasourcesPageData.data.forEach((datasource, index) => { entities.push(this.datasourceToEntityData(datasource, dataPageData.data[index])); }); if (this.appliedSortOrderLabel && this.appliedSortOrderLabel.length) { const asc = this.appliedPageLink.sortOrder.direction === Direction.ASC; entities = entities.sort((a, b) => sortItems(a, b, this.appliedSortOrderLabel, asc)); } const entitiesPageData: PageData = { data: entities, totalPages: datasourcesPageData.totalPages, totalElements: datasourcesPageData.totalElements, hasNext: datasourcesPageData.hasNext }; this.entitiesSubject.next(entities); this.pageDataSubject.next(entitiesPageData); this.dataLoading = false; } private datasourceToEntityData(datasource: Datasource, data: DatasourceData[]): EntityData { const entity: EntityData = { id: {} as EntityId, entityName: datasource.entityName, entityLabel: datasource.entityLabel ? datasource.entityLabel : datasource.entityName }; if (datasource.entityId) { entity.id.id = datasource.entityId; } if (datasource.entityType) { entity.id.entityType = datasource.entityType; entity.entityType = this.translate.instant(entityTypeTranslations.get(datasource.entityType).type); } else { entity.entityType = ''; } this.dataKeys.forEach((dataKey, index) => { const keyData = data[index].data; if (keyData && keyData.length && keyData[0].length > 1) { entity[dataKey.label] = keyData[0][1]; } else { entity[dataKey.label] = ''; } }); return entity; } isEmpty(): Observable { return this.entitiesSubject.pipe( map((entities) => !entities.length) ); } total(): Observable { return this.pageDataSubject.pipe( map((pageData) => pageData.totalElements) ); } public toggleCurrentEntity(entity: EntityData): boolean { if (this.currentEntity !== entity) { this.currentEntity = entity; return true; } else { return false; } } public isCurrentEntity(entity: EntityData): boolean { return (this.currentEntity && entity && this.currentEntity.id && entity.id) && (this.currentEntity.id.id === entity.id.id); } }