From 9a9373791dfa3f43177a011b78b1b032e97390e9 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 29 Aug 2019 20:04:59 +0300 Subject: [PATCH] Attributes table --- ui-ngx/src/app/core/http/attribute.service.ts | 74 ++++ .../add-attribute-dialog.component.html | 61 ++++ .../add-attribute-dialog.component.ts | 96 +++++ .../attribute/attribute-table.component.html | 192 ++++++++++ .../attribute/attribute-table.component.scss | 63 ++++ .../attribute/attribute-table.component.ts | 341 ++++++++++++++++++ .../edit-attribute-value-panel.component.html | 42 +++ .../edit-attribute-value-panel.component.scss | 19 + .../edit-attribute-value-panel.component.ts | 88 +++++ .../entity-details-panel.component.scss | 3 + .../entity/entity-tabs.component.ts | 4 + .../home/components/home-components.module.ts | 15 +- .../models/datasource/attribute-datasource.ts | 135 +++++++ .../pages/device/device-tabs.component.html | 15 + .../components/value-input.component.html | 63 ++++ .../components/value-input.component.scss | 28 ++ .../components/value-input.component.ts | 107 ++++++ .../models/telemetry/telemetry.models.ts | 38 ++ ui-ngx/src/app/shared/shared.module.ts | 7 +- .../assets/locale/locale.constant-en_US.json | 7 +- 20 files changed, 1390 insertions(+), 8 deletions(-) create mode 100644 ui-ngx/src/app/core/http/attribute.service.ts create mode 100644 ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.html create mode 100644 ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.html create mode 100644 ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.ts create mode 100644 ui-ngx/src/app/modules/home/models/datasource/attribute-datasource.ts create mode 100644 ui-ngx/src/app/shared/components/value-input.component.html create mode 100644 ui-ngx/src/app/shared/components/value-input.component.scss create mode 100644 ui-ngx/src/app/shared/components/value-input.component.ts diff --git a/ui-ngx/src/app/core/http/attribute.service.ts b/ui-ngx/src/app/core/http/attribute.service.ts new file mode 100644 index 0000000000..c05d10de45 --- /dev/null +++ b/ui-ngx/src/app/core/http/attribute.service.ts @@ -0,0 +1,74 @@ +/// +/// 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 { Injectable } from '@angular/core'; +import { defaultHttpOptions } from './http-utils'; +import { forkJoin, Observable, of } from 'rxjs/index'; +import { HttpClient } from '@angular/common/http'; +import { EntityId } from '@shared/models/id/entity-id'; +import { AttributeData, AttributeScope } from '@shared/models/telemetry/telemetry.models'; + +@Injectable({ + providedIn: 'root' +}) +export class AttributeService { + + constructor( + private http: HttpClient + ) { } + + public getEntityAttributes(entityId: EntityId, attributeScope: AttributeScope, + ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable> { + return this.http.get>(`/api/plugins/telemetry/${entityId.entityType}/${entityId.id}/values/attributes/` + + `${attributeScope}`, + defaultHttpOptions(ignoreLoading, ignoreErrors)); + } + + public deleteEntityAttributes(entityId: EntityId, attributeScope: AttributeScope, attributes: Array, + ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable { + const keys = attributes.map(attribute => attribute.key).join(','); + return this.http.delete(`/api/plugins/telemetry/${entityId.entityType}/${entityId.id}/${attributeScope}` + + `?keys=${keys}`, + defaultHttpOptions(ignoreLoading, ignoreErrors)); + } + + public saveEntityAttributes(entityId: EntityId, attributeScope: AttributeScope, attributes: Array, + ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable { + const attributesData: {[key: string]: any} = {}; + const deleteAttributes: AttributeData[] = []; + attributes.forEach((attribute) => { + if (attribute.value !== null) { + attributesData[attribute.key] = attribute.value; + } else { + deleteAttributes.push(attribute); + } + }); + let deleteEntityAttributesObservable: Observable; + if (deleteAttributes.length) { + deleteEntityAttributesObservable = this.deleteEntityAttributes(entityId, attributeScope, deleteAttributes); + } else { + deleteEntityAttributesObservable = of(null); + } + let saveEntityAttributesObservable: Observable; + if (Object.keys(attributesData).length) { + saveEntityAttributesObservable = this.http.post(`/api/plugins/telemetry/${entityId.entityType}/${entityId.id}/${attributeScope}`, + attributesData, defaultHttpOptions(ignoreLoading, ignoreErrors)); + } else { + saveEntityAttributesObservable = of(null); + } + return forkJoin(saveEntityAttributesObservable, deleteEntityAttributesObservable); + } +} diff --git a/ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html b/ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html new file mode 100644 index 0000000000..5d621986d8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html @@ -0,0 +1,61 @@ + +
+ +

