From b438cdd255edbf0afd41086cfa2ce71f17f86c24 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 27 Aug 2019 20:07:09 +0300 Subject: [PATCH] Edit relation dialog --- .../home/components/home-components.module.ts | 7 +- .../relation/relation-dialog.component.html | 68 +++++++ .../relation/relation-dialog.component.scss | 18 ++ .../relation/relation-dialog.component.ts | 134 +++++++++++++ .../relation/relation-table.component.ts | 112 ++++++++++- .../entity/entity-list-select.component.html | 36 ++++ .../entity/entity-list-select.component.scss | 32 ++++ .../entity/entity-list-select.component.ts | 170 +++++++++++++++++ .../entity/entity-list.component.html | 4 +- .../entity/entity-list.component.ts | 57 ++++-- .../json-object-edit.component.html | 35 ++++ .../json-object-edit.component.scss | 55 ++++++ .../components/json-object-edit.component.ts | 180 ++++++++++++++++++ .../relation-type-autocomplete.component.html | 42 ++++ .../relation-type-autocomplete.component.ts | 168 ++++++++++++++++ .../src/app/shared/models/page/page-link.ts | 2 +- ui-ngx/src/app/shared/shared.module.ts | 9 + 17 files changed, 1099 insertions(+), 30 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.ts create mode 100644 ui-ngx/src/app/shared/components/entity/entity-list-select.component.html create mode 100644 ui-ngx/src/app/shared/components/entity/entity-list-select.component.scss create mode 100644 ui-ngx/src/app/shared/components/entity/entity-list-select.component.ts create mode 100644 ui-ngx/src/app/shared/components/json-object-edit.component.html create mode 100644 ui-ngx/src/app/shared/components/json-object-edit.component.scss create mode 100644 ui-ngx/src/app/shared/components/json-object-edit.component.ts create mode 100644 ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.html create mode 100644 ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.ts 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 daeb05e3fa..3cf870f96d 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 @@ -27,12 +27,14 @@ import { AuditLogTableComponent } from './audit-log/audit-log-table.component'; import { EventTableHeaderComponent } from '@home/components/event/event-table-header.component'; import { EventTableComponent } from '@home/components/event/event-table.component'; import { RelationTableComponent } from '@home/components/relation/relation-table.component'; +import { RelationDialogComponent } from './relation/relation-dialog.component'; @NgModule({ entryComponents: [ AddEntityDialogComponent, AuditLogDetailsDialogComponent, - EventTableHeaderComponent + EventTableHeaderComponent, + RelationDialogComponent ], declarations: [ @@ -45,7 +47,8 @@ import { RelationTableComponent } from '@home/components/relation/relation-table AuditLogDetailsDialogComponent, EventTableHeaderComponent, EventTableComponent, - RelationTableComponent + RelationTableComponent, + RelationDialogComponent ], imports: [ CommonModule, diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.html b/ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.html new file mode 100644 index 0000000000..f8b5ff439f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.html @@ -0,0 +1,68 @@ + +
+ +

{{ (isAdd ? 'relation.add' : 'relation.edit' ) | translate }}

+ + +
+ + +
+
+
+ + + {{(direction === entitySearchDirection.FROM ? + 'relation.to-entity' : 'relation.from-entity') | translate}} + + + + + +
+
+
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.scss b/ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.scss new file mode 100644 index 0000000000..dfbd362f33 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.scss @@ -0,0 +1,18 @@ +/** + * 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 { + +} diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.ts b/ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.ts new file mode 100644 index 0000000000..5bd216c10f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.ts @@ -0,0 +1,134 @@ +/// +/// 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 } from '@angular/core'; +import { ErrorStateMatcher, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material'; +import { PageComponent } from '@shared/components/page.component'; +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 { Observable, forkJoin } from 'rxjs'; + +export interface RelationDialogData { + isAdd: boolean; + direction: EntitySearchDirection; + relation: EntityRelation; +} + +@Component({ + selector: 'tb-relation-dialog', + templateUrl: './relation-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: RelationDialogComponent}], + styleUrls: ['./relation-dialog.component.scss'] +}) +export class RelationDialogComponent extends PageComponent implements OnInit, ErrorStateMatcher { + + relationFormGroup: FormGroup; + + isAdd: boolean; + direction: EntitySearchDirection; + entitySearchDirection = EntitySearchDirection; + + submitted = false; + + constructor(protected store: Store, + @Inject(MAT_DIALOG_DATA) public data: RelationDialogData, + private entityRelationService: EntityRelationService, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef, + public fb: FormBuilder) { + super(store); + this.isAdd = data.isAdd; + this.direction = data.direction; + } + + ngOnInit(): void { + this.relationFormGroup = this.fb.group({ + type: [this.isAdd ? CONTAINS_TYPE : this.data.relation.type, [Validators.required]], + targetEntityIds: [this.isAdd ? null : + [this.direction === EntitySearchDirection.FROM ? this.data.relation.to : this.data.relation.from], + [Validators.required]], + additionalInfo: [this.data.relation.additionalInfo] + }); + if (!this.isAdd) { + this.relationFormGroup.get('type').disable(); + this.relationFormGroup.get('targetEntityIds').disable(); + } + this.relationFormGroup.valueChanges.subscribe( + () => { + this.submitted = false; + } + ); + } + + 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); + } + + save(): void { + this.submitted = true; + if (this.relationFormGroup.valid) { + const additionalInfo = this.relationFormGroup.get('additionalInfo').value; + if (this.isAdd) { + const tasks: Observable[] = []; + const type: string = this.relationFormGroup.get('type').value; + const entityIds: Array = this.relationFormGroup.get('targetEntityIds').value; + entityIds.forEach(entityId => { + const relation = { + type, + additionalInfo, + typeGroup: RelationTypeGroup.COMMON + } as EntityRelation; + if (this.direction === EntitySearchDirection.FROM) { + relation.from = this.data.relation.from; + relation.to = entityId; + } else { + relation.from = entityId; + relation.to = this.data.relation.to; + } + tasks.push(this.entityRelationService.saveRelation(relation)); + }); + forkJoin(tasks).subscribe( + () => { + this.dialogRef.close(true); + } + ); + } else { + const relation: EntityRelation = {...this.data.relation}; + relation.additionalInfo = additionalInfo; + this.entityRelationService.saveRelation(relation).subscribe( + () => { + this.dialogRef.close(true); + } + ); + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-table.component.ts b/ui-ngx/src/app/modules/home/components/relation/relation-table.component.ts index fddf1db15a..26f7deb804 100644 --- a/ui-ngx/src/app/modules/home/components/relation/relation-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/relation/relation-table.component.ts @@ -26,12 +26,18 @@ import { MatDialog } from '@angular/material/dialog'; import { DialogService } from '@core/services/dialog.service'; import { EntityRelationService } from '@core/http/entity-relation.service'; import { Direction, SortOrder } from '@shared/models/page/sort-order'; -import { fromEvent, merge } from 'rxjs'; +import { forkJoin, fromEvent, merge, Observable } from 'rxjs'; import { debounceTime, distinctUntilChanged, tap } from 'rxjs/operators'; -import { EntityRelationInfo, EntitySearchDirection, entitySearchDirectionTranslations } from '@shared/models/relation.models'; +import { + EntityRelation, + EntityRelationInfo, + EntitySearchDirection, + entitySearchDirectionTranslations, + RelationTypeGroup +} from '@shared/models/relation.models'; import { EntityId } from '@shared/models/id/entity-id'; import { RelationsDatasource } from '../../models/datasource/relation-datasource'; -import { DebugEventType, EventType } from '@shared/models/event.models'; +import { RelationDialogComponent, RelationDialogData } from '@home/components/relation/relation-dialog.component'; @Component({ selector: 'tb-relation-table', @@ -201,8 +207,35 @@ export class RelationTableComponent extends PageComponent implements AfterViewIn if ($event) { $event.stopPropagation(); } + let title; + let content; + if (this.direction === EntitySearchDirection.FROM) { + title = this.translate.instant('relation.delete-to-relation-title', {entityName: relation.toName}); + content = this.translate.instant('relation.delete-to-relation-text', {entityName: relation.toName}); + } else { + title = this.translate.instant('relation.delete-from-relation-title', {entityName: relation.fromName}); + content = this.translate.instant('relation.delete-from-relation-text', {entityName: relation.fromName}); + } - // TODO: + this.dialogService.confirm( + title, + content, + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((result) => { + if (result) { + this.entityRelationService.deleteRelation( + relation.from, + relation.type, + relation.to + ).subscribe( + () => { + this.reloadRelations(); + } + ); + } + }); } deleteRelations($event: Event) { @@ -210,16 +243,79 @@ export class RelationTableComponent extends PageComponent implements AfterViewIn $event.stopPropagation(); } if (this.dataSource.selection.selected.length > 0) { - // TODO: + let title; + let content; + + if (this.direction === EntitySearchDirection.FROM) { + title = this.translate.instant('relation.delete-to-relations-title', {count: this.dataSource.selection.selected.length}); + content = this.translate.instant('relation.delete-to-relations-text'); + } else { + title = this.translate.instant('relation.delete-from-relations-title', {count: this.dataSource.selection.selected.length}); + content = this.translate.instant('relation.delete-from-relations-text'); + } + + this.dialogService.confirm( + title, + content, + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((result) => { + if (result) { + const tasks: Observable[] = []; + this.dataSource.selection.selected.forEach((relation) => { + tasks.push(this.entityRelationService.deleteRelation( + relation.from, + relation.type, + relation.to + )); + }); + forkJoin(tasks).subscribe( + () => { + this.reloadRelations(); + } + ); + } + }); } } - openRelationDialog($event: Event, relation: EntityRelationInfo = null) { + openRelationDialog($event: Event, relation: EntityRelation = null) { if ($event) { $event.stopPropagation(); } - // TODO: + + let isAdd = false; + if (!relation) { + isAdd = true; + relation = { + from: null, + to: null, + type: null, + typeGroup: RelationTypeGroup.COMMON + }; + if (this.direction === EntitySearchDirection.FROM) { + relation.from = this.entityIdValue; + } else { + relation.to = this.entityIdValue; + } + } + + this.dialog.open(RelationDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + isAdd, + direction: this.direction, + relation: {...relation} + } + }).afterClosed().subscribe( + (res) => { + if (res) { + this.reloadRelations(); + } + } + ); } - } diff --git a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html new file mode 100644 index 0000000000..6a5a117cd3 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html @@ -0,0 +1,36 @@ + +
+ + + + +
diff --git a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.scss b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.scss new file mode 100644 index 0000000000..21da8f6580 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.scss @@ -0,0 +1,32 @@ +/** + * 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 { +} + +:host ::ng-deep { + tb-entity-list { + &.tb-not-empty { + .mat-form-field-flex { + padding-top: 0; + } + } + .mat-form-field-flex { + .mat-form-field-infix { + border-top: 0; + } + } + } +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.ts new file mode 100644 index 0000000000..c10d37d9a8 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.ts @@ -0,0 +1,170 @@ +/// +/// 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, forwardRef, Input, OnInit} from '@angular/core'; +import {ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR} from '@angular/forms'; +import {Store} from '@ngrx/store'; +import {AppState} from '@core/core.state'; +import {TranslateService} from '@ngx-translate/core'; +import {AliasEntityType, EntityType, entityTypeTranslations} from '@shared/models/entity-type.models'; +import {EntityService} from '@core/http/entity.service'; +import {EntityId} from '@shared/models/id/entity-id'; +import {coerceBooleanProperty} from '@angular/cdk/coercion'; + +interface EntityListSelectModel { + entityType: EntityType | AliasEntityType; + ids: Array; +} + +@Component({ + selector: 'tb-entity-list-select', + templateUrl: './entity-list-select.component.html', + styleUrls: ['./entity-list-select.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityListSelectComponent), + multi: true + }] +}) + +export class EntityListSelectComponent implements ControlValueAccessor, OnInit, AfterViewInit { + + entityListSelectFormGroup: FormGroup; + + modelValue: EntityListSelectModel = {entityType: null, ids: []}; + + @Input() + allowedEntityTypes: Array; + + @Input() + useAliasEntityTypes: boolean; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + displayEntityTypeSelect: boolean; + + private defaultEntityType: EntityType | AliasEntityType = null; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + private entityService: EntityService, + public translate: TranslateService, + private fb: FormBuilder) { + + const entityTypes = this.entityService.prepareAllowedEntityTypesList(this.allowedEntityTypes, + this.useAliasEntityTypes); + if (entityTypes.length === 1) { + this.displayEntityTypeSelect = false; + this.defaultEntityType = entityTypes[0]; + } else { + this.displayEntityTypeSelect = true; + } + + this.entityListSelectFormGroup = this.fb.group({ + entityType: [this.defaultEntityType], + entityIds: [[]] + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.entityListSelectFormGroup.get('entityType').valueChanges.subscribe( + (value) => { + this.updateView(value, this.modelValue.ids); + } + ); + this.entityListSelectFormGroup.get('entityIds').valueChanges.subscribe( + (values) => { + this.updateView(this.modelValue.entityType, values); + } + ); + } + + ngAfterViewInit(): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.entityListSelectFormGroup.disable(); + } else { + this.entityListSelectFormGroup.enable(); + } + } + + writeValue(value: Array | null): void { + if (value != null && value.length > 0) { + const id = value[0]; + this.modelValue = { + entityType: id.entityType, + ids: value.map(val => val.id) + }; + } else { + this.modelValue = { + entityType: this.defaultEntityType, + ids: [] + }; + } + this.entityListSelectFormGroup.get('entityType').patchValue(this.modelValue.entityType, {emitEvent: true}); + this.entityListSelectFormGroup.get('entityIds').patchValue([...this.modelValue.ids], {emitEvent: true}); + } + + updateView(entityType: EntityType | AliasEntityType | null, entityIds: Array | null) { + if (this.modelValue.entityType !== entityType || + !this.compareIds(this.modelValue.ids, entityIds)) { + this.modelValue = { + entityType, + ids: this.modelValue.entityType !== entityType || !entityIds ? [] : [...entityIds] + }; + this.propagateChange(this.toEntityIds(this.modelValue)); + } + } + + compareIds(ids1: Array | null, ids2: Array | null): boolean { + if (ids1 !== null && ids2 !== null) { + return JSON.stringify(ids1) === JSON.stringify(ids2); + } else { + return ids1 === ids2; + } + } + + toEntityIds(modelValue: EntityListSelectModel): Array { + if (modelValue !== null && modelValue.entityType && modelValue.ids && modelValue.ids.length > 0) { + const entityType = modelValue.entityType; + return modelValue.ids.map(id => ({entityType, id})); + } else { + return null; + } + } + +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-list.component.html index 435427716e..8e0bae6b53 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.html @@ -16,7 +16,7 @@ --> - + - + {{ 'entity.entity-list-empty' | translate }} diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.ts b/ui-ngx/src/app/shared/components/entity/entity-list.component.ts index 8ab9600dd6..d0af0f533d 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.ts @@ -14,16 +14,26 @@ /// limitations under the License. /// -import {AfterViewInit, Component, ElementRef, forwardRef, Input, OnInit, SkipSelf, ViewChild} from '@angular/core'; +import { + AfterContentInit, + AfterViewInit, + Component, + ElementRef, + forwardRef, + Input, + OnInit, + SkipSelf, + ViewChild +} from '@angular/core'; import { ControlValueAccessor, FormBuilder, FormControl, FormGroup, FormGroupDirective, - NG_VALUE_ACCESSOR, NgForm + NG_VALUE_ACCESSOR, NgForm, Validators } from '@angular/forms'; -import {Observable} from 'rxjs'; +import {Observable, of} from 'rxjs'; import {map, mergeMap, startWith, tap, share, pairwise, filter} from 'rxjs/operators'; import {Store} from '@ngrx/store'; import {AppState} from '@app/core/core.state'; @@ -34,6 +44,7 @@ import {EntityId} from '@shared/models/id/entity-id'; import {EntityService} from '@core/http/entity.service'; import {ErrorStateMatcher, MatAutocomplete, MatAutocompleteSelectedEvent, MatChipList} from '@angular/material'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { emptyPageData } from '@shared/models/page/page-data'; @Component({ selector: 'tb-entity-list', @@ -69,7 +80,11 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV } @Input() set required(value: boolean) { - this.requiredValue = coerceBooleanProperty(value); + const newVal = coerceBooleanProperty(value); + if (this.requiredValue !== newVal) { + this.requiredValue = newVal; + this.updateValidators(); + } } @Input() @@ -77,7 +92,7 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV @ViewChild('entityInput', {static: false}) entityInput: ElementRef; @ViewChild('entityAutocomplete', {static: false}) matAutocomplete: MatAutocomplete; - @ViewChild('chipList', {static: false}) chipList: MatChipList; + @ViewChild('chipList', {static: true}) chipList: MatChipList; entities: Array> = []; filteredEntities: Observable>>; @@ -91,10 +106,16 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV private entityService: EntityService, private fb: FormBuilder) { this.entityListFormGroup = this.fb.group({ + entities: [this.entities, this.required ? [Validators.required] : []], entity: [null] }); } + updateValidators() { + this.entityListFormGroup.get('entities').setValidators(this.required ? [Validators.required] : []); + this.entityListFormGroup.get('entities').updateValueAndValidity(); + } + registerOnChange(fn: any): void { this.propagateChange = fn; } @@ -120,34 +141,39 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV ); } - ngAfterViewInit(): void {} + ngAfterViewInit(): void { + } setDisabledState(isDisabled: boolean): void { + const emitEvent = this.disabled !== isDisabled; this.disabled = isDisabled; - if (this.disabled) { - this.entityListFormGroup.disable(); + if (isDisabled) { + this.entityListFormGroup.disable({emitEvent}); } else { - this.entityListFormGroup.enable(); + this.entityListFormGroup.enable({emitEvent}); } } writeValue(value: Array | null): void { this.searchText = ''; - if (value != null) { + if (value != null && value.length > 0) { this.modelValue = [...value]; this.entityService.getEntities(this.entityTypeValue, value).subscribe( (entities) => { this.entities = entities; + this.entityListFormGroup.get('entities').setValue(this.entities); } ); } else { this.entities = []; + this.entityListFormGroup.get('entities').setValue(this.entities); this.modelValue = null; } } reset() { this.entities = []; + this.entityListFormGroup.get('entities').setValue(this.entities); this.modelValue = null; this.entityListFormGroup.get('entity').patchValue('', {emitEvent: true}); this.propagateChange(this.modelValue); @@ -160,9 +186,7 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV } this.modelValue.push(entity.id.id); this.entities.push(entity); - if (this.required) { - this.chipList.errorState = false; - } + this.entityListFormGroup.get('entities').setValue(this.entities); } this.propagateChange(this.modelValue); this.clear(); @@ -172,12 +196,10 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV const index = this.entities.indexOf(entity); if (index >= 0) { this.entities.splice(index, 1); + this.entityListFormGroup.get('entities').setValue(this.entities); this.modelValue.splice(index, 1); if (!this.modelValue.length) { this.modelValue = null; - if (this.required) { - this.chipList.errorState = true; - } } this.propagateChange(this.modelValue); this.clear(); @@ -190,7 +212,8 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV fetchEntities(searchText?: string): Observable>> { this.searchText = searchText; - return this.entityService.getEntitiesByNameFilter(this.entityTypeValue, searchText, + return this.disabled ? of([]) : + this.entityService.getEntitiesByNameFilter(this.entityTypeValue, searchText, 50, '', false, true).pipe( map((data) => data ? data : [])); } diff --git a/ui-ngx/src/app/shared/components/json-object-edit.component.html b/ui-ngx/src/app/shared/components/json-object-edit.component.html new file mode 100644 index 0000000000..a723cf1c33 --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-object-edit.component.html @@ -0,0 +1,35 @@ + +
+
+ + + +
+
+
+
+
diff --git a/ui-ngx/src/app/shared/components/json-object-edit.component.scss b/ui-ngx/src/app/shared/components/json-object-edit.component.scss new file mode 100644 index 0000000000..913da86348 --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-object-edit.component.scss @@ -0,0 +1,55 @@ +/** + * 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. + */ + +/** + * 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 { + position: relative; + + .fill-height { + height: 100%; + } + + .tb-json-object-panel { + height: 100%; + margin-left: 15px; + border: 1px solid #c0c0c0; + + #tb-json-input { + width: 100%; + min-width: 200px; + height: 100%; + + &:not(.fill-height) { + min-height: 200px; + } + } + } + +} diff --git a/ui-ngx/src/app/shared/components/json-object-edit.component.ts b/ui-ngx/src/app/shared/components/json-object-edit.component.ts new file mode 100644 index 0000000000..72f64e1bb7 --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-object-edit.component.ts @@ -0,0 +1,180 @@ +/// +/// 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 { + Attribute, + Component, + ElementRef, + forwardRef, + Input, + OnInit, + ViewChild +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormControl, Validator, NG_VALIDATORS } from '@angular/forms'; +import * as ace from 'ace-builds'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; + +@Component({ + selector: 'tb-json-object-edit', + templateUrl: './json-object-edit.component.html', + styleUrls: ['./json-object-edit.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => JsonObjectEditComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => JsonObjectEditComponent), + multi: true, + } + ] +}) +export class JsonObjectEditComponent implements OnInit, ControlValueAccessor, Validator { + + @ViewChild('jsonEditor', {static: true}) + jsonEditorElmRef: ElementRef; + + private jsonEditor: ace.Ace.Editor; + + @Input() label: string; + + @Input() disabled: boolean; + + @Input() fillHeight: boolean; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + private readonlyValue: boolean; + get readonly(): boolean { + return this.readonlyValue; + } + @Input() + set readonly(value: boolean) { + this.readonlyValue = coerceBooleanProperty(value); + } + + fullscreen = false; + + modelValue: any; + + contentValue: string; + + objectValid: boolean; + + private propagateChange = null; + + constructor() { + } + + ngOnInit(): void { + const editorElement = this.jsonEditorElmRef.nativeElement; + let editorOptions: Partial = { + mode: 'ace/mode/json', + theme: 'ace/theme/github', + showGutter: true, + showPrintMargin: false, + readOnly: this.readonly + }; + + const advancedOptions = { + enableSnippets: true, + enableBasicAutocompletion: true, + enableLiveAutocompletion: true + }; + + editorOptions = {...editorOptions, ...advancedOptions}; + this.jsonEditor = ace.edit(editorElement, editorOptions); + this.jsonEditor.session.setUseWrapMode(false); + this.jsonEditor.setValue(this.contentValue ? this.contentValue : '', -1); + this.jsonEditor.on('change', () => { + this.updateView(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + public validate(c: FormControl) { + return (this.objectValid) ? null : { + jsonParseError: { + valid: false, + }, + }; + } + + writeValue(value: any): void { + this.modelValue = value; + this.contentValue = ''; + this.objectValid = false; + try { + if (this.modelValue) { + this.contentValue = JSON.stringify(this.modelValue, undefined, 2); + this.objectValid = true; + } else { + this.objectValid = !this.required; + } + } catch (e) { + // + } + if (this.jsonEditor) { + this.jsonEditor.setValue(this.contentValue ? this.contentValue : '', -1); + } + } + + updateView() { + const editorValue = this.jsonEditor.getValue(); + if (this.contentValue !== editorValue) { + this.contentValue = editorValue; + let data = null; + this.objectValid = false; + if (this.contentValue && this.contentValue.length > 0) { + try { + data = JSON.parse(this.contentValue); + this.objectValid = true; + } catch (ex) {} + } else { + this.objectValid = !this.required; + } + this.propagateChange(data); + } + } + + onFullscreen() { + if (this.jsonEditor) { + setTimeout(() => { + this.jsonEditor.resize(); + }, 0); + } + } + +} diff --git a/ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.html b/ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.html new file mode 100644 index 0000000000..9e0d80c1c8 --- /dev/null +++ b/ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.html @@ -0,0 +1,42 @@ + + + + + + + + + + + {{ 'relation.relation-type-required' | translate }} + + diff --git a/ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.ts b/ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.ts new file mode 100644 index 0000000000..3c1536b361 --- /dev/null +++ b/ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.ts @@ -0,0 +1,168 @@ +/// +/// 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, forwardRef, Input, OnInit, ViewChild, OnDestroy} from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import {Observable, of, throwError, Subscription} from 'rxjs'; +import {PageLink} from '@shared/models/page/page-link'; +import {Direction} from '@shared/models/page/sort-order'; +import {filter, map, mergeMap, publishReplay, refCount, startWith, tap, publish} from 'rxjs/operators'; +import {PageData, emptyPageData} from '@shared/models/page/page-data'; +import {DashboardInfo} from '@app/shared/models/dashboard.models'; +import {DashboardId} from '@app/shared/models/id/dashboard-id'; +import {DashboardService} from '@core/http/dashboard.service'; +import {Store} from '@ngrx/store'; +import {AppState} from '@app/core/core.state'; +import {getCurrentAuthUser} from '@app/core/auth/auth.selectors'; +import {Authority} from '@shared/models/authority.enum'; +import {TranslateService} from '@ngx-translate/core'; +import {DeviceService} from '@core/http/device.service'; +import {EntitySubtype, EntityType} from '@app/shared/models/entity-type.models'; +import {BroadcastService} from '@app/core/services/broadcast.service'; +import {coerceBooleanProperty} from '@angular/cdk/coercion'; +import {AssetService} from '@core/http/asset.service'; +import {EntityViewService} from '@core/http/entity-view.service'; +import { RelationTypes } from '@app/shared/models/relation.models'; + +@Component({ + selector: 'tb-relation-type-autocomplete', + templateUrl: './relation-type-autocomplete.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RelationTypeAutocompleteComponent), + multi: true + }] +}) +export class RelationTypeAutocompleteComponent implements ControlValueAccessor, OnInit, AfterViewInit, OnDestroy { + + relationTypeFormGroup: FormGroup; + + modelValue: string | null; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + @ViewChild('relationTypeInput', {static: true}) relationTypeInput: ElementRef; + + filteredRelationTypes: Observable>; + + private searchText = ''; + + private dirty = false; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + private broadcast: BroadcastService, + public translate: TranslateService, + private fb: FormBuilder) { + this.relationTypeFormGroup = this.fb.group({ + relationType: [null, this.required ? [Validators.required] : []] + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + + this.filteredRelationTypes = this.relationTypeFormGroup.get('relationType').valueChanges + .pipe( + tap(value => { + this.updateView(value); + }), + // startWith(''), + map(value => value ? value : ''), + mergeMap(type => this.fetchRelationTypes(type) ) + ); + } + + ngAfterViewInit(): void { + } + + ngOnDestroy(): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.relationTypeFormGroup.disable({emitEvent: false}); + } else { + this.relationTypeFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: string | null): void { + this.searchText = ''; + this.modelValue = value; + this.relationTypeFormGroup.get('relationType').patchValue(value, {emitEvent: false}); + this.dirty = true; + } + + onFocus() { + if (this.dirty) { + this.relationTypeFormGroup.get('relationType').updateValueAndValidity({onlySelf: true, emitEvent: true}); + this.dirty = false; + } + } + + updateView(value: string | null) { + if (this.modelValue !== value) { + this.modelValue = value; + this.propagateChange(this.modelValue); + } + } + + displayRelationTypeFn(relationType?: string): string | undefined { + return relationType ? relationType : undefined; + } + + fetchRelationTypes(searchText?: string, strictMatch: boolean = false): Observable> { + this.searchText = searchText; + return of(RelationTypes).pipe( + map(relationTypes => relationTypes.filter( relationType => { + if (strictMatch) { + return searchText ? relationType === searchText : false; + } else { + return searchText ? relationType.toUpperCase().startsWith(searchText.toUpperCase()) : true; + } + })) + ); + } + + clear() { + this.relationTypeFormGroup.get('relationType').patchValue(null, {emitEvent: true}); + setTimeout(() => { + this.relationTypeInput.nativeElement.blur(); + this.relationTypeInput.nativeElement.focus(); + }, 0); + } + +} diff --git a/ui-ngx/src/app/shared/models/page/page-link.ts b/ui-ngx/src/app/shared/models/page/page-link.ts index 4be0264466..e4e35a481e 100644 --- a/ui-ngx/src/app/shared/models/page/page-link.ts +++ b/ui-ngx/src/app/shared/models/page/page-link.ts @@ -96,7 +96,7 @@ export class PageLink { pageData.totalElements = pageData.data.length; pageData.totalPages = Math.ceil(pageData.totalElements / this.pageSize); if (this.sortOrder) { - pageData.data = pageData.data.sort(this.sort); + pageData.data = pageData.data.sort((a, b) => this.sort(a, b)); } const startIndex = this.pageSize * this.page; const endIndex = startIndex + this.pageSize; diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index dd102aa6ed..78b3bad20d 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -83,6 +83,9 @@ import {EntitySelectComponent} from './components/entity/entity-select.component import {DatetimeComponent} from '@shared/components/time/datetime.component'; import {EntityKeysListComponent} from './components/entity/entity-keys-list.component'; import {SocialSharePanelComponent} from './components/socialshare-panel.component'; +import { RelationTypeAutocompleteComponent } from '@shared/components/relation/relation-type-autocomplete.component'; +import { EntityListSelectComponent } from './components/entity/entity-list-select.component'; +import { JsonObjectEditComponent } from './components/json-object-edit.component'; @NgModule({ providers: [ @@ -122,7 +125,10 @@ import {SocialSharePanelComponent} from './components/socialshare-panel.componen EntityTypeSelectComponent, EntitySelectComponent, EntityKeysListComponent, + EntityListSelectComponent, + RelationTypeAutocompleteComponent, SocialSharePanelComponent, + JsonObjectEditComponent, NospacePipe, MillisecondsToTimeStringPipe, EnumToArrayPipe, @@ -192,7 +198,10 @@ import {SocialSharePanelComponent} from './components/socialshare-panel.componen EntityTypeSelectComponent, EntitySelectComponent, EntityKeysListComponent, + EntityListSelectComponent, + RelationTypeAutocompleteComponent, SocialSharePanelComponent, + JsonObjectEditComponent, // ValueInputComponent, MatButtonModule, MatCheckboxModule,