From 680cd1d2c1b91ce0971c87fbbe040c8e6b40739e Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 15 Aug 2019 20:39:56 +0300 Subject: [PATCH] Customers page --- ui-ngx/src/app/core/http/device.service.ts | 4 + ui-ngx/src/app/core/http/entity.service.ts | 76 ++++++- ...entities-to-customer-dialog.component.html | 58 +++++ ...d-entities-to-customer-dialog.component.ts | 118 ++++++++++ .../home/dialogs/home-dialogs.module.ts | 10 +- .../pages/customer/customer-routing.module.ts | 17 ++ .../customers-table-config.resolver.ts | 57 +++++ .../device/devices-table-config.resolver.ts | 19 +- .../dashboard-autocomplete.component.ts | 9 +- .../entity/entities-table-config.models.ts | 1 + .../entity/entities-table.component.html | 1 + .../entity/entities-table.component.ts | 1 + .../entity/entity-autocomplete.component.ts | 23 +- .../entity/entity-list.component.html | 48 +++++ .../entity/entity-list.component.ts | 202 ++++++++++++++++++ .../entity-subtype-autocomplete.component.ts | 9 +- .../models/datasource/entity-datasource.ts | 16 +- ui-ngx/src/app/shared/shared.module.ts | 9 +- 18 files changed, 660 insertions(+), 18 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/dialogs/add-entities-to-customer-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/dialogs/add-entities-to-customer-dialog.component.ts create mode 100644 ui-ngx/src/app/shared/components/entity/entity-list.component.html create mode 100644 ui-ngx/src/app/shared/components/entity/entity-list.component.ts diff --git a/ui-ngx/src/app/core/http/device.service.ts b/ui-ngx/src/app/core/http/device.service.ts index a45f89077b..d233e6650f 100644 --- a/ui-ngx/src/app/core/http/device.service.ts +++ b/ui-ngx/src/app/core/http/device.service.ts @@ -52,6 +52,10 @@ export class DeviceService { return this.http.get(`/api/device/${deviceId}`, defaultHttpOptions(ignoreLoading, ignoreErrors)); } + public getDevices(deviceIds: Array, ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable> { + return this.http.get>(`/api/devices?deviceIds=${deviceIds.join(',')}`, defaultHttpOptions(ignoreLoading, ignoreErrors)); + } + public getDeviceInfo(deviceId: string, ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable { return this.http.get(`/api/device/info/${deviceId}`, defaultHttpOptions(ignoreLoading, ignoreErrors)); } diff --git a/ui-ngx/src/app/core/http/entity.service.ts b/ui-ngx/src/app/core/http/entity.service.ts index c7bfdcc2ae..f0f22c50da 100644 --- a/ui-ngx/src/app/core/http/entity.service.ts +++ b/ui-ngx/src/app/core/http/entity.service.ts @@ -15,7 +15,7 @@ /// import {Injectable} from '@angular/core'; -import {Observable, throwError, of, empty, EMPTY} from 'rxjs/index'; +import {Observable, throwError, of, empty, EMPTY, forkJoin} from 'rxjs/index'; import {HttpClient} from '@angular/common/http'; import {PageLink} from '@shared/models/page/page-link'; import {EntityType} from '@shared/models/entity-type.models'; @@ -86,7 +86,6 @@ export class EntityService { } return observable; } - public getEntity(entityType: EntityType, entityId: string, ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable> { const entityObservable = this.getEntityObservable(entityType, entityId, ignoreErrors, ignoreLoading); @@ -97,6 +96,79 @@ export class EntityService { } } + private getEntitiesByIdsObservable(fetchEntityFunction: (entityId: string) => Observable>, + entityIds: Array): Observable>> { + const tasks: Observable>[] = []; + entityIds.forEach((entityId) => { + tasks.push(fetchEntityFunction(entityId)); + }); + return forkJoin(tasks).pipe( + map((entities) => { + if (entities) { + entities.sort((entity1, entity2) => { + const index1 = entityIds.indexOf(entity1.id.id); + const index2 = entityIds.indexOf(entity2.id.id); + return index1 - index2; + }); + return entities; + } else { + return []; + } + }) + ); + } + + + private getEntitiesObservable(entityType: EntityType, entityIds: Array, + ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable>> { + let observable: Observable>>; + switch (entityType) { + case EntityType.DEVICE: + observable = this.deviceService.getDevices(entityIds, ignoreErrors, ignoreLoading); + break; + case EntityType.ASSET: + // TODO: + break; + case EntityType.ENTITY_VIEW: + // TODO: + break; + case EntityType.TENANT: + observable = this.getEntitiesByIdsObservable( + (id) => this.tenantService.getTenant(id, ignoreErrors, ignoreLoading), + entityIds); + break; + case EntityType.CUSTOMER: + observable = this.getEntitiesByIdsObservable( + (id) => this.customerService.getCustomer(id, ignoreErrors, ignoreLoading), + entityIds); + break; + case EntityType.DASHBOARD: + observable = this.getEntitiesByIdsObservable( + (id) => this.dashboardService.getDashboardInfo(id, ignoreErrors, ignoreLoading), + entityIds); + break; + case EntityType.USER: + observable = this.getEntitiesByIdsObservable( + (id) => this.userService.getUser(id, ignoreErrors, ignoreLoading), + entityIds); + break; + case EntityType.ALARM: + console.error('Get Alarm Entity is not implemented!'); + break; + } + return observable; + } + + public getEntities(entityType: EntityType, entityIds: Array, + ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable>> { + const entitiesObservable = this.getEntitiesObservable(entityType, entityIds, ignoreErrors, ignoreLoading); + if (entitiesObservable) { + return entitiesObservable; + } else { + return throwError(null); + } + } + private getSingleTenantByPageLinkObservable(pageLink: PageLink, ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable> { diff --git a/ui-ngx/src/app/modules/home/dialogs/add-entities-to-customer-dialog.component.html b/ui-ngx/src/app/modules/home/dialogs/add-entities-to-customer-dialog.component.html new file mode 100644 index 0000000000..44efa5634b --- /dev/null +++ b/ui-ngx/src/app/modules/home/dialogs/add-entities-to-customer-dialog.component.html @@ -0,0 +1,58 @@ + +
+ +

{{ assignToCustomerTitle | translate }}

+ + +
+ + +
+
+
+ {{ assignToCustomerText | translate }} + + +
+
+
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/dialogs/add-entities-to-customer-dialog.component.ts b/ui-ngx/src/app/modules/home/dialogs/add-entities-to-customer-dialog.component.ts new file mode 100644 index 0000000000..19a4ac3be7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/dialogs/add-entities-to-customer-dialog.component.ts @@ -0,0 +1,118 @@ +/// +/// 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 {DeviceService} from '@core/http/device.service'; +import {EntityId} from '@shared/models/id/entity-id'; +import {EntityType} from '@shared/models/entity-type.models'; +import {forkJoin, Observable} from 'rxjs'; + +export interface AddEntitiesToCustomerDialogData { + customerId: string; + entityType: EntityType; +} + +@Component({ + selector: 'tb-add-entities-to-customer-dialog', + templateUrl: './add-entities-to-customer-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: AddEntitiesToCustomerDialogComponent}], + styleUrls: [] +}) +export class AddEntitiesToCustomerDialogComponent extends PageComponent implements OnInit, ErrorStateMatcher { + + addEntitiesToCustomerFormGroup: FormGroup; + + submitted = false; + + entityType: EntityType; + + assignToCustomerTitle: string; + assignToCustomerText: string; + + constructor(protected store: Store, + @Inject(MAT_DIALOG_DATA) public data: AddEntitiesToCustomerDialogData, + private deviceService: DeviceService, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef, + public fb: FormBuilder) { + super(store); + this.entityType = data.entityType; + } + + ngOnInit(): void { + this.addEntitiesToCustomerFormGroup = this.fb.group({ + entityIds: [null, [Validators.required]] + }); + switch (this.data.entityType) { + case EntityType.DEVICE: + this.assignToCustomerTitle = 'device.assign-device-to-customer'; + this.assignToCustomerText = 'device.assign-device-to-customer-text'; + break; + case EntityType.ASSET: + // TODO: + break; + case EntityType.ENTITY_VIEW: + // TODO: + break; + } + } + + 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); + } + + assign(): void { + this.submitted = true; + const entityIds: Array = this.addEntitiesToCustomerFormGroup.get('entityIds').value; + const tasks: Observable[] = []; + entityIds.forEach( + (entityId) => { + tasks.push(this.getAssignToCustomerTask(this.data.customerId, entityId)); + } + ); + forkJoin(tasks).subscribe( + () => { + this.dialogRef.close(true); + } + ); + } + + private getAssignToCustomerTask(customerId: string, entityId: string): Observable { + switch (this.data.entityType) { + case EntityType.DEVICE: + return this.deviceService.assignDeviceToCustomer(customerId, entityId); + break; + case EntityType.ASSET: + // TODO: + break; + case EntityType.ENTITY_VIEW: + // TODO: + break; + } + } + +} diff --git a/ui-ngx/src/app/modules/home/dialogs/home-dialogs.module.ts b/ui-ngx/src/app/modules/home/dialogs/home-dialogs.module.ts index de8d55263d..92dcf50231 100644 --- a/ui-ngx/src/app/modules/home/dialogs/home-dialogs.module.ts +++ b/ui-ngx/src/app/modules/home/dialogs/home-dialogs.module.ts @@ -18,21 +18,25 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { SharedModule } from '@app/shared/shared.module'; import {AssignToCustomerDialogComponent} from '@modules/home/dialogs/assign-to-customer-dialog.component'; +import {AddEntitiesToCustomerDialogComponent} from '@modules/home/dialogs/add-entities-to-customer-dialog.component'; @NgModule({ entryComponents: [ - AssignToCustomerDialogComponent + AssignToCustomerDialogComponent, + AddEntitiesToCustomerDialogComponent ], declarations: [ - AssignToCustomerDialogComponent + AssignToCustomerDialogComponent, + AddEntitiesToCustomerDialogComponent ], imports: [ CommonModule, SharedModule ], exports: [ - AssignToCustomerDialogComponent + AssignToCustomerDialogComponent, + AddEntitiesToCustomerDialogComponent ] }) export class HomeDialogsModule { } diff --git a/ui-ngx/src/app/modules/home/pages/customer/customer-routing.module.ts b/ui-ngx/src/app/modules/home/pages/customer/customer-routing.module.ts index e2a8c44359..317b894402 100644 --- a/ui-ngx/src/app/modules/home/pages/customer/customer-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/customer/customer-routing.module.ts @@ -21,6 +21,7 @@ import {EntitiesTableComponent} from '@shared/components/entity/entities-table.c import {Authority} from '@shared/models/authority.enum'; import {UsersTableConfigResolver} from '../user/users-table-config.resolver'; import {CustomersTableConfigResolver} from './customers-table-config.resolver'; +import {DevicesTableConfigResolver} from '@modules/home/pages/device/devices-table-config.resolver'; const routes: Routes = [ { @@ -57,6 +58,22 @@ const routes: Routes = [ resolve: { entitiesTableConfig: UsersTableConfigResolver } + }, + { + path: ':customerId/devices', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'customer.devices', + devicesType: 'customer', + breadcrumb: { + label: 'customer.devices', + icon: 'devices_other' + } + }, + resolve: { + entitiesTableConfig: DevicesTableConfigResolver + } } ] } diff --git a/ui-ngx/src/app/modules/home/pages/customer/customers-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/customer/customers-table-config.resolver.ts index 3eb876d0a4..fa558cf0c3 100644 --- a/ui-ngx/src/app/modules/home/pages/customer/customers-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/customer/customers-table-config.resolver.ts @@ -65,6 +65,39 @@ export class CustomersTableConfigResolver implements Resolve !customer.additionalInfo || !customer.additionalInfo.isPublic, onAction: ($event, entity) => this.manageCustomerUsers($event, entity) + }, + { + name: this.translate.instant('customer.manage-customer-assets'), + nameFunction: (customer) => { + return customer.additionalInfo && customer.additionalInfo.isPublic + ? this.translate.instant('customer.manage-public-assets') + : this.translate.instant('customer.manage-customer-assets'); + }, + icon: 'domain', + isEnabled: (customer) => true, + onAction: ($event, entity) => this.manageCustomerAssets($event, entity) + }, + { + name: this.translate.instant('customer.manage-customer-devices'), + nameFunction: (customer) => { + return customer.additionalInfo && customer.additionalInfo.isPublic + ? this.translate.instant('customer.manage-public-devices') + : this.translate.instant('customer.manage-customer-devices'); + }, + icon: 'devices_other', + isEnabled: (customer) => true, + onAction: ($event, entity) => this.manageCustomerDevices($event, entity) + }, + { + name: this.translate.instant('customer.manage-customer-dashboards'), + nameFunction: (customer) => { + return customer.additionalInfo && customer.additionalInfo.isPublic + ? this.translate.instant('customer.manage-public-dashboards') + : this.translate.instant('customer.manage-customer-dashboards'); + }, + icon: 'dashboard', + isEnabled: (customer) => true, + onAction: ($event, entity) => this.manageCustomerDashboards($event, entity) } ); @@ -78,6 +111,9 @@ export class CustomersTableConfigResolver implements Resolve this.customerService.saveCustomer(customer); this.config.deleteEntity = id => this.customerService.deleteCustomer(id.id); this.config.onEntityAction = action => this.onCustomerAction(action); + this.config.deleteEnabled = (customer) => customer && (!customer.additionalInfo || !customer.additionalInfo.isPublic); + this.config.entitySelectionEnabled = (customer) => customer && (!customer.additionalInfo || !customer.additionalInfo.isPublic); + this.config.detailsReadonly = (customer) => customer && customer.additionalInfo && customer.additionalInfo.isPublic; } resolve(): EntityTableConfig { @@ -93,6 +129,27 @@ export class CustomersTableConfigResolver implements Resolve): boolean { switch (action.action) { case 'manageUsers': diff --git a/ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts index a57777f318..eb7b1a808e 100644 --- a/ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts @@ -54,6 +54,10 @@ import { AssignToCustomerDialogData } from '@modules/home/dialogs/assign-to-customer-dialog.component'; import {DeviceId} from '@app/shared/models/id/device-id'; +import { + AddEntitiesToCustomerDialogComponent, + AddEntitiesToCustomerDialogData +} from '../../dialogs/add-entities-to-customer-dialog.component'; @Injectable() export class DevicesTableConfigResolver implements Resolve> { @@ -312,7 +316,20 @@ export class DevicesTableConfigResolver implements Resolve(AddEntitiesToCustomerDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + customerId: this.customerId, + entityType: EntityType.DEVICE + } + }).afterClosed() + .subscribe((res) => { + if (res) { + this.config.table.updateData(); + } + }); } makePublic($event: Event, device: Device) { diff --git a/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts b/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts index 41067c10e4..6b280bc40e 100644 --- a/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts @@ -29,6 +29,7 @@ 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 {coerceBooleanProperty} from '@angular/cdk/coercion'; @Component({ selector: 'tb-dashboard-autocomplete', @@ -64,8 +65,14 @@ export class DashboardAutocompleteComponent implements ControlValueAccessor, OnI @Input() customerId: string; + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } @Input() - required: boolean; + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } @Input() disabled: boolean; diff --git a/ui-ngx/src/app/shared/components/entity/entities-table-config.models.ts b/ui-ngx/src/app/shared/components/entity/entities-table-config.models.ts index f61dd04fb6..c3dbaf77ec 100644 --- a/ui-ngx/src/app/shared/components/entity/entities-table-config.models.ts +++ b/ui-ngx/src/app/shared/components/entity/entities-table-config.models.ts @@ -126,6 +126,7 @@ export class EntityTableConfig, P extends PageLink = P headerComponent: Type>; addEntity: CreateEntityOperation = null; detailsReadonly: EntityBooleanFunction = () => false; + entitySelectionEnabled: EntityBooleanFunction = () => true; deleteEnabled: EntityBooleanFunction = () => true; deleteEntityTitle: EntityStringFunction = () => ''; deleteEntityContent: EntityStringFunction = () => ''; diff --git a/ui-ngx/src/app/shared/components/entity/entities-table.component.html b/ui-ngx/src/app/shared/components/entity/entities-table.component.html index 89238df962..268ce79e41 100644 --- a/ui-ngx/src/app/shared/components/entity/entities-table.component.html +++ b/ui-ngx/src/app/shared/components/entity/entities-table.component.html @@ -145,6 +145,7 @@ diff --git a/ui-ngx/src/app/shared/components/entity/entities-table.component.ts b/ui-ngx/src/app/shared/components/entity/entities-table.component.ts index da9dc3331e..e21b048541 100644 --- a/ui-ngx/src/app/shared/components/entity/entities-table.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entities-table.component.ts @@ -173,6 +173,7 @@ export class EntitiesTableComponent extends PageComponent implements AfterViewIn } this.dataSource = new EntitiesDataSource>( this.entitiesTableConfig.entitiesFetchFunction, + this.entitiesTableConfig.entitySelectionEnabled, () => { this.dataLoaded(); } diff --git a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts index be51e544aa..d6830ee882 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts @@ -17,7 +17,7 @@ import {AfterViewInit, Component, ElementRef, forwardRef, Input, OnInit, ViewChild} from '@angular/core'; import {ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR} from '@angular/forms'; import {Observable} from 'rxjs'; -import {map, mergeMap, startWith, tap} from 'rxjs/operators'; +import {map, mergeMap, startWith, tap, share} from 'rxjs/operators'; import {Store} from '@ngrx/store'; import {AppState} from '@app/core/core.state'; import {TranslateService} from '@ngx-translate/core'; @@ -25,6 +25,7 @@ import {AliasEntityType, EntityType} from '@shared/models/entity-type.models'; import {BaseData} from '@shared/models/base-data'; import {EntityId} from '@shared/models/id/entity-id'; import {EntityService} from '@core/http/entity.service'; +import {coerceBooleanProperty} from '@angular/cdk/coercion'; @Component({ selector: 'tb-entity-autocomplete', @@ -70,8 +71,14 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit @Input() excludeEntityIds: Array; + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } @Input() - required: boolean; + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } @Input() disabled: boolean; @@ -115,10 +122,14 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit modelValue = value.id.id; } this.updateView(modelValue); + if (value === null) { + this.clear(); + } }), startWith>(''), map(value => value ? (typeof value === 'string' ? value : value.name) : ''), - mergeMap(name => this.fetchEntities(name) ) + mergeMap(name => this.fetchEntities(name) ), + share() ); } @@ -216,12 +227,12 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit ); } else { this.modelValue = null; - this.selectEntityFormGroup.get('entity').patchValue(null, {emitEvent: true}); + this.selectEntityFormGroup.get('entity').patchValue('', {emitEvent: true}); } } reset() { - this.selectEntityFormGroup.get('entity').patchValue(null, {emitEvent: true}); + this.selectEntityFormGroup.get('entity').patchValue('', {emitEvent: true}); } updateView(value: string | null) { @@ -264,7 +275,7 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit } clear() { - this.selectEntityFormGroup.get('entity').patchValue(null, {emitEvent: true}); + this.selectEntityFormGroup.get('entity').patchValue('', {emitEvent: true}); setTimeout(() => { this.entityInput.nativeElement.blur(); this.entityInput.nativeElement.focus(); 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 new file mode 100644 index 0000000000..d35ed12b64 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.html @@ -0,0 +1,48 @@ + + + + + {{entity.name}} + close + + + + + + + + + + {{ translate.get('entity.no-entities-matching', {entity: searchText}) | async }} + + + + + {{ '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 new file mode 100644 index 0000000000..e8bb92fb83 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.ts @@ -0,0 +1,202 @@ +/// +/// 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, SkipSelf, ViewChild} from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + FormControl, + FormGroup, + FormGroupDirective, + NG_VALUE_ACCESSOR, NgForm +} from '@angular/forms'; +import {Observable} 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'; +import {TranslateService} from '@ngx-translate/core'; +import {AliasEntityType, EntityType} from '@shared/models/entity-type.models'; +import {BaseData} from '@shared/models/base-data'; +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'; + +@Component({ + selector: 'tb-entity-list', + templateUrl: './entity-list.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityListComponent), + multi: true + } + ] +}) +export class EntityListComponent implements ControlValueAccessor, OnInit, AfterViewInit { + + entityListFormGroup: FormGroup; + + modelValue: Array | null; + + entityTypeValue: EntityType; + + @Input() + set entityType(entityType: EntityType) { + if (this.entityTypeValue !== entityType) { + this.entityTypeValue = entityType; + this.reset(); + } + } + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + @ViewChild('entityInput', {static: false}) entityInput: ElementRef; + @ViewChild('entityAutocomplete', {static: false}) matAutocomplete: MatAutocomplete; + @ViewChild('chipList', {static: false}) chipList: MatChipList; + + entities: Array> = []; + filteredEntities: Observable>>; + + private searchText = ''; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + public translate: TranslateService, + private entityService: EntityService, + private fb: FormBuilder) { + this.entityListFormGroup = this.fb.group({ + entity: [null] + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + this.filteredEntities = this.entityListFormGroup.get('entity').valueChanges + .pipe( + startWith>(''), + tap((value) => { + if (value && typeof value !== 'string') { + this.add(value); + } else if (value === null) { + this.clear(this.entityInput.nativeElement.value); + } + }), + filter((value) => typeof value === 'string'), + map((value) => value ? (typeof value === 'string' ? value : value.name) : ''), + mergeMap(name => this.fetchEntities(name) ), + share() + ); + } + + ngAfterViewInit(): void {} + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(value: Array | null): void { + this.searchText = ''; + if (value != null) { + this.modelValue = value; + this.entityService.getEntities(this.entityTypeValue, value).subscribe( + (entities) => { + this.entities = entities; + } + ); + } else { + this.entities = []; + this.modelValue = null; + } + } + + reset() { + this.entities = []; + this.modelValue = null; + this.entityListFormGroup.get('entity').patchValue('', {emitEvent: true}); + this.propagateChange(this.modelValue); + } + + add(entity: BaseData): void { + if (!this.modelValue || this.modelValue.indexOf(entity.id.id) === -1) { + if (!this.modelValue) { + this.modelValue = []; + } + this.modelValue.push(entity.id.id); + this.entities.push(entity); + if (this.required) { + this.chipList.errorState = false; + } + } + this.propagateChange(this.modelValue); + this.clear(); + } + + remove(entity: BaseData) { + const index = this.entities.indexOf(entity); + if (index >= 0) { + this.entities.splice(index, 1); + 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(); + } + } + + displayEntityFn(entity?: BaseData): string | undefined { + return entity ? entity.name : undefined; + } + + fetchEntities(searchText?: string): Observable>> { + this.searchText = searchText; + return this.entityService.getEntitiesByNameFilter(this.entityTypeValue, searchText, + 50, '', false, true).pipe( + map((data) => data ? data : [])); + } + + clear(value: string = '') { + this.entityInput.nativeElement.value = value; + this.entityListFormGroup.get('entity').patchValue(value, {emitEvent: true}); + setTimeout(() => { + this.entityInput.nativeElement.blur(); + this.entityInput.nativeElement.focus(); + }, 0); + } + +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-subtype-autocomplete.component.ts b/ui-ngx/src/app/shared/components/entity/entity-subtype-autocomplete.component.ts index 34044a45b2..b2d2e3b185 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-subtype-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-subtype-autocomplete.component.ts @@ -32,6 +32,7 @@ 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'; @Component({ selector: 'tb-entity-subtype-autocomplete', @@ -52,8 +53,14 @@ export class EntitySubTypeAutocompleteComponent implements ControlValueAccessor, @Input() entityType: EntityType; + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } @Input() - required: boolean; + set required(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } @Input() disabled: boolean; diff --git a/ui-ngx/src/app/shared/models/datasource/entity-datasource.ts b/ui-ngx/src/app/shared/models/datasource/entity-datasource.ts index ecfdc755f6..a61677f668 100644 --- a/ui-ngx/src/app/shared/models/datasource/entity-datasource.ts +++ b/ui-ngx/src/app/shared/models/datasource/entity-datasource.ts @@ -22,6 +22,7 @@ import { BaseData, HasId } from '@shared/models/base-data'; import { CollectionViewer, DataSource } from '@angular/cdk/typings/collections'; import { catchError, map, take, tap } from 'rxjs/operators'; import { SelectionModel } from '@angular/cdk/collections'; +import {EntityBooleanFunction} from '@shared/components/entity/entities-table-config.models'; export type EntitiesFetchFunction, P extends PageLink> = (pageLink: P) => Observable>; @@ -37,6 +38,7 @@ export class EntitiesDataSource, P extends PageLink = public currentEntity: T = null; constructor(private fetchFunction: EntitiesFetchFunction, + private selectionEnabledFunction: EntityBooleanFunction, private dataLoadedFunction: () => void) {} connect(collectionViewer: CollectionViewer): Observable> { @@ -69,7 +71,7 @@ export class EntitiesDataSource, P extends PageLink = isAllSelected(): Observable { const numSelected = this.selection.selected.length; return this.entitiesSubject.pipe( - map((entities) => numSelected === entities.length) + map((entities) => numSelected === this.selectableEntitiesCount(entities)) ); } @@ -103,13 +105,21 @@ export class EntitiesDataSource, P extends PageLink = this.entitiesSubject.pipe( tap((entities) => { const numSelected = this.selection.selected.length; - if (numSelected === entities.length) { + if (numSelected === this.selectableEntitiesCount(entities)) { this.selection.clear(); } else { - entities.forEach(row => this.selection.select(row)); + entities.forEach(row => { + if (this.selectionEnabledFunction(row)) { + this.selection.select(row); + } + }); } }), take(1) ).subscribe(); } + + private selectableEntitiesCount(entities: Array): number { + return entities.filter((entity) => this.selectionEnabledFunction(entity)).length; + } } diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index d309519b1b..23d19c39a5 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -47,7 +47,9 @@ import { MatDatepickerModule, MatSliderModule, MatExpansionModule, - MatStepperModule, MatAutocompleteModule + MatStepperModule, + MatAutocompleteModule, + MatChipsModule } from '@angular/material'; import { MatDatetimepickerModule, MatNativeDatetimeModule } from '@mat-datetimepicker/core'; import { FlexLayoutModule } from '@angular/flex-layout'; @@ -81,6 +83,7 @@ import {DashboardAutocompleteComponent} from '@shared/components/dashboard-autoc import {EntitySubTypeAutocompleteComponent} from '@shared/components/entity/entity-subtype-autocomplete.component'; import {EntitySubTypeSelectComponent} from './components/entity/entity-subtype-select.component'; import {EntityAutocompleteComponent} from './components/entity/entity-autocomplete.component'; +import {EntityListComponent} from '@shared/components/entity/entity-list.component'; @NgModule({ providers: [ @@ -124,6 +127,7 @@ import {EntityAutocompleteComponent} from './components/entity/entity-autocomple EntitySubTypeAutocompleteComponent, EntitySubTypeSelectComponent, EntityAutocompleteComponent, + EntityListComponent, NospacePipe, MillisecondsToTimeStringPipe, EnumToArrayPipe, @@ -162,6 +166,7 @@ import {EntityAutocompleteComponent} from './components/entity/entity-autocomple MatExpansionModule, MatStepperModule, MatAutocompleteModule, + MatChipsModule, ClipboardModule, FlexLayoutModule.withConfig({addFlexToParent: false}), FormsModule, @@ -192,6 +197,7 @@ import {EntityAutocompleteComponent} from './components/entity/entity-autocomple EntitySubTypeAutocompleteComponent, EntitySubTypeSelectComponent, EntityAutocompleteComponent, + EntityListComponent, // ValueInputComponent, MatButtonModule, MatCheckboxModule, @@ -222,6 +228,7 @@ import {EntityAutocompleteComponent} from './components/entity/entity-autocomple MatExpansionModule, MatStepperModule, MatAutocompleteModule, + MatChipsModule, ClipboardModule, FlexLayoutModule, FormsModule,