{{ 'attribute.add' | translate }}

+ + +
+ + +
+
+
+ + attribute.key + + + {{ 'attribute.key-required' | translate }} + + + + +
+
+
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.ts b/ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.ts new file mode 100644 index 0000000000..7b13618063 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.ts @@ -0,0 +1,96 @@ +/// +/// 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 { Component, Inject, OnInit, SkipSelf, ViewChild } from '@angular/core'; +import { ErrorStateMatcher, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormControl, FormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms'; +import { + CONTAINS_TYPE, + EntityRelation, + EntitySearchDirection, + RelationTypeGroup +} from '@shared/models/relation.models'; +import { EntityRelationService } from '@core/http/entity-relation.service'; +import { EntityId } from '@shared/models/id/entity-id'; +import { forkJoin, Observable } from 'rxjs'; +import { JsonObjectEditComponent } from '@app/shared/components/json-object-edit.component'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@app/shared/components/dialog.component'; +import { AttributeData, AttributeScope } from '@shared/models/telemetry/telemetry.models'; +import { AttributeService } from '@core/http/attribute.service'; + +export interface AddAttributeDialogData { + entityId: EntityId; + attributeScope: AttributeScope; +} + +@Component({ + selector: 'tb-add-attribute-dialog', + templateUrl: './add-attribute-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: AddAttributeDialogComponent}], + styleUrls: [] +}) +export class AddAttributeDialogComponent extends DialogComponent + implements OnInit, ErrorStateMatcher { + + attributeFormGroup: FormGroup; + + submitted = false; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: AddAttributeDialogData, + private attributeService: AttributeService, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef, + public fb: FormBuilder) { + super(store, router, dialogRef); + } + + ngOnInit(): void { + this.attributeFormGroup = this.fb.group({ + key: ['', [Validators.required]], + value: [null, [Validators.required]] + }); + } + + isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this.errorStateMatcher.isErrorState(control, form); + const customErrorState = !!(control && control.invalid && this.submitted); + return originalErrorState || customErrorState; + } + + cancel(): void { + this.dialogRef.close(false); + } + + add(): void { + this.submitted = true; + const attribute: AttributeData = { + lastUpdateTs: null, + key: this.attributeFormGroup.get('key').value, + value: this.attributeFormGroup.get('value').value + }; + this.attributeService.saveEntityAttributes(this.data.entityId, + this.data.attributeScope, [attribute]).subscribe( + () => { + this.dialogRef.close(true); + } + ); + } +} diff --git a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.html b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.html new file mode 100644 index 0000000000..834cb262fc --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.html @@ -0,0 +1,192 @@ + +
+
+ +
+ {{telemetryTypeTranslationsMap.get(attributeScope) | translate}} + + attribute.attributes-scope + + + {{ telemetryTypeTranslationsMap.get(scope) | translate }} + + + + + + + +
+
+ +
+ + +   + + + +
+
+ +
+ + {{ translate.get( + attributeScope === latestTelemetryTypes.LATEST_TELEMETRY + ? 'attribute.selected-telemetry' : 'attribute.selected-attributes', + {count: dataSource.selection.selected.length}) | async }} + + + + +
+
+ +
+
+ {{ 'widgets-bundle.current' | translate }} + TODO: +
+ + +
+
+
+ + + + + + + + + + + + + {{ 'attribute.last-update-time' | translate }} + + {{ attribute.lastUpdateTs | date:'yyyy-MM-dd HH:mm:ss' }} + + + + {{ 'attribute.key' | translate }} + + {{ attribute.key }} + + + + {{ 'attribute.value' | translate }} + + {{attribute.value}} + + edit + + + + + + + {{ + attributeScope === latestTelemetryTypes.LATEST_TELEMETRY + ? 'attribute.no-telemetry-text' + : 'attribute.no-attributes-text' + }} +
+ + +
+ Coming soon! +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.scss b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.scss new file mode 100644 index 0000000000..96a70f6e8c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.scss @@ -0,0 +1,63 @@ +/** + * 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-entity-table { + .tb-entity-table-content { + width: 100%; + height: 100%; + background: #fff; + + .tb-entity-table-title { + padding-right: 20px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .table-container { + overflow: auto; + } + } + } +} + +:host ::ng-deep { + .mat-sort-header-sorted .mat-sort-header-arrow { + opacity: 1 !important; + } + mat-form-field.tb-attribute-scope { + font-size: 16px; + + .mat-form-field-wrapper { + padding-bottom: 0; + } + + .mat-form-field-underline { + bottom: 0; + } + } + mat-cell.tb-value-cell { + cursor: pointer; + mat-icon { + height: 16px; + width: 16px; + font-size: 16px; + color: #757575 + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts new file mode 100644 index 0000000000..70c5154615 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts @@ -0,0 +1,341 @@ +/// +/// 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, + ChangeDetectionStrategy, + Component, + ElementRef, + Input, + OnInit, + ViewChild, + ViewContainerRef +} from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { PageLink } from '@shared/models/page/page-link'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort } from '@angular/material/sort'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { DialogService } from '@core/services/dialog.service'; +import { Direction, SortOrder } from '@shared/models/page/sort-order'; +import { fromEvent, merge } from 'rxjs'; +import { debounceTime, distinctUntilChanged, tap } from 'rxjs/operators'; +import { EntityId } from '@shared/models/id/entity-id'; +import { + AttributeData, + AttributeScope, + isClientSideTelemetryType, LatestTelemetry, + TelemetryType, + telemetryTypeTranslations +} from '@shared/models/telemetry/telemetry.models'; +import { AttributeDatasource } from '@home/models/datasource/attribute-datasource'; +import { AttributeService } from '@app/core/http/attribute.service'; +import { EntityType } from '@shared/models/entity-type.models'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { RelationDialogComponent, RelationDialogData } from '@home/components/relation/relation-dialog.component'; +import { + AddAttributeDialogComponent, + AddAttributeDialogData +} from '@home/components/attribute/add-attribute-dialog.component'; +import { ConnectedPosition, Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; +import { TIMEWINDOW_PANEL_DATA, TimewindowPanelComponent } from '@shared/components/time/timewindow-panel.component'; +import { + EDIT_ATTRIBUTE_VALUE_PANEL_DATA, + EditAttributeValuePanelComponent, + EditAttributeValuePanelData +} from './edit-attribute-value-panel.component'; +import { ComponentPortal, PortalInjector } from '@angular/cdk/portal'; + + +@Component({ + selector: 'tb-attribute-table', + templateUrl: './attribute-table.component.html', + styleUrls: ['./attribute-table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AttributeTableComponent extends PageComponent implements AfterViewInit, OnInit { + + telemetryTypeTranslationsMap = telemetryTypeTranslations; + isClientSideTelemetryTypeMap = isClientSideTelemetryType; + + latestTelemetryTypes = LatestTelemetry; + + mode: 'default' | 'widget' = 'default'; + + attributeScopes: Array = []; + attributeScope: TelemetryType; + + displayedColumns = ['select', 'lastUpdateTs', 'key', 'value']; + pageLink: PageLink; + textSearchMode = false; + dataSource: AttributeDatasource; + + activeValue = false; + dirtyValue = false; + entityIdValue: EntityId; + + attributeScopeSelectionReadonly = false; + + viewsInited = false; + + private disableAttributeScopeSelectionValue: boolean; + get disableAttributeScopeSelection(): boolean { + return this.disableAttributeScopeSelectionValue; + } + @Input() + set disableAttributeScopeSelection(value: boolean) { + this.disableAttributeScopeSelectionValue = coerceBooleanProperty(value); + } + + @Input() + defaultAttributeScope: TelemetryType; + + @Input() + set active(active: boolean) { + if (this.activeValue !== active) { + this.activeValue = active; + if (this.activeValue && this.dirtyValue) { + this.dirtyValue = false; + if (this.viewsInited) { + this.updateData(true); + } + } + } + } + + @Input() + set entityId(entityId: EntityId) { + if (this.entityIdValue !== entityId) { + this.entityIdValue = entityId; + this.resetSortAndFilter(this.activeValue); + if (!this.activeValue) { + this.dirtyValue = true; + } + } + } + + @ViewChild('searchInput', {static: false}) searchInputField: ElementRef; + + @ViewChild(MatPaginator, {static: false}) paginator: MatPaginator; + @ViewChild(MatSort, {static: false}) sort: MatSort; + + constructor(protected store: Store, + private attributeService: AttributeService, + public translate: TranslateService, + public dialog: MatDialog, + private overlay: Overlay, + private viewContainerRef: ViewContainerRef, + private dialogService: DialogService) { + super(store); + this.dirtyValue = !this.activeValue; + const sortOrder: SortOrder = { property: 'key', direction: Direction.ASC }; + this.pageLink = new PageLink(10, 0, null, sortOrder); + this.dataSource = new AttributeDatasource(this.attributeService, this.translate); + } + + ngOnInit() { + } + + attributeScopeChanged(attributeScope: TelemetryType) { + this.attributeScope = attributeScope; + this.mode = 'default'; + this.updateData(true); + } + + ngAfterViewInit() { + + fromEvent(this.searchInputField.nativeElement, 'keyup') + .pipe( + debounceTime(150), + distinctUntilChanged(), + tap(() => { + this.paginator.pageIndex = 0; + this.updateData(); + }) + ) + .subscribe(); + + this.sort.sortChange.subscribe(() => this.paginator.pageIndex = 0); + + merge(this.sort.sortChange, this.paginator.page) + .pipe( + tap(() => this.updateData()) + ) + .subscribe(); + + this.viewsInited = true; + if (this.activeValue && this.entityIdValue) { + this.updateData(true); + } + } + + updateData(reload: boolean = false) { + this.pageLink.page = this.paginator.pageIndex; + this.pageLink.pageSize = this.paginator.pageSize; + this.pageLink.sortOrder.property = this.sort.active; + this.pageLink.sortOrder.direction = Direction[this.sort.direction.toUpperCase()]; + this.dataSource.loadAttributes(this.entityIdValue, this.attributeScope, this.pageLink, reload); + } + + enterFilterMode() { + this.textSearchMode = true; + this.pageLink.textSearch = ''; + setTimeout(() => { + this.searchInputField.nativeElement.focus(); + this.searchInputField.nativeElement.setSelectionRange(0, 0); + }, 10); + } + + exitFilterMode() { + this.textSearchMode = false; + this.pageLink.textSearch = null; + this.paginator.pageIndex = 0; + this.updateData(); + } + + resetSortAndFilter(update: boolean = true) { + const entityType = this.entityIdValue.entityType; + if (entityType === EntityType.DEVICE || entityType === EntityType.ENTITY_VIEW) { + this.attributeScopes = Object.keys(AttributeScope); + this.attributeScopeSelectionReadonly = false; + } else { + this.attributeScopes = [AttributeScope.SERVER_SCOPE]; + this.attributeScopeSelectionReadonly = true; + } + this.mode = 'default'; + this.attributeScope = this.defaultAttributeScope; + this.pageLink.textSearch = null; + if (this.viewsInited) { + this.paginator.pageIndex = 0; + const sortable = this.sort.sortables.get('key'); + this.sort.active = sortable.id; + this.sort.direction = 'asc'; + if (update) { + this.updateData(true); + } + } + } + + reloadAttributes() { + this.updateData(true); + } + + addAttribute($event: Event) { + if ($event) { + $event.stopPropagation(); + } + this.dialog.open(AddAttributeDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + entityId: this.entityIdValue, + attributeScope: this.attributeScope as AttributeScope + } + }).afterClosed().subscribe( + (res) => { + if (res) { + this.reloadAttributes(); + } + } + ); + } + + editAttribute($event: Event, attribute: AttributeData) { + if ($event) { + $event.stopPropagation(); + } + const target = $event.target || $event.srcElement || $event.currentTarget; + const config = new OverlayConfig(); + config.backdropClass = 'cdk-overlay-transparent-backdrop'; + config.hasBackdrop = true; + const connectedPosition: ConnectedPosition = { + originX: 'end', + originY: 'center', + overlayX: 'end', + overlayY: 'center' + }; + config.positionStrategy = this.overlay.position().flexibleConnectedTo(target as HTMLElement) + .withPositions([connectedPosition]); + + const overlayRef = this.overlay.create(config); + overlayRef.backdropClick().subscribe(() => { + overlayRef.dispose(); + }); + const injectionTokens = new WeakMap([ + [EDIT_ATTRIBUTE_VALUE_PANEL_DATA, { + attributeValue: attribute.value + } as EditAttributeValuePanelData], + [OverlayRef, overlayRef] + ]); + const injector = new PortalInjector(this.viewContainerRef.injector, injectionTokens); + const componentRef = overlayRef.attach(new ComponentPortal(EditAttributeValuePanelComponent, + this.viewContainerRef, injector)); + componentRef.onDestroy(() => { + if (componentRef.instance.result !== null) { + const attributeValue = componentRef.instance.result; + const updatedAttribute = {...attribute}; + updatedAttribute.value = attributeValue; + this.attributeService.saveEntityAttributes(this.entityIdValue, + this.attributeScope as AttributeScope, [updatedAttribute]).subscribe( + () => { + this.reloadAttributes(); + } + ); + } + }); + } + + deleteAttributes($event: Event) { + if ($event) { + $event.stopPropagation(); + } + if (this.dataSource.selection.selected.length > 0) { + this.dialogService.confirm( + this.translate.instant('attribute.delete-attributes-title', {count: this.dataSource.selection.selected.length}), + this.translate.instant('attribute.delete-attributes-text'), + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((result) => { + if (result) { + this.attributeService.deleteEntityAttributes(this.entityIdValue, + this.attributeScope as AttributeScope, this.dataSource.selection.selected).subscribe( + () => { + this.reloadAttributes(); + } + ); + } + }); + } + } + + enterWidgetMode() { + this.mode = 'widget'; + + // TODO: + } + + exitWidgetMode() { + this.mode = 'default'; + this.reloadAttributes(); + + // TODO: + } + +} diff --git a/ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.html b/ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.html new file mode 100644 index 0000000000..ad2945f6bd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.html @@ -0,0 +1,42 @@ + +
+
+ + +
+
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.scss b/ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.scss new file mode 100644 index 0000000000..4ee628e7d3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.scss @@ -0,0 +1,19 @@ +/** + * 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 { + background-color: #f9f9f9; +} + diff --git a/ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.ts b/ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.ts new file mode 100644 index 0000000000..d2912dda2e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.ts @@ -0,0 +1,88 @@ +/// +/// 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 { Component, Inject, InjectionToken, OnInit, SkipSelf, ViewChild } from '@angular/core'; +import { ErrorStateMatcher, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormControl, FormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms'; +import { + CONTAINS_TYPE, + EntityRelation, + EntitySearchDirection, + RelationTypeGroup +} from '@shared/models/relation.models'; +import { EntityRelationService } from '@core/http/entity-relation.service'; +import { EntityId } from '@shared/models/id/entity-id'; +import { forkJoin, Observable } from 'rxjs'; +import { JsonObjectEditComponent } from '@app/shared/components/json-object-edit.component'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@app/shared/components/dialog.component'; +import { AttributeData, AttributeScope } from '@shared/models/telemetry/telemetry.models'; +import { AttributeService } from '@core/http/attribute.service'; +import { PageComponent } from '@shared/components/page.component'; +import { OverlayRef } from '@angular/cdk/overlay'; + +export const EDIT_ATTRIBUTE_VALUE_PANEL_DATA = new InjectionToken('EditAttributeValuePanelData'); + +export interface EditAttributeValuePanelData { + attributeValue: any; +} + +@Component({ + selector: 'tb-edit-attribute-value-panel', + templateUrl: './edit-attribute-value-panel.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: EditAttributeValuePanelComponent}], + styleUrls: ['./edit-attribute-value-panel.component.scss'] +}) +export class EditAttributeValuePanelComponent extends PageComponent implements OnInit, ErrorStateMatcher { + + attributeFormGroup: FormGroup; + + result: any = null; + + submitted = false; + + constructor(protected store: Store, + @Inject(EDIT_ATTRIBUTE_VALUE_PANEL_DATA) public data: EditAttributeValuePanelData, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public overlayRef: OverlayRef, + public fb: FormBuilder) { + super(store); + } + + ngOnInit(): void { + this.attributeFormGroup = this.fb.group({ + value: [this.data.attributeValue, [Validators.required]] + }); + } + + isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this.errorStateMatcher.isErrorState(control, form); + const customErrorState = !!(control && control.invalid && this.submitted); + return originalErrorState || customErrorState; + } + + cancel(): void { + this.overlayRef.dispose(); + } + + update(): void { + this.submitted = true; + this.result = this.attributeFormGroup.get('value').value; + this.overlayRef.dispose(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.scss b/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.scss index d30159bdfa..1197999f23 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.scss +++ b/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.scss @@ -28,4 +28,7 @@ right: 0; bottom: 0; } + .mat-tab-label { + min-width: 40px; + } } diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-tabs.component.ts b/ui-ngx/src/app/modules/home/components/entity/entity-tabs.component.ts index dd8a02c8bc..bd37d8808a 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entity-tabs.component.ts @@ -38,10 +38,14 @@ import { AuthUser } from '@shared/models/user.model'; import { EntityType } from '@shared/models/entity-type.models'; import { AuditLogMode } from '@shared/models/audit-log.models'; import { DebugEventType, EventType } from '@shared/models/event.models'; +import { AttributeScope, LatestTelemetry } from '@shared/models/telemetry/telemetry.models'; import { NULL_UUID } from '@shared/models/id/has-uuid'; export abstract class EntityTabsComponent> extends PageComponent implements OnInit, AfterViewInit { + attributeScopes = AttributeScope; + latestTelemetryTypes = LatestTelemetry; + authorities = Authority; entityTypes = EntityType; diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index 61c2c0f458..d10e52dec8 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -31,6 +31,9 @@ import { RelationDialogComponent } from './relation/relation-dialog.component'; import { AlarmTableHeaderComponent } from '@home/components/alarm/alarm-table-header.component'; import { AlarmTableComponent } from '@home/components/alarm/alarm-table.component'; import { AlarmDetailsDialogComponent } from '@home/components/alarm/alarm-details-dialog.component'; +import { AttributeTableComponent } from '@home/components/attribute/attribute-table.component'; +import { AddAttributeDialogComponent } from './attribute/add-attribute-dialog.component'; +import { EditAttributeValuePanelComponent } from './attribute/edit-attribute-value-panel.component'; @NgModule({ entryComponents: [ @@ -39,7 +42,9 @@ import { AlarmDetailsDialogComponent } from '@home/components/alarm/alarm-detail EventTableHeaderComponent, RelationDialogComponent, AlarmTableHeaderComponent, - AlarmDetailsDialogComponent + AlarmDetailsDialogComponent, + AddAttributeDialogComponent, + EditAttributeValuePanelComponent ], declarations: [ @@ -56,7 +61,10 @@ import { AlarmDetailsDialogComponent } from '@home/components/alarm/alarm-detail RelationDialogComponent, AlarmTableHeaderComponent, AlarmTableComponent, - AlarmDetailsDialogComponent + AlarmDetailsDialogComponent, + AttributeTableComponent, + AddAttributeDialogComponent, + EditAttributeValuePanelComponent ], imports: [ CommonModule, @@ -72,7 +80,8 @@ import { AlarmDetailsDialogComponent } from '@home/components/alarm/alarm-detail EventTableComponent, RelationTableComponent, AlarmTableComponent, - AlarmDetailsDialogComponent + AlarmDetailsDialogComponent, + AttributeTableComponent ] }) export class HomeComponentsModule { } diff --git a/ui-ngx/src/app/modules/home/models/datasource/attribute-datasource.ts b/ui-ngx/src/app/modules/home/models/datasource/attribute-datasource.ts new file mode 100644 index 0000000000..6c709c5181 --- /dev/null +++ b/ui-ngx/src/app/modules/home/models/datasource/attribute-datasource.ts @@ -0,0 +1,135 @@ +/// +/// 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 { CollectionViewer, DataSource } from '@angular/cdk/typings/collections'; +import { BehaviorSubject, Observable, of, ReplaySubject } from 'rxjs'; +import { emptyPageData, PageData } from '@shared/models/page/page-data'; +import { SelectionModel } from '@angular/cdk/collections'; +import { PageLink } from '@shared/models/page/page-link'; +import { catchError, map, publishReplay, refCount, take, tap } from 'rxjs/operators'; +import { EntityId } from '@app/shared/models/id/entity-id'; +import { TranslateService } from '@ngx-translate/core'; +import { + AttributeData, + AttributeScope, + isClientSideTelemetryType, + TelemetryType +} from '@shared/models/telemetry/telemetry.models'; +import { AttributeService } from '@core/http/attribute.service'; + +export class AttributeDatasource implements DataSource { + + private attributesSubject = new BehaviorSubject([]); + private pageDataSubject = new BehaviorSubject>(emptyPageData()); + + public pageData$ = this.pageDataSubject.asObservable(); + + public selection = new SelectionModel(true, []); + + private allAttributes: Observable>; + + constructor(private attributeService: AttributeService, + private translate: TranslateService) {} + + connect(collectionViewer: CollectionViewer): Observable> { + return this.attributesSubject.asObservable(); + } + + disconnect(collectionViewer: CollectionViewer): void { + this.attributesSubject.complete(); + this.pageDataSubject.complete(); + } + + loadAttributes(entityId: EntityId, attributesScope: TelemetryType, + pageLink: PageLink, reload: boolean = false): Observable> { + if (reload) { + this.allAttributes = null; + } + const result = new ReplaySubject>(); + this.fetchAttributes(entityId, attributesScope, pageLink).pipe( + tap(() => { + this.selection.clear(); + }), + catchError(() => of(emptyPageData())), + ).subscribe( + (pageData) => { + this.attributesSubject.next(pageData.data); + this.pageDataSubject.next(pageData); + result.next(pageData); + } + ); + return result; + } + + fetchAttributes(entityId: EntityId, attributesScope: TelemetryType, + pageLink: PageLink): Observable> { + return this.getAllAttributes(entityId, attributesScope).pipe( + map((data) => pageLink.filterData(data)) + ); + } + + getAllAttributes(entityId: EntityId, attributesScope: TelemetryType): Observable> { + if (!this.allAttributes) { + let attributesObservable: Observable>; + if (isClientSideTelemetryType.get(attributesScope)) { + attributesObservable = of([]); + // TODO: + } else { + attributesObservable = this.attributeService.getEntityAttributes(entityId, attributesScope as AttributeScope); + } + this.allAttributes = attributesObservable.pipe( + publishReplay(1), + refCount() + ); + } + return this.allAttributes; + } + + isAllSelected(): Observable { + const numSelected = this.selection.selected.length; + return this.attributesSubject.pipe( + map((attributes) => numSelected === attributes.length) + ); + } + + isEmpty(): Observable { + return this.attributesSubject.pipe( + map((attributes) => !attributes.length) + ); + } + + total(): Observable { + return this.pageDataSubject.pipe( + map((pageData) => pageData.totalElements) + ); + } + + masterToggle() { + this.attributesSubject.pipe( + tap((attributes) => { + const numSelected = this.selection.selected.length; + if (numSelected === attributes.length) { + this.selection.clear(); + } else { + attributes.forEach(row => { + this.selection.select(row); + }); + } + }), + take(1) + ).subscribe(); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.html b/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.html index 84ecb1bfab..a4d260f91b 100644 --- a/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.html @@ -15,6 +15,21 @@ limitations under the License. --> + + + + + + + + diff --git a/ui-ngx/src/app/shared/components/value-input.component.html b/ui-ngx/src/app/shared/components/value-input.component.html new file mode 100644 index 0000000000..741b0f49d5 --- /dev/null +++ b/ui-ngx/src/app/shared/components/value-input.component.html @@ -0,0 +1,63 @@ + +
+
+ + value.type + + + + {{ valueTypes.get(valueType).name | translate }} + + + + {{ valueTypes.get(valueType).name | translate }} + + + + + value.string-value + + + {{ (requiredText ? requiredText : 'value.string-value-required') | translate }} + + + + value.integer-value + + + {{ (requiredText ? requiredText : 'value.integer-value-required') | translate }} + + + {{ 'value.invalid-integer-value' | translate }} + + + + value.double-value + + + {{ (requiredText ? requiredText : 'value.double-value-required') | translate }} + + +
+ + {{ (modelValue ? 'value.true' : 'value.false') | translate }} + +
+
+
diff --git a/ui-ngx/src/app/shared/components/value-input.component.scss b/ui-ngx/src/app/shared/components/value-input.component.scss new file mode 100644 index 0000000000..fb05bf73dc --- /dev/null +++ b/ui-ngx/src/app/shared/components/value-input.component.scss @@ -0,0 +1,28 @@ +/** + * 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 ::ng-deep { + mat-form-field.tb-value-type { + .mat-form-field-infix { + padding-bottom: 1px; + } + mat-select-trigger { + mat-icon { + margin-right: 16px; + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/value-input.component.ts b/ui-ngx/src/app/shared/components/value-input.component.ts new file mode 100644 index 0000000000..ef2f328b24 --- /dev/null +++ b/ui-ngx/src/app/shared/components/value-input.component.ts @@ -0,0 +1,107 @@ +/// +/// 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 { Component, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR, NgForm } from '@angular/forms'; +import { ValueType, valueTypesMap } from '@shared/models/constants'; + +@Component({ + selector: 'tb-value-input', + templateUrl: './value-input.component.html', + styleUrls: ['./value-input.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ValueInputComponent), + multi: true + } + ] +}) +export class ValueInputComponent implements OnInit, ControlValueAccessor { + + @Input() disabled: boolean; + + @Input() requiredText: string; + + @ViewChild('inputForm', {static: true}) inputForm: NgForm; + + modelValue: any; + + valueType: ValueType; + + public valueTypeEnum = ValueType; + + valueTypeKeys = Object.keys(ValueType); + + valueTypes = valueTypesMap; + + private propagateChange = null; + + constructor() { + + } + + ngOnInit(): void { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(value: any): void { + this.modelValue = value; + if (this.modelValue === true || this.modelValue === false) { + this.valueType = ValueType.BOOLEAN; + } else if (typeof this.modelValue === 'number') { + if (this.modelValue.toString().indexOf('.') === -1) { + this.valueType = ValueType.INTEGER; + } else { + this.valueType = ValueType.DOUBLE; + } + } else { + this.valueType = ValueType.STRING; + } + } + + updateView() { + if (this.inputForm.valid || this.valueType === ValueType.BOOLEAN) { + this.propagateChange(this.modelValue); + } else { + this.propagateChange(null); + } + } + + onValueTypeChanged() { + if (this.valueType === ValueType.BOOLEAN) { + this.modelValue = false; + } else { + this.modelValue = null; + } + this.updateView(); + } + + onValueChanged() { + this.updateView(); + } + +} diff --git a/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts b/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts index 2e91be8e11..ace2326549 100644 --- a/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts +++ b/ui-ngx/src/app/shared/models/telemetry/telemetry.models.ts @@ -15,9 +15,47 @@ /// +import { AlarmSeverity } from '@shared/models/alarm.models'; + export enum DataKeyType { timeseries = 'timeseries', attribute = 'attribute', function = 'function', alarm = 'alarm' } + +export enum LatestTelemetry { + LATEST_TELEMETRY = 'LATEST_TELEMETRY' +} + +export enum AttributeScope { + CLIENT_SCOPE = 'CLIENT_SCOPE', + SERVER_SCOPE = 'SERVER_SCOPE', + SHARED_SCOPE = 'SHARED_SCOPE' +} + +export type TelemetryType = LatestTelemetry | AttributeScope; + +export const telemetryTypeTranslations = new Map( + [ + [LatestTelemetry.LATEST_TELEMETRY, 'attribute.scope-latest-telemetry'], + [AttributeScope.CLIENT_SCOPE, 'attribute.scope-client'], + [AttributeScope.SERVER_SCOPE, 'attribute.scope-server'], + [AttributeScope.SHARED_SCOPE, 'attribute.scope-shared'] + ] +); + +export const isClientSideTelemetryType = new Map( + [ + [LatestTelemetry.LATEST_TELEMETRY, true], + [AttributeScope.CLIENT_SCOPE, true], + [AttributeScope.SERVER_SCOPE, false], + [AttributeScope.SHARED_SCOPE, false] + ] +); + +export interface AttributeData { + lastUpdateTs: number; + key: string; + value: any; +} diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 78b3bad20d..44c5b27ac8 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -70,7 +70,7 @@ import {TimeintervalComponent} from '@shared/components/time/timeinterval.compon import {DatetimePeriodComponent} from '@shared/components/time/datetime-period.component'; import {EnumToArrayPipe} from '@shared/pipe/enum-to-array.pipe'; import {ClipboardModule} from 'ngx-clipboard'; -// import { ValueInputComponent } from '@shared/components/value-input.component'; +import { ValueInputComponent } from '@shared/components/value-input.component'; import {FullscreenDirective} from '@shared/components/fullscreen.directive'; import {HighlightPipe} from '@shared/pipe/highlight.pipe'; import {DashboardAutocompleteComponent} from '@shared/components/dashboard-autocomplete.component'; @@ -93,7 +93,6 @@ import { JsonObjectEditComponent } from './components/json-object-edit.component MillisecondsToTimeStringPipe, EnumToArrayPipe, HighlightPipe -// IntervalCountPipe, ], entryComponents: [ TbSnackBarComponent, @@ -116,7 +115,7 @@ import { JsonObjectEditComponent } from './components/json-object-edit.component TimeintervalComponent, DatetimePeriodComponent, DatetimeComponent, -// ValueInputComponent, + ValueInputComponent, DashboardAutocompleteComponent, EntitySubTypeAutocompleteComponent, EntitySubTypeSelectComponent, @@ -202,7 +201,7 @@ import { JsonObjectEditComponent } from './components/json-object-edit.component RelationTypeAutocompleteComponent, SocialSharePanelComponent, JsonObjectEditComponent, -// ValueInputComponent, + ValueInputComponent, MatButtonModule, MatCheckboxModule, MatIconModule, diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index c87564f227..f87e2027ac 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -296,7 +296,9 @@ "add-to-dashboard": "Add to dashboard", "add-widget-to-dashboard": "Add widget to dashboard", "selected-attributes": "{ count, plural, 1 {1 attribute} other {# attributes} } selected", - "selected-telemetry": "{ count, plural, 1 {1 telemetry unit} other {# telemetry units} } selected" + "selected-telemetry": "{ count, plural, 1 {1 telemetry unit} other {# telemetry units} } selected", + "no-attributes-text": "No attributes found", + "no-telemetry-text": "No telemetry found" }, "audit-log": { "audit": "Audit", @@ -1491,11 +1493,14 @@ "type": "Value type", "string": "String", "string-value": "String value", + "string-value-required": "String value is required", "integer": "Integer", "integer-value": "Integer value", + "integer-value-required": "Integer value is required", "invalid-integer-value": "Invalid integer value", "double": "Double", "double-value": "Double value", + "double-value-required": "Double value is required", "boolean": "Boolean", "boolean-value": "Boolean value", "false": "False",