From d987c864564cc5fc9dee6b47e85389ccf0139d2e Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 10 Oct 2019 13:00:29 +0300 Subject: [PATCH] UI: Widget Config. Entity aliases dialog. --- ui-ngx/package-lock.json | 18 +- ui-ngx/package.json | 1 + ui-ngx/src/app/core/http/asset.service.ts | 8 +- ui-ngx/src/app/core/http/device.service.ts | 7 +- .../src/app/core/http/entity-view.service.ts | 8 +- ui-ngx/src/app/core/http/entity.service.ts | 353 +++++++++++++++++- .../core/services/dashboard-utils.service.ts | 11 + ui-ngx/src/app/core/services/utils.service.ts | 64 +++- ui-ngx/src/app/core/utils.ts | 17 + .../alias/entity-alias-dialog.component.html | 76 ++++ .../alias/entity-alias-dialog.component.scss | 28 ++ .../alias/entity-alias-dialog.component.ts | 164 ++++++++ .../entity-aliases-dialog.component.html | 111 ++++++ .../entity-aliases-dialog.component.scss | 49 +++ .../alias/entity-aliases-dialog.component.ts | 264 +++++++++++++ .../entity/entity-filter-view.component.html | 25 ++ .../entity/entity-filter-view.component.scss | 36 ++ .../entity/entity-filter-view.component.ts | 230 ++++++++++++ .../entity/entity-filter.component.html | 244 ++++++++++++ .../entity/entity-filter.component.scss | 38 ++ .../entity/entity-filter.component.ts | 218 +++++++++++ .../home/components/home-components.module.ts | 23 +- .../relation/relation-filters.component.html | 66 ++++ .../relation/relation-filters.component.scss | 67 ++++ .../relation/relation-filters.component.ts | 123 ++++++ .../widget/widget-config.component.html | 113 +++++- .../widget/widget-config.component.scss | 3 + .../widget/widget-config.component.ts | 286 ++++++++++++-- .../home/models/widget-component.models.ts | 1 + .../dashboard/dashboard-page.component.html | 4 +- .../dashboard/dashboard-page.component.scss | 9 +- .../dashboard/dashboard-page.component.ts | 35 +- .../dashboard/edit-widget.component.html | 1 - .../pages/dashboard/edit-widget.component.ts | 7 +- .../entity/entity-list-select.component.scss | 5 - .../entity/entity-list.component.scss | 22 ++ .../entity/entity-list.component.ts | 32 +- .../entity/entity-subtype-list.component.html | 57 +++ .../entity/entity-subtype-list.component.scss | 22 ++ .../entity/entity-subtype-list.component.ts | 317 ++++++++++++++++ .../entity/entity-type-list.component.html | 54 +++ .../entity/entity-type-list.component.ts | 262 +++++++++++++ .../entity/entity-type-select.component.html | 2 +- .../entity/entity-type-select.component.scss | 10 + .../entity/entity-type-select.component.ts | 8 +- .../shared/components/tb-error.component.ts | 67 ++++ ui-ngx/src/app/shared/models/alias.models.ts | 96 ++++- ui-ngx/src/app/shared/models/asset.models.ts | 5 + ui-ngx/src/app/shared/models/device.models.ts | 5 + .../app/shared/models/entity-view.models.ts | 5 + .../src/app/shared/models/relation.models.ts | 9 +- ui-ngx/src/app/shared/shared.module.ts | 9 + ui-ngx/src/styles.scss | 18 + 53 files changed, 3607 insertions(+), 106 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.html create mode 100644 ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html create mode 100644 ui-ngx/src/app/modules/home/components/entity/entity-filter.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/entity/entity-filter.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/relation/relation-filters.component.html create mode 100644 ui-ngx/src/app/modules/home/components/relation/relation-filters.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/relation/relation-filters.component.ts create mode 100644 ui-ngx/src/app/shared/components/entity/entity-list.component.scss create mode 100644 ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.html create mode 100644 ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.scss create mode 100644 ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts create mode 100644 ui-ngx/src/app/shared/components/entity/entity-type-list.component.html create mode 100644 ui-ngx/src/app/shared/components/entity/entity-type-list.component.ts create mode 100644 ui-ngx/src/app/shared/components/tb-error.component.ts diff --git a/ui-ngx/package-lock.json b/ui-ngx/package-lock.json index 388d4cb64e..d3bf2add95 100644 --- a/ui-ngx/package-lock.json +++ b/ui-ngx/package-lock.json @@ -1622,7 +1622,6 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, "requires": { "sprintf-js": "~1.0.2" } @@ -5308,9 +5307,9 @@ "dev": true }, "handlebars": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.1.2.tgz", - "integrity": "sha512-nvfrjqvt9xQ8Z/w0ijewdD/vvWDTOweBUm96NTr66Wfvo1mJenBLwcYmPs3TIBP5ruzYGD7Hx/DaM9RmhroGPw==", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.4.2.tgz", + "integrity": "sha512-cIv17+GhL8pHHnRJzGu2wwcthL5sb8uDKBHvZ2Dtu5s1YNt0ljbzKbamnc+gr69y7bzwQiBdr5+hOpRd5pnOdg==", "dev": true, "requires": { "neo-async": "^2.6.0", @@ -6532,6 +6531,14 @@ "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", "dev": true }, + "json-schema-defaults": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema-defaults/-/json-schema-defaults-0.4.0.tgz", + "integrity": "sha512-UsUrkDVNvHTneyeQOYHH9ZHb3+6OjwYfJ831SdO0yjtXtYZ7Jh8BKWsuJYUQW7qckP5JhHawsg4GI6A5fMaR/Q==", + "requires": { + "argparse": "^1.0.9" + } + }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -9989,8 +9996,7 @@ "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", - "dev": true + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" }, "sshpk": { "version": "1.16.1", diff --git a/ui-ngx/package.json b/ui-ngx/package.json index 8527312a0f..f6ce518a8a 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -46,6 +46,7 @@ "jquery": "^3.4.1", "jquery.terminal": "^2.8.0", "js-beautify": "^1.10.2", + "json-schema-defaults": "^0.4.0", "material-design-icons": "^3.0.1", "messageformat": "^2.3.0", "moment": "^2.24.0", diff --git a/ui-ngx/src/app/core/http/asset.service.ts b/ui-ngx/src/app/core/http/asset.service.ts index e7cd9af761..079a4b500d 100644 --- a/ui-ngx/src/app/core/http/asset.service.ts +++ b/ui-ngx/src/app/core/http/asset.service.ts @@ -21,7 +21,8 @@ import {HttpClient} from '@angular/common/http'; import {PageLink} from '@shared/models/page/page-link'; import {PageData} from '@shared/models/page/page-data'; import {EntitySubtype} from '@app/shared/models/entity-type.models'; -import {Asset, AssetInfo} from '@app/shared/models/asset.models'; +import {Asset, AssetInfo, AssetSearchQuery} from '@app/shared/models/asset.models'; +import { Device, DeviceSearchQuery } from '@shared/models/device.models'; @Injectable({ providedIn: 'root' @@ -81,4 +82,9 @@ export class AssetService { return this.http.delete(`/api/customer/asset/${assetId}`, defaultHttpOptions(ignoreLoading, ignoreErrors)); } + public findByQuery(query: AssetSearchQuery, + ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable> { + return this.http.post>('/api/assets', query, defaultHttpOptions(ignoreLoading, ignoreErrors)); + } + } diff --git a/ui-ngx/src/app/core/http/device.service.ts b/ui-ngx/src/app/core/http/device.service.ts index adc4bf0ac2..d1b0a48464 100644 --- a/ui-ngx/src/app/core/http/device.service.ts +++ b/ui-ngx/src/app/core/http/device.service.ts @@ -23,7 +23,7 @@ import { PageData } from '@shared/models/page/page-data'; import { Tenant } from '@shared/models/tenant.model'; import {DashboardInfo, Dashboard} from '@shared/models/dashboard.models'; import {map} from 'rxjs/operators'; -import {DeviceInfo, Device, DeviceCredentials} from '@app/shared/models/device.models'; +import { DeviceInfo, Device, DeviceCredentials, DeviceSearchQuery } from '@app/shared/models/device.models'; import {EntitySubtype} from '@app/shared/models/entity-type.models'; import {AuthService} from '../auth/auth.service'; @@ -115,4 +115,9 @@ export class DeviceService { return this.http.delete(`/api/customer/device/${deviceId}`, defaultHttpOptions(ignoreLoading, ignoreErrors)); } + public findByQuery(query: DeviceSearchQuery, + ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable> { + return this.http.post>('/api/devices', query, defaultHttpOptions(ignoreLoading, ignoreErrors)); + } + } diff --git a/ui-ngx/src/app/core/http/entity-view.service.ts b/ui-ngx/src/app/core/http/entity-view.service.ts index c22c2e0d74..353eae5ed0 100644 --- a/ui-ngx/src/app/core/http/entity-view.service.ts +++ b/ui-ngx/src/app/core/http/entity-view.service.ts @@ -21,7 +21,8 @@ import {HttpClient} from '@angular/common/http'; import {PageLink} from '@shared/models/page/page-link'; import {PageData} from '@shared/models/page/page-data'; import {EntitySubtype} from '@app/shared/models/entity-type.models'; -import {EntityView, EntityViewInfo} from '@app/shared/models/entity-view.models'; +import { EntityView, EntityViewInfo, EntityViewSearchQuery } from '@app/shared/models/entity-view.models'; +import { Asset, AssetSearchQuery } from '@shared/models/asset.models'; @Injectable({ providedIn: 'root' @@ -80,4 +81,9 @@ export class EntityViewService { return this.http.delete(`/api/customer/entityView/${entityViewId}`, defaultHttpOptions(ignoreLoading, ignoreErrors)); } + public findByQuery(query: EntityViewSearchQuery, + ignoreErrors: boolean = false, ignoreLoading: boolean = false): Observable> { + return this.http.post>('/api/entityViews', query, 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 c37f3ecefe..39b07b8fc0 100644 --- a/ui-ngx/src/app/core/http/entity.service.ts +++ b/ui-ngx/src/app/core/http/entity.service.ts @@ -33,16 +33,29 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { Authority } from '@shared/models/authority.enum'; import { Tenant } from '@shared/models/tenant.model'; -import { catchError, concatMap, expand, map, toArray } from 'rxjs/operators'; +import { catchError, concatMap, expand, map, mergeMap, toArray } from 'rxjs/operators'; import { Customer } from '@app/shared/models/customer.model'; import { AssetService } from '@core/http/asset.service'; import { EntityViewService } from '@core/http/entity-view.service'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { defaultHttpOptions } from '@core/http/http-utils'; import { RuleChainService } from '@core/http/rule-chain.service'; -import { SubscriptionInfo } from '@core/api/widget-api.models'; +import { StateParams, SubscriptionInfo } from '@core/api/widget-api.models'; import { Datasource, DatasourceType, KeyInfo } from '@app/shared/models/widget.models'; import { UtilsService } from '@core/services/utils.service'; +import { AliasFilterType, EntityAliasFilter, EntityAliasFilterResult } from '@shared/models/alias.models'; +import { EntityInfo } from '@shared/models/entity.models'; +import { + EntityRelationInfo, + EntityRelationsQuery, + EntitySearchDirection, + EntitySearchQuery +} from '@shared/models/relation.models'; +import { EntityRelationService } from '@core/http/entity-relation.service'; +import { isDefined } from '../utils'; +import { AssetSearchQuery } from '@shared/models/asset.models'; +import { DeviceSearchQuery } from '@shared/models/device.models'; +import { EntityViewSearchQuery } from '@shared/models/entity-view.models'; @Injectable({ providedIn: 'root' @@ -60,6 +73,7 @@ export class EntityService { private userService: UserService, private ruleChainService: RuleChainService, private dashboardService: DashboardService, + private entityRelationService: EntityRelationService, private utils: UtilsService ) { } @@ -348,8 +362,62 @@ export class EntityService { } } + public getAliasFilterTypesByEntityTypes(entityTypes: Array): Array { + const allAliasFilterTypes: Array = Object.keys(AliasFilterType).map((key) => AliasFilterType[key]); + if (!entityTypes || !entityTypes.length) { + return allAliasFilterTypes; + } + const result = []; + for (const aliasFilterType of allAliasFilterTypes) { + if (this.filterAliasFilterTypeByEntityTypes(aliasFilterType, entityTypes)) { + result.push(aliasFilterType); + } + } + return result; + } + + private filterAliasFilterTypeByEntityTypes(aliasFilterType: AliasFilterType, + entityTypes: Array): boolean { + if (!entityTypes || !entityTypes.length) { + return true; + } + let valid = false; + entityTypes.forEach((entityType) => { + valid = valid || this.filterAliasFilterTypeByEntityType(aliasFilterType, entityType); + }); + return valid; + } + + private filterAliasFilterTypeByEntityType(aliasFilterType: AliasFilterType, entityType: EntityType | AliasEntityType): boolean { + switch (aliasFilterType) { + case AliasFilterType.singleEntity: + return true; + case AliasFilterType.entityList: + return true; + case AliasFilterType.entityName: + return true; + case AliasFilterType.stateEntity: + return true; + case AliasFilterType.assetType: + return entityType === EntityType.ASSET; + case AliasFilterType.deviceType: + return entityType === EntityType.DEVICE; + case AliasFilterType.entityViewType: + return entityType === EntityType.ENTITY_VIEW; + case AliasFilterType.relationsQuery: + return true; + case AliasFilterType.assetSearchQuery: + return entityType === EntityType.ASSET; + case AliasFilterType.deviceSearchQuery: + return entityType === EntityType.DEVICE; + case AliasFilterType.entityViewSearchQuery: + return entityType === EntityType.ENTITY_VIEW; + } + return false; + } + public prepareAllowedEntityTypesList(allowedEntityTypes: Array, - useAliasEntityTypes: boolean): Array { + useAliasEntityTypes?: boolean): Array { const authUser = getCurrentAuthUser(this.store); const entityTypes: Array = []; switch (authUser.authority) { @@ -444,6 +512,285 @@ export class EntityService { } } + public resolveAliasFilter(filter: EntityAliasFilter, stateParams: StateParams, + maxItems: number, failOnEmpty: boolean): Observable { + const result: EntityAliasFilterResult = { + entities: [], + stateEntity: false + }; + if (filter.stateEntityParamName && filter.stateEntityParamName.length) { + result.entityParamName = filter.stateEntityParamName; + } + const stateEntityId = this.getStateEntityId(filter, stateParams); + switch (filter.type) { + case AliasFilterType.singleEntity: + const aliasEntityId = this.resolveAliasEntityId(filter.singleEntity.entityType, filter.singleEntity.id); + return this.getEntity(aliasEntityId.entityType as EntityType, aliasEntityId.id, true, true).pipe( + map((entity) => { + result.entities = this.entitiesToEntitiesInfo([entity]); + return result; + } + )); + break; + case AliasFilterType.entityList: + return this.getEntities(filter.entityType, filter.entityList, true, true).pipe( + map((entities) => { + if (entities && entities.length || !failOnEmpty) { + result.entities = this.entitiesToEntitiesInfo(entities); + return result; + } else { + throw new Error(); + } + } + )); + break; + case AliasFilterType.entityName: + return this.getEntitiesByNameFilter(filter.entityType, filter.entityNameFilter, maxItems, + '', true, true).pipe( + map((entities) => { + if (entities && entities.length || !failOnEmpty) { + result.entities = this.entitiesToEntitiesInfo(entities); + return result; + } else { + throw new Error(); + } + } + ) + ); + break; + case AliasFilterType.stateEntity: + result.stateEntity = true; + if (stateEntityId) { + return this.getEntity(stateEntityId.entityType as EntityType, stateEntityId.id, true, true).pipe( + map((entity) => { + result.entities = this.entitiesToEntitiesInfo([entity]); + return result; + } + )); + } else { + return of(result); + } + break; + case AliasFilterType.assetType: + return this.getEntitiesByNameFilter(EntityType.ASSET, filter.assetNameFilter, maxItems, + filter.assetType, true, true).pipe( + map((entities) => { + if (entities && entities.length || !failOnEmpty) { + result.entities = this.entitiesToEntitiesInfo(entities); + return result; + } else { + throw new Error(); + } + } + ) + ); + break; + case AliasFilterType.deviceType: + return this.getEntitiesByNameFilter(EntityType.DEVICE, filter.deviceNameFilter, maxItems, + filter.deviceType, true, true).pipe( + map((entities) => { + if (entities && entities.length || !failOnEmpty) { + result.entities = this.entitiesToEntitiesInfo(entities); + return result; + } else { + throw new Error(); + } + } + ) + ); + break; + case AliasFilterType.entityViewType: + return this.getEntitiesByNameFilter(EntityType.ENTITY_VIEW, filter.entityViewNameFilter, maxItems, + filter.entityViewType, true, true).pipe( + map((entities) => { + if (entities && entities.length || !failOnEmpty) { + result.entities = this.entitiesToEntitiesInfo(entities); + return result; + } else { + throw new Error(); + } + } + ) + ); + break; + case AliasFilterType.relationsQuery: + result.stateEntity = filter.rootStateEntity; + let rootEntityType; + let rootEntityId; + if (result.stateEntity && stateEntityId) { + rootEntityType = stateEntityId.entityType; + rootEntityId = stateEntityId.id; + } else if (!result.stateEntity) { + rootEntityType = filter.rootEntity.entityType; + rootEntityId = filter.rootEntity.id; + } + if (rootEntityType && rootEntityId) { + const relationQueryRootEntityId = this.resolveAliasEntityId(rootEntityType, rootEntityId); + const searchQuery: EntityRelationsQuery = { + parameters: { + rootId: relationQueryRootEntityId.id, + rootType: relationQueryRootEntityId.entityType as EntityType, + direction: filter.direction + }, + filters: filter.filters + }; + searchQuery.parameters.maxLevel = filter.maxLevel && filter.maxLevel > 0 ? filter.maxLevel : -1; + return this.entityRelationService.findInfoByQuery(searchQuery, true, true).pipe( + mergeMap((allRelations) => { + if (allRelations && allRelations.length || !failOnEmpty) { + if (isDefined(maxItems) && maxItems > 0 && allRelations) { + const limit = Math.min(allRelations.length, maxItems); + allRelations.length = limit; + } + return this.entityRelationInfosToEntitiesInfo(allRelations, filter.direction).pipe( + map((entities) => { + result.entities = entities; + return result; + }) + ); + } else { + return throwError(null); + } + }) + ); + } else { + return of(result); + } + break; + case AliasFilterType.assetSearchQuery: + case AliasFilterType.deviceSearchQuery: + case AliasFilterType.entityViewSearchQuery: + result.stateEntity = filter.rootStateEntity; + if (result.stateEntity && stateEntityId) { + rootEntityType = stateEntityId.entityType; + rootEntityId = stateEntityId.id; + } else if (!result.stateEntity) { + rootEntityType = filter.rootEntity.entityType; + rootEntityId = filter.rootEntity.id; + } + if (rootEntityType && rootEntityId) { + const searchQueryRootEntityId = this.resolveAliasEntityId(rootEntityType, rootEntityId); + const searchQuery: EntitySearchQuery = { + parameters: { + rootId: searchQueryRootEntityId.id, + rootType: searchQueryRootEntityId.entityType as EntityType, + direction: filter.direction + }, + relationType: filter.relationType + }; + let findByQueryObservable: Observable>>; + if (filter.type === AliasFilterType.assetSearchQuery) { + const assetSearchQuery = searchQuery as AssetSearchQuery; + assetSearchQuery.assetTypes = filter.assetTypes; + findByQueryObservable = this.assetService.findByQuery(assetSearchQuery, true, true); + } else if (filter.type === AliasFilterType.deviceSearchQuery) { + const deviceSearchQuery = searchQuery as DeviceSearchQuery; + deviceSearchQuery.deviceTypes = filter.deviceTypes; + findByQueryObservable = this.deviceService.findByQuery(deviceSearchQuery, true, true); + } else if (filter.type === AliasFilterType.entityViewSearchQuery) { + const entityViewSearchQuery = searchQuery as EntityViewSearchQuery; + entityViewSearchQuery.entityViewTypes = filter.entityViewTypes; + findByQueryObservable = this.entityViewService.findByQuery(entityViewSearchQuery, true, true); + } + return findByQueryObservable.pipe( + map((entities) => { + if (entities && entities.length || !failOnEmpty) { + if (isDefined(maxItems) && maxItems > 0 && entities) { + const limit = Math.min(entities.length, maxItems); + entities.length = limit; + } + result.entities = this.entitiesToEntitiesInfo(entities); + return result; + } else { + throw Error(); + } + }) + ); + } else { + return of(result); + } + break; + } + } + + private entitiesToEntitiesInfo(entities: Array>): Array { + const entitiesInfo = []; + if (entities) { + entities.forEach((entity) => { + entitiesInfo.push(this.entityToEntityInfo(entity)); + }); + } + return entitiesInfo; + } + + private entityToEntityInfo(entity: BaseData): EntityInfo { + return { + origEntity: entity, + name: entity.name, + label: (entity as any).label ? (entity as any).label : '', + entityType: entity.id.entityType as EntityType, + id: entity.id.id, + entityDescription: (entity as any).additionalInfo ? (entity as any).additionalInfo.description : '' + }; + } + + private entityRelationInfosToEntitiesInfo(entityRelations: Array, + direction: EntitySearchDirection): Observable> { + if (entityRelations) { + const tasks: Observable[] = []; + entityRelations.forEach((entityRelation) => { + tasks.push(this.entityRelationInfoToEntityInfo(entityRelation, direction)); + }); + return forkJoin(tasks); + } else { + return of([]); + } + } + + private entityRelationInfoToEntityInfo(entityRelationInfo: EntityRelationInfo, direction: EntitySearchDirection): Observable { + const entityId = direction === EntitySearchDirection.FROM ? entityRelationInfo.to : entityRelationInfo.from; + return this.getEntity(entityId.entityType as EntityType, entityId.id, true, true).pipe( + map((entity) => { + return this.entityToEntityInfo(entity); + }) + ); + } + + private getStateEntityId(filter: EntityAliasFilter, stateParams: StateParams): EntityId { + let entityId = null; + if (stateParams) { + if (filter.stateEntityParamName && filter.stateEntityParamName.length) { + if (stateParams[filter.stateEntityParamName]) { + entityId = stateParams[filter.stateEntityParamName].entityId; + } + } else { + entityId = stateParams.entityId; + } + } + if (!entityId) { + entityId = filter.defaultStateEntity; + } + if (entityId) { + entityId = this.resolveAliasEntityId(entityId.entityType, entityId.id); + } + return entityId; + } + + private resolveAliasEntityId(entityType: EntityType | AliasEntityType, id: string): EntityId { + const entityId: EntityId = { + entityType, + id + }; + if (entityType === AliasEntityType.CURRENT_CUSTOMER) { + const authUser = getCurrentAuthUser(this.store); + entityId.entityType = EntityType.CUSTOMER; + if (authUser.authority === Authority.CUSTOMER_USER) { + entityId.id = authUser.customerId; + } + } + return entityId; + } + private createDatasourcesFromSubscriptionInfo(subscriptionInfo: SubscriptionInfo): Observable> { subscriptionInfo = this.validateSubscriptionInfo(subscriptionInfo); if (subscriptionInfo.type === DatasourceType.entity) { diff --git a/ui-ngx/src/app/core/services/dashboard-utils.service.ts b/ui-ngx/src/app/core/services/dashboard-utils.service.ts index cafeaccd2f..9ae6eac9f5 100644 --- a/ui-ngx/src/app/core/services/dashboard-utils.service.ts +++ b/ui-ngx/src/app/core/services/dashboard-utils.service.ts @@ -278,6 +278,17 @@ export class DashboardUtilsService { } } + public getWidgetsArray(dashboard: Dashboard): Array { + const widgetsArray: Array = []; + const dashboardConfiguration = dashboard.configuration; + const widgets = dashboardConfiguration.widgets; + for (const widgetId of Object.keys(widgets)) { + const widget = widgets[widgetId]; + widgetsArray.push(widget); + } + return widgetsArray; + } + private validateAndUpdateEntityAliases(configuration: DashboardConfiguration, datasourcesByAliasId: {[aliasId: string]: Array}, targetDevicesByAliasId: {[aliasId: string]: Array>}): DashboardConfiguration { diff --git a/ui-ngx/src/app/core/services/utils.service.ts b/ui-ngx/src/app/core/services/utils.service.ts index c3b1f2896c..89afecc718 100644 --- a/ui-ngx/src/app/core/services/utils.service.ts +++ b/ui-ngx/src/app/core/services/utils.service.ts @@ -17,19 +17,39 @@ import { Inject, Injectable } from '@angular/core'; import { WINDOW } from '@core/services/window.service'; import { ExceptionData } from '@app/shared/models/error.models'; -import { isUndefined, isDefined, deepClone } from '@core/utils'; +import { deepClone, isDefined, isUndefined, deleteNullProperties } from '@core/utils'; import { WindowMessage } from '@shared/models/window-message.model'; import { TranslateService } from '@ngx-translate/core'; import { customTranslationsPrefix } from '@app/shared/models/constants'; -import { DataKey, Datasource, DatasourceType, KeyInfo, Widget } from '@shared/models/widget.models'; +import { DataKey, Datasource, DatasourceType, KeyInfo } from '@shared/models/widget.models'; import { EntityType } from '@shared/models/entity-type.models'; import { DataKeyType } from '@app/shared/models/telemetry/telemetry.models'; import { alarmFields } from '@shared/models/alarm.models'; import { materialColors } from '@app/shared/models/material.models'; import { WidgetInfo } from '@home/models/widget-component.models'; +import jsonSchemaDefaults from 'json-schema-defaults'; const varsRegex = /\$\{([^}]*)\}/g; +const predefinedFunctions: {[func: string]: string} = { + Sin: 'return Math.round(1000*Math.sin(time/5000));', + Cos: 'return Math.round(1000*Math.cos(time/5000));', + Random: 'var value = prevValue + Math.random() * 100 - 50;\n' + + 'var multiplier = Math.pow(10, 2 || 0);\n' + + 'var value = Math.round(value * multiplier) / multiplier;\n' + + 'if (value < -1000) {\n' + + ' value = -1000;\n' + + '} else if (value > 1000) {\n' + + ' value = 1000;\n' + + '}\n' + + 'return value;' +}; + +const predefinedFunctionsList: Array = []; +for (const func of Object.keys(predefinedFunctions)) { + predefinedFunctionsList.push(func); +} + @Injectable({ providedIn: 'root' }) @@ -39,6 +59,22 @@ export class UtilsService { widgetEditMode = false; editWidgetInfo: WidgetInfo = null; + defaultDataKey: DataKey = { + name: 'f(x)', + type: DataKeyType.function, + label: 'Sin', + color: this.getMaterialColor(0), + funcBody: this.getPredefinedFunctionBody('Sin'), + settings: {}, + _hash: Math.random() + }; + + defaultDatasource: Datasource = { + type: DatasourceType.function, + name: DatasourceType.function, + dataKeys: [deepClone(this.defaultDataKey)] + }; + constructor(@Inject(WINDOW) private window: Window, private translate: TranslateService) { let frame: Element = null; @@ -57,6 +93,28 @@ export class UtilsService { } } + public getPredefinedFunctionsList(): Array { + return predefinedFunctionsList; + } + + public getPredefinedFunctionBody(func: string): string { + return predefinedFunctions[func]; + } + + public getDefaultDatasource(dataKeySchema: any): Datasource { + const datasource = deepClone(this.defaultDatasource); + if (isDefined(dataKeySchema)) { + datasource.dataKeys[0].settings = this.generateObjectFromJsonSchema(dataKeySchema); + } + return datasource; + } + + public generateObjectFromJsonSchema(schema: any): any { + const obj = jsonSchemaDefaults(schema); + deleteNullProperties(obj); + return obj; + } + public hashCode(str: string): number { let hash = 0; let i: number; @@ -192,7 +250,7 @@ export class UtilsService { return datasources; } - public getMaterialColor(index) { + public getMaterialColor(index: number) { const colorIndex = index % materialColors.length; return materialColors[colorIndex].value; } diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index 192387f853..394ed143df 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -103,6 +103,23 @@ export function isString(value: any): boolean { return typeof value === 'string'; } +export function deleteNullProperties(obj: any) { + if (isUndefined(obj) || obj == null) { + return; + } + Object.keys(obj).forEach((propName) => { + if (obj[propName] === null || isUndefined(obj[propName])) { + delete obj[propName]; + } else if (isObject(obj[propName])) { + deleteNullProperties(obj[propName]); + } else if (obj[propName] instanceof Array) { + (obj[propName] as any[]).forEach((elem) => { + deleteNullProperties(elem); + }); + } + }); +} + export function objToBase64(obj: any): string { const json = JSON.stringify(obj); const encoded = utf8Encode(json); diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.html b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.html new file mode 100644 index 0000000000..4d0ae583e0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.html @@ -0,0 +1,76 @@ + +
+ +

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

+ + +
+ + +
+
+
+
+ + alias.name + + + {{ 'alias.name-required' | translate }} + + + {{ 'alias.duplicate-alias' | translate }} + + +
+ + + +
+
+ + + +
+
+
+
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.scss b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.scss new file mode 100644 index 0000000000..f9ef126138 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.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 { + .tb-resolve-multiple-switch { + padding-left: 10px; + + .resolve-multiple-switch { + margin: 0; + } + + .resolve-multiple-label { + margin: 5px 0; + } + } +} \ No newline at end of file diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.ts new file mode 100644 index 0000000000..535b1d9452 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.ts @@ -0,0 +1,164 @@ +/// +/// 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 { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + AbstractControl, + FormArray, + FormBuilder, + FormControl, + FormGroup, + FormGroupDirective, + NgForm, Validators, ValidatorFn +} from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@app/shared/components/dialog.component'; +import { AttributeData } from '@shared/models/telemetry/telemetry.models'; +import { EntityAlias, EntityAliases, EntityAliasFilter } from '@shared/models/alias.models'; +import { DatasourceType, Widget, widgetType } from '@shared/models/widget.models'; +import { AliasEntityType, EntityType } from '@shared/models/entity-type.models'; +import { UtilsService } from '@core/services/utils.service'; +import { TranslateService } from '@ngx-translate/core'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { DialogService } from '@core/services/dialog.service'; +import { EntityService } from '@core/http/entity.service'; +import { Observable, of } from 'rxjs'; + +export interface EntityAliasDialogData { + isAdd: boolean; + allowedEntityTypes: Array; + entityAliases: EntityAliases | Array; + alias?: EntityAlias; +} + +@Component({ + selector: 'tb-entity-alias-dialog', + templateUrl: './entity-alias-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: EntityAliasDialogComponent}], + styleUrls: ['./entity-alias-dialog.component.scss'] +}) +export class EntityAliasDialogComponent extends DialogComponent + implements OnInit, ErrorStateMatcher { + + isAdd: boolean; + allowedEntityTypes: Array; + entityAliases: Array; + + alias: EntityAlias; + + entityAliasFormGroup: FormGroup; + + submitted = false; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: EntityAliasDialogData, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef, + private fb: FormBuilder, + private utils: UtilsService, + private translate: TranslateService, + private entityService: EntityService) { + super(store, router, dialogRef); + this.isAdd = data.isAdd; + this.allowedEntityTypes = data.allowedEntityTypes; + if (Array.isArray(data.entityAliases)) { + this.entityAliases = data.entityAliases; + } else { + this.entityAliases = []; + for (const aliasId of Object.keys(data.entityAliases)) { + this.entityAliases.push(data.entityAliases[aliasId]); + } + } + if (this.isAdd) { + this.alias = { + id: null, + alias: '', + filter: { + resolveMultiple: false + } + }; + } else { + this.alias = data.alias; + } + + this.entityAliasFormGroup = this.fb.group({ + alias: [this.alias.alias, [this.validateDuplicateAliasName(), Validators.required]], + resolveMultiple: [this.alias.filter.resolveMultiple], + filter: [this.alias.filter, Validators.required] + }); + } + + validateDuplicateAliasName(): ValidatorFn { + return (c: FormControl) => { + const newAlias = c.value; + const found = this.entityAliases.find((entityAlias) => entityAlias.alias === newAlias); + if (found) { + if (this.isAdd || this.alias.id !== found.id) { + return { + duplicateAliasName: { + valid: false + } + }; + } + } + return null; + }; + } + + ngOnInit(): void { + } + + 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(null); + } + + private validate(): Observable { + return this.entityService.resolveAliasFilter(this.alias.filter, null, 1, true); + } + + save(): void { + this.submitted = true; + this.alias.alias = this.entityAliasFormGroup.get('alias').value; + this.alias.filter = this.entityAliasFormGroup.get('filter').value; + this.alias.filter.resolveMultiple = this.entityAliasFormGroup.get('resolveMultiple').value; + this.validate().subscribe(() => { + if (this.isAdd) { + this.alias.id = this.utils.guid(); + } + this.dialogRef.close(this.alias); + }, + () => { + this.entityAliasFormGroup.setErrors({ + noEntityMatched: true + }); + const changesSubscriptuion = this.entityAliasFormGroup.valueChanges.subscribe(() => { + this.entityAliasFormGroup.setErrors(null); + changesSubscriptuion.unsubscribe(); + }); + } + ); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.html b/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.html new file mode 100644 index 0000000000..c5f8539e48 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.html @@ -0,0 +1,111 @@ + +
+ +

{{ title | translate }}

+ + +
+ + +
+
+ +
+ alias.name + alias.entity-filter + alias.resolve-multiple + +
+
+ +
+
+
+ {{$index + 1}}. +
+ + + + + {{ 'entity.alias-required' | translate }} + + + + +
+ + +
+ + +
+
+
+
+
+ + + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.scss b/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.scss new file mode 100644 index 0000000000..86415375e0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.scss @@ -0,0 +1,49 @@ +/** + * 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 { + + .tb-aliases-header { + min-height: 40px; + padding: 0 34px 0 34px; + margin: 5px; + + .tb-header-label { + font-size: 14px; + color: rgba(0, 0, 0, .570588); + } + } + + .tb-alias { + padding: 0 0 0 10px; + margin: 5px; + + .tb-resolve-multiple-switch { + padding-left: 10px; + + .resolve-multiple-switch { + margin: 0; + } + } + } +} + +:host ::ng-deep { + .mat-dialog-content { + padding-top: 0 !important; + padding-bottom: 0 !important; + } +} diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts new file mode 100644 index 0000000000..8810495038 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts @@ -0,0 +1,264 @@ +/// +/// 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 { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + AbstractControl, + FormArray, + FormBuilder, + FormControl, + FormGroup, + FormGroupDirective, + NgForm, Validators +} from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@app/shared/components/dialog.component'; +import { AttributeData } from '@shared/models/telemetry/telemetry.models'; +import { EntityAlias, EntityAliases, EntityAliasFilter } from '@shared/models/alias.models'; +import { DatasourceType, Widget, widgetType } from '@shared/models/widget.models'; +import { AliasEntityType, EntityType } from '@shared/models/entity-type.models'; +import { UtilsService } from '@core/services/utils.service'; +import { TranslateService } from '@ngx-translate/core'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { DialogService } from '@core/services/dialog.service'; +import { deepClone } from '@core/utils'; +import { MatDialog } from '@angular/material/dialog'; +import { EntityAliasDialogComponent, EntityAliasDialogData } from './entity-alias-dialog.component'; + +export interface EntityAliasesDialogData { + entityAliases: EntityAliases; + widgets: Array; + isSingleEntityAlias: boolean; + isSingleWidget?: boolean; + allowedEntityTypes?: Array; + disableAdd?: boolean; + singleEntityAlias?: EntityAlias; + customTitle?: string; +} + +@Component({ + selector: 'tb-entity-aliases-dialog', + templateUrl: './entity-aliases-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: EntityAliasesDialogComponent}], + styleUrls: ['./entity-aliases-dialog.component.scss'] +}) +export class EntityAliasesDialogComponent extends DialogComponent + implements OnInit, ErrorStateMatcher { + + title: string; + disableAdd: boolean; + allowedEntityTypes: Array; + + aliasToWidgetsMap: {[aliasId: string]: Array} = {}; + + entityAliasesFormGroup: FormGroup; + + submitted = false; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: EntityAliasesDialogData, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef, + private fb: FormBuilder, + private utils: UtilsService, + private translate: TranslateService, + private dialogs: DialogService, + private dialog: MatDialog) { + super(store, router, dialogRef); + this.title = data.customTitle ? data.customTitle : 'entity.aliases'; + this.disableAdd = this.data.disableAdd; + this.allowedEntityTypes = this.data.allowedEntityTypes; + + if (data.widgets) { + let widgetsTitleList: Array; + if (this.data.isSingleWidget && this.data.widgets.length === 1) { + const widget = this.data.widgets[0]; + widgetsTitleList = [widget.config.title]; + for (const aliasId of Object.keys(this.data.entityAliases)) { + this.aliasToWidgetsMap[aliasId] = widgetsTitleList; + } + } else { + this.data.widgets.forEach((widget) => { + if (widget.type === widgetType.rpc) { + if (widget.config.targetDeviceAliasIds && widget.config.targetDeviceAliasIds.length > 0) { + const targetDeviceAliasId = widget.config.targetDeviceAliasIds[0]; + widgetsTitleList = this.aliasToWidgetsMap[targetDeviceAliasId]; + if (!widgetsTitleList) { + widgetsTitleList = []; + this.aliasToWidgetsMap[targetDeviceAliasId] = widgetsTitleList; + } + widgetsTitleList.push(widget.config.title); + } + } else { + const datasources = this.utils.validateDatasources(widget.config.datasources); + datasources.forEach((datasource) => { + if (datasource.type === DatasourceType.entity && datasource.entityAliasId) { + widgetsTitleList = this.aliasToWidgetsMap[datasource.entityAliasId]; + if (!widgetsTitleList) { + widgetsTitleList = []; + this.aliasToWidgetsMap[datasource.entityAliasId] = widgetsTitleList; + } + widgetsTitleList.push(widget.config.title); + } + }); + } + }); + } + } + const entityAliasControls: Array = []; + for (const aliasId of Object.keys(this.data.entityAliases)) { + const entityAlias = this.data.entityAliases[aliasId]; + let filter = entityAlias.filter; + if (!filter) { + filter = { + resolveMultiple: false + }; + } + if (!filter.resolveMultiple) { + filter.resolveMultiple = false; + } + entityAliasControls.push(this.createEntityAliasFormControl(aliasId, entityAlias)); + } + + this.entityAliasesFormGroup = this.fb.group({ + entityAliases: this.fb.array(entityAliasControls) + }); + } + + private createEntityAliasFormControl(aliasId: string, entityAlias: EntityAlias): AbstractControl { + const aliasFormControl = this.fb.group({ + id: [aliasId], + alias: [entityAlias ? entityAlias.alias : null, [Validators.required]], + filter: [entityAlias ? entityAlias.filter : null], + resolveMultiple: [entityAlias ? entityAlias.filter.resolveMultiple : false] + }); + aliasFormControl.get('resolveMultiple').valueChanges.subscribe((resolveMultiple: boolean) => { + (aliasFormControl.get('filter').value as EntityAliasFilter).resolveMultiple = resolveMultiple; + }); + return aliasFormControl; + } + + ngOnInit(): void { + } + + 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; + } + + removeAlias(index: number) { + const entityAlias = (this.entityAliasesFormGroup.get('entityAliases').value as any[])[index]; + const widgetsTitleList = this.aliasToWidgetsMap[entityAlias.id]; + if (widgetsTitleList) { + let widgetsListHtml = ''; + for (const widgetTitle of widgetsTitleList) { + widgetsListHtml += '
\'' + widgetTitle + '\''; + } + const message = this.translate.instant('entity.unable-delete-entity-alias-text', + {entityAlias: entityAlias.alias, widgetsList: widgetsListHtml}); + this.dialogs.alert(this.translate.instant('entity.unable-delete-entity-alias-title'), + message, this.translate.instant('action.close'), true); + } else { + (this.entityAliasesFormGroup.get('entityAliases') as FormArray).removeAt(index); + } + } + + public addAlias() { + this.openAliasDialog(-1); + } + + public editAlias(index: number) { + this.openAliasDialog(index); + } + + private openAliasDialog(index: number) { + const isAdd = index === -1; + let alias; + const aliasesArray = this.entityAliasesFormGroup.get('entityAliases').value as any[]; + if (!isAdd) { + alias = aliasesArray[index]; + } + this.dialog.open(EntityAliasDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + isAdd, + allowedEntityTypes: this.allowedEntityTypes, + entityAliases: aliasesArray, + alias: isAdd ? null : deepClone(alias) + } + }).afterClosed().subscribe((entityAlias) => { + if (entityAlias) { + if (isAdd) { + (this.entityAliasesFormGroup.get('entityAliases') as FormArray) + .push(this.createEntityAliasFormControl(entityAlias.id, entityAlias)); + } else { + const aliasFormControl = (this.entityAliasesFormGroup.get('entityAliases') as FormArray).at(index); + aliasFormControl.get('alias').patchValue(entityAlias.alias); + aliasFormControl.get('filter').patchValue(entityAlias.filter); + aliasFormControl.get('resolveMultiple').patchValue(entityAlias.filter.resolveMultiple); + } + } + }); + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + this.submitted = true; + const entityAliases: EntityAliases = {}; + const uniqueAliasList: {[alias: string]: string} = {}; + + let valid = true; + let message: string; + + const aliasesArray = this.entityAliasesFormGroup.get('entityAliases').value as any[]; + for (const aliasValue of aliasesArray) { + const aliasId: string = aliasValue.id; + const alias: string = aliasValue.alias; + const filter: EntityAliasFilter = aliasValue.filter; + if (uniqueAliasList[alias]) { + valid = false; + message = this.translate.instant('entity.duplicate-alias-error', {alias}); + break; + } else if (!filter || !filter.type) { + valid = false; + message = this.translate.instant('entity.missing-entity-filter-error', {alias}); + break; + } else { + uniqueAliasList[alias] = alias; + entityAliases[aliasId] = {id: aliasId, alias, filter}; + } + } + if (valid) { + this.dialogRef.close(entityAliases); + } else { + this.store.dispatch(new ActionNotificationShow( + { + message, + type: 'error' + })); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.html b/ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.html new file mode 100644 index 0000000000..c6caab5dbf --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.html @@ -0,0 +1,25 @@ + +
+
alias.no-entity-filter-specified
+ +
+
{{ filterDisplayValue }}
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.scss b/ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.scss new file mode 100644 index 0000000000..1068720bc7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.scss @@ -0,0 +1,36 @@ +/** + * 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 { + .tb-entity-filter-view { + .entity-filter-empty { + font-size: 14px; + line-height: 16px; + color: rgba(221, 44, 0, .87); + } + + .entity-filter-type { + font-size: 14px; + line-height: 16px; + color: rgba(0, 0, 0, .570588); + } + + .entity-filter-value { + font-size: 14px; + line-height: 16px; + color: rgba(0, 0, 0, .570588); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.ts b/ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.ts new file mode 100644 index 0000000000..12993eeafd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.ts @@ -0,0 +1,230 @@ +/// +/// 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 } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { AliasFilterType, EntityAliasFilter } from '@shared/models/alias.models'; +import { AliasEntityType, EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { TranslateService } from '@ngx-translate/core'; + +@Component({ + selector: 'tb-entity-filter-view', + templateUrl: './entity-filter-view.component.html', + styleUrls: ['./entity-filter-view.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityFilterViewComponent), + multi: true + } + ] +}) +export class EntityFilterViewComponent implements ControlValueAccessor { + + constructor(private translate: TranslateService) {} + + filterDisplayValue: string; + filter: EntityAliasFilter; + + registerOnChange(fn: any): void { + } + + registerOnTouched(fn: any): void { + } + + setDisabledState?(isDisabled: boolean): void { + } + + writeValue(filter: EntityAliasFilter): void { + this.filter = filter; + if (this.filter && this.filter.type) { + let entityType: EntityType | AliasEntityType; + let prefix: string; + let allEntitiesText; + let anyRelationText; + let relationTypeText; + let rootEntityText; + let directionText; + switch (this.filter.type) { + case AliasFilterType.singleEntity: + entityType = this.filter.singleEntity.entityType; + this.filterDisplayValue = this.translate.instant(entityTypeTranslations.get(entityType).list, + {count: 1}); + break; + case AliasFilterType.entityList: + entityType = this.filter.entityType; + const count = this.filter.entityList.length; + this.filterDisplayValue = this.translate.instant(entityTypeTranslations.get(entityType).list, + {count}); + break; + case AliasFilterType.entityName: + entityType = this.filter.entityType; + prefix = this.filter.entityNameFilter; + this.filterDisplayValue = this.translate.instant(entityTypeTranslations.get(entityType).nameStartsWith, + {prefix}); + break; + case AliasFilterType.stateEntity: + this.filterDisplayValue = this.translate.instant('alias.filter-type-state-entity-description'); + break; + case AliasFilterType.assetType: + const assetType = this.filter.assetType; + prefix = this.filter.assetNameFilter; + if (prefix && prefix.length) { + this.filterDisplayValue = this.translate.instant('alias.filter-type-asset-type-and-name-description', + {assetType, prefix}); + } else { + this.filterDisplayValue = this.translate.instant('alias.filter-type-asset-type-description', + {assetType}); + } + break; + case AliasFilterType.deviceType: + const deviceType = this.filter.deviceType; + prefix = this.filter.deviceNameFilter; + if (prefix && prefix.length) { + this.filterDisplayValue = this.translate.instant('alias.filter-type-device-type-and-name-description', + {deviceType, prefix}); + } else { + this.filterDisplayValue = this.translate.instant('alias.filter-type-device-type-description', + {deviceType}); + } + break; + case AliasFilterType.entityViewType: + const entityViewType = this.filter.entityViewType; + prefix = this.filter.entityViewNameFilter; + if (prefix && prefix.length) { + this.filterDisplayValue = this.translate.instant('alias.filter-type-entity-view-type-and-name-description', + {entityViewType, prefix}); + } else { + this.filterDisplayValue = this.translate.instant('alias.filter-type-entity-view-type-description', + {entityViewType}); + } + break; + case AliasFilterType.relationsQuery: + allEntitiesText = this.translate.instant('alias.all-entities'); + anyRelationText = this.translate.instant('alias.any-relation'); + if (this.filter.rootStateEntity) { + rootEntityText = this.translate.instant('alias.state-entity'); + } else { + rootEntityText = this.translate.instant(entityTypeTranslations.get(this.filter.rootEntity.entityType).type); + } + directionText = this.translate.instant('relation.direction-type.' + this.filter.direction); + const relationFilters = this.filter.filters; + if (relationFilters && relationFilters.length) { + const relationFiltersDisplayValues = []; + relationFilters.forEach((relationFilter) => { + let entitiesText; + if (relationFilter.entityTypes && relationFilter.entityTypes.length) { + const entitiesNamesList = []; + relationFilter.entityTypes.forEach((filterEntityType) => { + entitiesNamesList.push( + this.translate.instant(entityTypeTranslations.get(filterEntityType).typePlural) + ); + }); + entitiesText = entitiesNamesList.join(', '); + } else { + entitiesText = allEntitiesText; + } + if (relationFilter.relationType && relationFilter.relationType.length) { + relationTypeText = `'${relationFilter.relationType}'`; + } else { + relationTypeText = anyRelationText; + } + const relationFilterDisplayValue = this.translate.instant('alias.filter-type-relations-query-description', + { + entities: entitiesText, + relationType: relationTypeText, + direction: directionText, + rootEntity: rootEntityText + } + ); + relationFiltersDisplayValues.push(relationFilterDisplayValue); + }); + this.filterDisplayValue = relationFiltersDisplayValues.join(', '); + } else { + this.filterDisplayValue = this.translate.instant('alias.filter-type-relations-query-description', + { + entities: allEntitiesText, + relationType: anyRelationText, + direction: directionText, + rootEntity: rootEntityText + } + ); + } + break; + case AliasFilterType.assetSearchQuery: + case AliasFilterType.deviceSearchQuery: + case AliasFilterType.entityViewSearchQuery: + allEntitiesText = this.translate.instant('alias.all-entities'); + anyRelationText = this.translate.instant('alias.any-relation'); + if (this.filter.rootStateEntity) { + rootEntityText = this.translate.instant('alias.state-entity'); + } else { + rootEntityText = this.translate.instant(entityTypeTranslations.get(this.filter.rootEntity.entityType).type); + } + directionText = this.translate.instant('relation.direction-type.' + this.filter.direction); + if (this.filter.relationType && this.filter.relationType.length) { + relationTypeText = `'${filter.relationType}'`; + } else { + relationTypeText = anyRelationText; + } + + const translationValues: any = { + relationType: relationTypeText, + direction: directionText, + rootEntity: rootEntityText + }; + + if (this.filter.type === AliasFilterType.assetSearchQuery) { + const assetTypesQuoted = []; + this.filter.assetTypes.forEach((filterAssetType) => { + assetTypesQuoted.push(`'${filterAssetType}'`); + }); + const assetTypesText = assetTypesQuoted.join(', '); + translationValues.assetTypes = assetTypesText; + this.filterDisplayValue = this.translate.instant('alias.filter-type-asset-search-query-description', + translationValues + ); + } else if (this.filter.type === AliasFilterType.deviceSearchQuery) { + const deviceTypesQuoted = []; + this.filter.deviceTypes.forEach((filterDeviceType) => { + deviceTypesQuoted.push(`'${filterDeviceType}'`); + }); + const deviceTypesText = deviceTypesQuoted.join(', '); + translationValues.deviceTypes = deviceTypesText; + this.filterDisplayValue = this.translate.instant('alias.filter-type-device-search-query-description', + translationValues + ); + } else if (this.filter.type === AliasFilterType.entityViewSearchQuery) { + const entityViewTypesQuoted = []; + this.filter.entityViewTypes.forEach((filterEntityViewType) => { + entityViewTypesQuoted.push(`'${filterEntityViewType}'`); + }); + const entityViewTypesText = entityViewTypesQuoted.join(', '); + translationValues.entityViewTypes = entityViewTypesText; + this.filterDisplayValue = this.translate.instant('alias.filter-type-entity-view-search-query-description', + translationValues + ); + } + break; + default: + this.filterDisplayValue = this.filter.type; + break; + } + } else { + this.filterDisplayValue = ''; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html new file mode 100644 index 0000000000..fa7679c1c1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html @@ -0,0 +1,244 @@ + +
+ + alias.filter-type + + + {{aliasFilterTypeTranslations.get(type) | translate}} + + + + {{ 'alias.filter-type-required' | translate }} + + +
+ + + + + + + + + + + + + + + entity.name-starts-with + + + {{ 'entity.entity-name-filter-required' | translate }} + + + + + + alias.state-entity-parameter-name + + +
+ + + +
+
+ + + + + asset.name-starts-with + + + + + + + + device.name-starts-with + + + + + + + + entity-view.name-starts-with + + + + +
+ +
+ + + +
+
+ + +
+
+ + alias.state-entity-parameter-name + + +
+ + + +
+
+
+ + relation.direction + + + {{ directionTypeTranslations.get(type) | translate }} + + + + + alias.max-relation-level + + +
+
relation.relation-filters
+ + +
+
+ + +
+ + + +
+
+ + +
+
+ + alias.state-entity-parameter-name + + +
+ + + +
+
+
+ + relation.direction + + + {{ directionTypeTranslations.get(type) | translate }} + + + + + alias.max-relation-level + + +
+
relation.relation-type
+ + +
+ +
asset.asset-types
+ + +
+ +
device.device-types
+ + +
+ +
entity-view.entity-view-types
+ + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.scss b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.scss new file mode 100644 index 0000000000..3d0db1ea5c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.scss @@ -0,0 +1,38 @@ +/** + * 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 { + .tb-entity-filter { + #relationsQueryFilter { + padding-top: 20px; + + tb-entity-select { + min-height: 92px; + } + } + + .tb-root-state-entity-switch { + padding-left: 10px; + + .root-state-entity-switch { + margin: 0; + } + + .root-state-entity-label { + margin: 5px 0 5px 10px; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.ts b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.ts new file mode 100644 index 0000000000..3d00d6dfbf --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.ts @@ -0,0 +1,218 @@ +/// +/// 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, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, FormControl, + FormGroup, + NG_VALUE_ACCESSOR, + ValidatorFn, + Validators +} from '@angular/forms'; +import { AliasFilterType, aliasFilterTypeTranslationMap, EntityAliasFilter } from '@shared/models/alias.models'; +import { AliasEntityType, EntityType } from '@shared/models/entity-type.models'; +import { TranslateService } from '@ngx-translate/core'; +import { EntityService } from '@core/http/entity.service'; +import { EntitySearchDirection, entitySearchDirectionTranslations } from '@shared/models/relation.models'; + +@Component({ + selector: 'tb-entity-filter', + templateUrl: './entity-filter.component.html', + styleUrls: ['./entity-filter.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityFilterComponent), + multi: true + } + ] +}) +export class EntityFilterComponent implements ControlValueAccessor, OnInit { + + @Input() disabled: boolean; + + @Input() allowedEntityTypes: Array; + + @Input() resolveMultiple: boolean; + + @Output() resolveMultipleChanged: EventEmitter = new EventEmitter(); + + entityFilterFormGroup: FormGroup; + filterFormGroup: FormGroup; + + aliasFilterTypes: Array; + + aliasFilterType = AliasFilterType; + aliasFilterTypeTranslations = aliasFilterTypeTranslationMap; + entityType = EntityType; + + directionTypes = Object.keys(EntitySearchDirection); + directionTypeTranslations = entitySearchDirectionTranslations; + + private propagateChange = null; + + constructor(private translate: TranslateService, + private entityService: EntityService, + private fb: FormBuilder) { + } + + ngOnInit(): void { + + this.aliasFilterTypes = this.entityService.getAliasFilterTypesByEntityTypes(this.allowedEntityTypes); + + this.entityFilterFormGroup = this.fb.group({ + type: [null, [Validators.required]] + }); + this.entityFilterFormGroup.get('type').valueChanges.subscribe((type: AliasFilterType) => { + this.filterTypeChanged(type); + }); + this.entityFilterFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + this.filterFormGroup = this.fb.group({}); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState?(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(filter: EntityAliasFilter): void { + if (!filter) { + filter = { + type: null, + resolveMultiple: this.resolveMultiple + }; + } + this.entityFilterFormGroup.get('type').patchValue(filter.type, {emitEvent: false}); + if (filter && filter.type) { + this.updateFilterFormGroup(filter.type, filter); + } + } + + private updateFilterFormGroup(type: AliasFilterType, filter?: EntityAliasFilter) { + switch (type) { + case AliasFilterType.singleEntity: + this.filterFormGroup = this.fb.group({ + singleEntity: [filter ? filter.singleEntity : null, [Validators.required]] + }); + break; + case AliasFilterType.entityList: + this.filterFormGroup = this.fb.group({ + entityType: [filter ? filter.entityType : null, [Validators.required]], + entityList: [filter ? filter.entityList : [], [Validators.required]], + }); + break; + case AliasFilterType.entityName: + this.filterFormGroup = this.fb.group({ + entityType: [filter ? filter.entityType : null, [Validators.required]], + entityNameFilter: [filter ? filter.entityNameFilter : '', [Validators.required]], + }); + break; + case AliasFilterType.stateEntity: + this.filterFormGroup = this.fb.group({ + stateEntityParamName: [filter ? filter.stateEntityParamName : null, []], + defaultStateEntity: [filter ? filter.defaultStateEntity : null, []], + }); + break; + case AliasFilterType.assetType: + this.filterFormGroup = this.fb.group({ + assetType: [filter ? filter.assetType : null, [Validators.required]], + assetNameFilter: [filter ? filter.assetNameFilter : '', []], + }); + break; + case AliasFilterType.deviceType: + this.filterFormGroup = this.fb.group({ + deviceType: [filter ? filter.deviceType : null, [Validators.required]], + deviceNameFilter: [filter ? filter.deviceNameFilter : '', []], + }); + break; + case AliasFilterType.entityViewType: + this.filterFormGroup = this.fb.group({ + entityViewType: [filter ? filter.entityViewType : null, [Validators.required]], + entityViewNameFilter: [filter ? filter.entityViewNameFilter : '', []], + }); + break; + case AliasFilterType.relationsQuery: + case AliasFilterType.assetSearchQuery: + case AliasFilterType.deviceSearchQuery: + case AliasFilterType.entityViewSearchQuery: + this.filterFormGroup = this.fb.group({ + rootStateEntity: [filter ? filter.rootStateEntity : false, []], + stateEntityParamName: [filter ? filter.stateEntityParamName : null, []], + defaultStateEntity: [filter ? filter.defaultStateEntity : null, []], + rootEntity: [filter ? filter.rootEntity : null, (filter && filter.rootStateEntity) ? [] : [Validators.required]], + direction: [filter ? filter.direction : EntitySearchDirection.FROM, [Validators.required]], + maxLevel: [filter ? filter.maxLevel : 1, []], + }); + this.filterFormGroup.get('rootStateEntity').valueChanges.subscribe((rootStateEntity: boolean) => { + this.filterFormGroup.get('rootEntity').setValidators(rootStateEntity ? [] : [Validators.required]); + this.filterFormGroup.get('rootEntity').updateValueAndValidity(); + }); + if (type === AliasFilterType.relationsQuery) { + this.filterFormGroup.addControl('filters', + this.fb.control(filter ? filter.filters : [], [])); + } else { + this.filterFormGroup.addControl('relationType', + this.fb.control(filter ? filter.relationType : null, [])); + if (type === AliasFilterType.assetSearchQuery) { + this.filterFormGroup.addControl('assetTypes', + this.fb.control(filter ? filter.assetTypes : [], [Validators.required])); + } else if (type === AliasFilterType.deviceSearchQuery) { + this.filterFormGroup.addControl('deviceTypes', + this.fb.control(filter ? filter.deviceTypes : [], [Validators.required])); + } else if (type === AliasFilterType.entityViewSearchQuery) { + this.filterFormGroup.addControl('entityViewTypes', + this.fb.control(filter ? filter.entityViewTypes : [], [Validators.required])); + } + } + break; + } + this.filterFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + } + + private filterTypeChanged(type: AliasFilterType) { + let resolveMultiple = true; + if (type === AliasFilterType.singleEntity || type === AliasFilterType.stateEntity) { + resolveMultiple = false; + } + if (this.resolveMultiple !== resolveMultiple) { + this.resolveMultipleChanged.emit(resolveMultiple); + } + this.updateFilterFormGroup(type); + } + + private updateModel() { + let filter = null; + if (this.entityFilterFormGroup.valid && this.filterFormGroup.valid) { + filter = { + type: this.entityFilterFormGroup.get('type').value, + resolveMultiple: this.resolveMultiple + }; + filter = {...filter, ...this.filterFormGroup.value}; + } + this.propagateChange(filter); + } +} 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 1eb39dcab3..287844ab12 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 @@ -41,6 +41,11 @@ import { LegendComponent } from '@home/components/widget/legend.component'; import { AliasesEntitySelectPanelComponent } from '@home/components/alias/aliases-entity-select-panel.component'; import { AliasesEntitySelectComponent } from '@home/components/alias/aliases-entity-select.component'; import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; +import { EntityAliasesDialogComponent } from '@home/components/alias/entity-aliases-dialog.component'; +import { EntityFilterViewComponent } from './entity/entity-filter-view.component'; +import { EntityAliasDialogComponent } from './alias/entity-alias-dialog.component'; +import { EntityFilterComponent } from './entity/entity-filter.component'; +import { RelationFiltersComponent } from './relation/relation-filters.component'; @NgModule({ entryComponents: [ @@ -52,7 +57,9 @@ import { WidgetConfigComponent } from '@home/components/widget/widget-config.com AlarmDetailsDialogComponent, AddAttributeDialogComponent, EditAttributeValuePanelComponent, - AliasesEntitySelectPanelComponent + AliasesEntitySelectPanelComponent, + EntityAliasesDialogComponent, + EntityAliasDialogComponent ], declarations: [ @@ -67,6 +74,7 @@ import { WidgetConfigComponent } from '@home/components/widget/widget-config.com EventTableComponent, RelationTableComponent, RelationDialogComponent, + RelationFiltersComponent, AlarmTableHeaderComponent, AlarmTableComponent, AlarmDetailsDialogComponent, @@ -75,10 +83,14 @@ import { WidgetConfigComponent } from '@home/components/widget/widget-config.com EditAttributeValuePanelComponent, AliasesEntitySelectPanelComponent, AliasesEntitySelectComponent, + EntityAliasesDialogComponent, + EntityAliasDialogComponent, DashboardComponent, WidgetComponent, LegendComponent, - WidgetConfigComponent + WidgetConfigComponent, + EntityFilterViewComponent, + EntityFilterComponent ], imports: [ CommonModule, @@ -93,14 +105,19 @@ import { WidgetConfigComponent } from '@home/components/widget/widget-config.com AuditLogTableComponent, EventTableComponent, RelationTableComponent, + RelationFiltersComponent, AlarmTableComponent, AlarmDetailsDialogComponent, AttributeTableComponent, AliasesEntitySelectComponent, + EntityAliasesDialogComponent, + EntityAliasDialogComponent, DashboardComponent, WidgetComponent, LegendComponent, - WidgetConfigComponent + WidgetConfigComponent, + EntityFilterViewComponent, + EntityFilterComponent ], providers: [ WidgetComponentService diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.html b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.html new file mode 100644 index 0000000000..8c21ee040c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.html @@ -0,0 +1,66 @@ + +
+
+
+ relation.type + entity.entity-types +   +
+
+
+
+
+ + + + + +
+
+
+
+ relation.any-relation +
+
+ +
+
diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.scss b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.scss new file mode 100644 index 0000000000..ab9c046b07 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.scss @@ -0,0 +1,67 @@ +/** + * 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 { + .tb-relation-filters { + .header { + padding-right: 5px; + padding-bottom: 5px; + padding-left: 5px; + + .cell { + padding-right: 5px; + padding-left: 5px; + font-size: 12px; + font-weight: 700; + color: rgba(0, 0, 0, .54); + white-space: nowrap; + } + } + + .body { + max-height: 300px; + padding-right: 5px; + padding-bottom: 20px; + padding-left: 5px; + overflow: auto; + + .row { + padding-top: 5px; + } + + .cell { + padding-right: 5px; + padding-left: 5px; + } + } + } +} + +:host ::ng-deep { + .tb-relation-filters { + .cell { + .mat-form-field { + .mat-form-field-infix { + border-top: none; + } + &.mat-form-field-appearance-standard { + .mat-form-field-flex { + padding-top: 0; + } + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.ts b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.ts new file mode 100644 index 0000000000..d9c966bd19 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.ts @@ -0,0 +1,123 @@ +/// +/// 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, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, FormArray, + FormBuilder, FormControl, + FormGroup, + NG_VALUE_ACCESSOR, + ValidatorFn, + Validators +} from '@angular/forms'; +import { AliasFilterType, aliasFilterTypeTranslationMap, EntityAliasFilter } from '@shared/models/alias.models'; +import { AliasEntityType, EntityType } from '@shared/models/entity-type.models'; +import { TranslateService } from '@ngx-translate/core'; +import { EntityService } from '@core/http/entity.service'; +import { EntitySearchDirection, entitySearchDirectionTranslations, EntityTypeFilter } from '@shared/models/relation.models'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'tb-relation-filters', + templateUrl: './relation-filters.component.html', + styleUrls: ['./relation-filters.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RelationFiltersComponent), + multi: true + } + ] +}) +export class RelationFiltersComponent extends PageComponent implements ControlValueAccessor, OnInit { + + @Input() disabled: boolean; + + @Input() allowedEntityTypes: Array; + + relationFiltersFormGroup: FormGroup; + + private propagateChange = null; + + private valueChangeSubscription: Subscription = null; + + constructor(protected store: Store, + private fb: FormBuilder) { + super(store); + } + + ngOnInit(): void { + this.relationFiltersFormGroup = this.fb.group({}); + this.relationFiltersFormGroup.addControl('relationFilters', + this.fb.array([])); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState?(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(filters: Array): void { + if (this.valueChangeSubscription) { + this.valueChangeSubscription.unsubscribe(); + } + const relationFiltersControls: Array = []; + if (filters && filters.length) { + filters.forEach((filter) => { + relationFiltersControls.push(this.createRelationFilterFormGroup(filter)); + }); + } + this.relationFiltersFormGroup.setControl('relationFilters', this.fb.array(relationFiltersControls)); + this.valueChangeSubscription = this.relationFiltersFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + } + + public removeFilter(index: number) { + (this.relationFiltersFormGroup.get('relationFilters') as FormArray).removeAt(index); + } + + public addFilter() { + const relationFiltersFormArray = this.relationFiltersFormGroup.get('relationFilters') as FormArray; + const filter: EntityTypeFilter = { + relationType: null, + entityTypes: [] + }; + relationFiltersFormArray.push(this.createRelationFilterFormGroup(filter)); + } + + private createRelationFilterFormGroup(filter: EntityTypeFilter): AbstractControl { + return this.fb.group({ + relationType: [filter ? filter.relationType : null], + entityTypes: [filter ? filter.entityTypes : []] + }); + } + + private updateModel() { + const filters: Array = this.relationFiltersFormGroup.get('relationFilters').value; + this.propagateChange(filters); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html index 961ca40d9a..01b63e8fd5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html @@ -16,26 +16,121 @@ --> + fxFlex class="tb-widget-config tb-absolute-fill" [(selectedIndex)]="selectedTab"> -
-
-
- +
+
+
+ {{ 'widget-config.use-dashboard-timewindow' | translate }} - + {{ 'widget-config.display-timewindow' | translate }}
widget-config.timewindow - + fxFlex formControlName="timewindow">
+
+ + alarm.alarm-status + + + {{ ('alarm.search-status.' + searchStatus) | translate }} + + + + + alarm.polling-interval + + + {{ 'alarm.polling-interval-required' | translate }} + + + {{ 'alarm.min-polling-interval-message' | translate }} + + +
+ + + +
widget-config.datasources
+
{{ 'widget-config.maximum-datasources' | translate:{count: typeParameters.maxDatasources} }}
+
+
+
+ datasource.add-datasource-prompt +
+ +
+ +
+ widget-config.datasource-type + widget-config.datasource-parameters + +
+
+
+
+ {{$index + 1}}. +
+
TODO: datasource: {{ datasourceControl.value | json }}
+ +
+
+
+
+
+ +
+
+ + + + {{ 'widget-config.target-device' | translate }} + + +
+ +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss index e2548a4a50..d966d6df6e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss @@ -28,5 +28,8 @@ height: 100%; } } + .mat-expansion-panel.tb-datasources.mat-expanded { + overflow: visible; + } } } diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts index 9dd0324433..eba71b1be3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts @@ -19,20 +19,35 @@ import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { + DataKey, Datasource, + DatasourceType, LegendConfig, WidgetActionDescriptor, - WidgetActionSource, WidgetConfigSettings, + WidgetActionSource, + WidgetConfigSettings, widgetType, WidgetTypeParameters } from '@shared/models/widget.models'; -import { ControlValueAccessor, FormControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validator } from '@angular/forms'; +import { + ControlValueAccessor, + FormArray, + FormBuilder, + FormControl, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + Validator, + Validators +} from '@angular/forms'; import { WidgetConfigComponentData } from '@home/models/widget-component.models'; -import { deepClone, isDefined } from '@app/core/utils'; -import { Timewindow } from '@shared/models/time/time.models'; -import { AlarmSearchStatus } from '@shared/models/alarm.models'; +import { deepClone, isDefined, isObject } from '@app/core/utils'; +import { alarmFields, AlarmSearchStatus } from '@shared/models/alarm.models'; import { IAliasController } from '@core/api/widget-api.models'; import { EntityAlias } from '@shared/models/alias.models'; +import { UtilsService } from '@core/services/utils.service'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { TranslateService } from '@ngx-translate/core'; @Component({ selector: 'tb-widget-config', @@ -55,15 +70,14 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont widgetTypes = widgetType; + alarmSearchStatuses = Object.keys(AlarmSearchStatus); + @Input() forceExpandDatasources: boolean; @Input() isDataEnabled: boolean; - @Input() - widgetType: widgetType; - @Input() typeParameters: WidgetTypeParameters; @@ -84,6 +98,8 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont @Input() disabled: boolean; + widgetType: widgetType; + selectedTab: number; title: string; showTitleIcon: boolean; @@ -101,17 +117,11 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont titleStyle: string; units: string; decimals: number; - useDashboardTimewindow: boolean; - displayTimewindow: boolean; - timewindow: Timewindow; showLegend: boolean; legendConfig: LegendConfig; actions: {[actionSourceId: string]: Array}; - datasources: Array; targetDeviceAlias: EntityAlias; alarmSource: Datasource; - alarmSearchStatus: AlarmSearchStatus; - alarmsPollingInterval: number; settings: WidgetConfigSettings; mobileOrder: number; mobileHeight: number; @@ -138,11 +148,51 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont private propagateChange = null; - constructor(protected store: Store) { + public dataSettings: FormGroup; + + constructor(protected store: Store, + private utils: UtilsService, + private translate: TranslateService, + private fb: FormBuilder) { super(store); } ngOnInit(): void { + + } + + private buildForms() { + this.dataSettings = this.fb.group({}); + if (this.widgetType === widgetType.timeseries || this.widgetType === widgetType.alarm) { + this.dataSettings.addControl('useDashboardTimewindow', this.fb.control(null)); + this.dataSettings.addControl('displayTimewindow', this.fb.control(null)); + this.dataSettings.addControl('timewindow', this.fb.control(null)); + this.dataSettings.get('useDashboardTimewindow').valueChanges.subscribe((value: boolean) => { + if (value) { + this.dataSettings.get('displayTimewindow').disable({emitEvent: false}); + this.dataSettings.get('timewindow').disable({emitEvent: false}); + } else { + this.dataSettings.get('displayTimewindow').enable({emitEvent: false}); + this.dataSettings.get('timewindow').enable({emitEvent: false}); + } + }); + if (this.widgetType === widgetType.alarm) { + this.dataSettings.addControl('alarmSearchStatus', this.fb.control(null)); + this.dataSettings.addControl('alarmsPollingInterval', this.fb.control(null, + [Validators.required, Validators.min(1)])); + } + } + if (this.isDataEnabled) { + if (this.widgetType !== widgetType.rpc && + this.widgetType !== widgetType.alarm && + this.widgetType !== widgetType.static) { + this.dataSettings.addControl('datasources', + this.fb.array([])); + } + } + this.dataSettings.valueChanges.subscribe( + () => this.updateModel() + ); } registerOnChange(fn: any): void { @@ -159,6 +209,10 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont writeValue(value: WidgetConfigComponentData): void { this.modelValue = value; if (this.modelValue) { + if (this.widgetType !== this.modelValue.widgetType) { + this.widgetType = this.modelValue.widgetType; + this.buildForms(); + } const config = this.modelValue.config; const layout = this.modelValue.layout; if (config) { @@ -184,23 +238,42 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont }, null, 2); this.units = config.units; this.decimals = config.decimals; - this.useDashboardTimewindow = isDefined(config.useDashboardTimewindow) ? - config.useDashboardTimewindow : true; - this.displayTimewindow = isDefined(config.displayTimewindow) ? - config.displayTimewindow : true; - this.timewindow = config.timewindow; this.actions = config.actions; if (!this.actions) { this.actions = {}; } + if (this.widgetType === widgetType.timeseries || this.widgetType === widgetType.alarm) { + const useDashboardTimewindow = isDefined(config.useDashboardTimewindow) ? + config.useDashboardTimewindow : true; + this.dataSettings.patchValue( + { useDashboardTimewindow }, {emitEvent: false} + ); + if (useDashboardTimewindow) { + this.dataSettings.get('displayTimewindow').disable({emitEvent: false}); + this.dataSettings.get('timewindow').disable({emitEvent: false}); + } else { + this.dataSettings.get('displayTimewindow').enable({emitEvent: false}); + this.dataSettings.get('timewindow').enable({emitEvent: false}); + } + this.dataSettings.patchValue( + { displayTimewindow: isDefined(config.displayTimewindow) ? + config.displayTimewindow : true }, {emitEvent: false} + ); + this.dataSettings.patchValue( + { timewindow: config.timewindow }, {emitEvent: false} + ); + } if (this.isDataEnabled) { if (this.widgetType !== widgetType.rpc && this.widgetType !== widgetType.alarm && this.widgetType !== widgetType.static) { + const datasourcesFormArray = this.dataSettings.get('datasources') as FormArray; + datasourcesFormArray.controls.length = 0; if (config.datasources) { - this.datasources = config.datasources; - } else { - this.datasources = []; + config.datasources.forEach((datasource) => { + datasourcesFormArray.controls.push(this.fb.control(datasource)); + }); + datasourcesFormArray.setValue(config.datasources, {emitEvent: false}); } } else if (this.widgetType === widgetType.rpc) { if (config.targetDeviceAliasIds && config.targetDeviceAliasIds.length > 0) { @@ -215,10 +288,14 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont this.targetDeviceAlias = null; } } else if (this.widgetType === widgetType.alarm) { - this.alarmSearchStatus = isDefined(config.alarmSearchStatus) ? - config.alarmSearchStatus : AlarmSearchStatus.ANY; - this.alarmsPollingInterval = isDefined(config.alarmsPollingInterval) ? - config.alarmsPollingInterval : 5; + this.dataSettings.patchValue( + { alarmSearchStatus: isDefined(config.alarmSearchStatus) ? + config.alarmSearchStatus : AlarmSearchStatus.ANY }, {emitEvent: false} + ); + this.dataSettings.patchValue( + { alarmsPollingInterval: isDefined(config.alarmsPollingInterval) ? + config.alarmsPollingInterval : 5}, {emitEvent: false} + ); if (config.alarmSource) { this.alarmSource = config.alarmSource; } else { @@ -255,13 +332,10 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont } } - public updateModel() { + private updateModel() { if (this.modelValue) { if (this.modelValue.config) { - const config = this.modelValue.config; - config.useDashboardTimewindow = this.useDashboardTimewindow; - config.displayTimewindow = this.displayTimewindow; - config.timewindow = this.timewindow; + Object.assign(this.modelValue.config, this.dataSettings.value); } this.propagateChange(this.modelValue); } @@ -271,12 +345,150 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont return this.widgetSettingsSchema && this.widgetSettingsSchema.schema; } + public removeDatasource(index: number) { + (this.dataSettings.get('datasources') as FormArray).removeAt(index); + } + + public addDatasource() { + let newDatasource: Datasource; + if (this.functionsOnly) { + newDatasource = deepClone(this.utils.getDefaultDatasource(this.dataKeySettingsSchema.schema)); + newDatasource.dataKeys = [this.generateDataKey('Sin', DataKeyType.function)]; + } else { + newDatasource = { type: DatasourceType.entity, + dataKeys: [] + }; + } + const datasourcesFormArray = this.dataSettings.get('datasources') as FormArray; + datasourcesFormArray.push(this.fb.control(newDatasource)); + } + + public generateDataKey(chip: any, type: DataKeyType): DataKey { + if (isObject(chip)) { + (chip as DataKey)._hash = Math.random(); + return chip; + } else { + let label: string = chip; + if (type === DataKeyType.alarm) { + const alarmField = alarmFields[label]; + if (alarmField) { + label = this.translate.instant(alarmField.name); + } + } + label = this.genNextLabel(label); + const result: DataKey = { + name: chip, + type, + label, + color: this.genNextColor(), + settings: {}, + _hash: Math.random() + }; + if (type === DataKeyType.function) { + result.name = 'f(x)'; + result.funcBody = this.utils.getPredefinedFunctionBody(chip); + if (!result.funcBody) { + result.funcBody = 'return prevValue + 1;'; + } + } + if (isDefined(this.dataKeySettingsSchema.schema)) { + result.settings = this.utils.generateObjectFromJsonSchema(this.dataKeySettingsSchema.schema); + } + return result; + } + } + + private genNextLabel(name: string): string { + let label = name; + let i = 1; + let matches = false; + const datasources = this.widgetType === widgetType.alarm ? [this.modelValue.config.alarmSource] : this.modelValue.config.datasources; + if (datasources) { + do { + matches = false; + datasources.forEach((datasource) => { + if (datasource && datasource.dataKeys) { + datasource.dataKeys.forEach((dataKey) => { + if (dataKey.label === label) { + i++; + label = name + ' ' + i; + matches = true; + } + }); + } + }); + } while (matches); + } + return label; + } + + private genNextColor(): string { + let i = 0; + const datasources = this.widgetType === widgetType.alarm ? [this.modelValue.config.alarmSource] : this.modelValue.config.datasources; + if (datasources) { + datasources.forEach((datasource) => { + if (datasource && datasource.dataKeys) { + i += datasource.dataKeys.length; + } + }); + } + return this.utils.getMaterialColor(i); + } + public validate(c: FormControl) { - return null; /*{ - targetDeviceAliasIds: { - valid: false, - }, - };*/ + if (!this.dataSettings.valid) { + return { + dataSettings: { + valid: false + } + }; + } else { + const config = this.modelValue.config; + if (this.widgetType === widgetType.rpc && this.isDataEnabled) { + if (!config.targetDeviceAliasIds || !config.targetDeviceAliasIds.length) { + return { + targetDeviceAliasIds: { + valid: false + } + }; + } + } else if (this.widgetType === widgetType.alarm && this.isDataEnabled) { + if (!config.alarmSource) { + return { + alarmSource: { + valid: false + } + }; + } + } else if (this.widgetType !== widgetType.static && this.isDataEnabled) { + if (!config.datasources || !config.datasources.length) { + return { + datasources: { + valid: false + } + }; + } + } + try { + JSON.parse(this.widgetStyle); + } catch (e) { + return { + widgetStyle: { + valid: false + } + }; + } + try { + JSON.parse(this.titleStyle); + } catch (e) { + return { + titleStyle: { + valid: false + } + }; + } + } + return null; } } diff --git a/ui-ngx/src/app/modules/home/models/widget-component.models.ts b/ui-ngx/src/app/modules/home/models/widget-component.models.ts index 1d12ea26e9..78c762debc 100644 --- a/ui-ngx/src/app/modules/home/models/widget-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/widget-component.models.ts @@ -121,6 +121,7 @@ export interface WidgetInfo extends WidgetTypeDescriptor, WidgetControllerDescri export interface WidgetConfigComponentData { config: WidgetConfig; layout: WidgetLayout; + widgetType: widgetType; } export const MissingWidgetType: WidgetInfo = { diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.component.html b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.component.html index 37029509e2..010a27985f 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.component.html +++ b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.component.html @@ -18,7 +18,8 @@
+ [ngClass]="{ 'tb-dashboard-toolbar-opened': toolbarOpened, + 'tb-dashboard-toolbar-closed': !toolbarOpened }">
diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.component.scss b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.component.scss index 1638e83886..54b5a389a1 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.component.scss +++ b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.component.scss @@ -93,15 +93,18 @@ div.tb-dashboard-page { @media #{$mat-gt-sm} { margin-top: $toolbar-height; } - - transition: margin-top .3s cubic-bezier(.55, 0, .55, .2); + &.tb-dashboard-toolbar-animated { + transition: margin-top .3s cubic-bezier(.55, 0, .55, .2); + } } } &.tb-dashboard-toolbar-closed { margin-top: 0; - transition: margin-top .3s cubic-bezier(.55, 0, .55, .2) .2s; + &.tb-dashboard-toolbar-animated { + transition: margin-top .3s cubic-bezier(.55, 0, .55, .2) .2s; + } } .tb-dashboard-layouts { diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.component.ts b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.component.ts index be102ae272..e311a9ebe6 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.component.ts +++ b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.component.ts @@ -60,6 +60,17 @@ import { import { WidgetComponentService } from '../../components/widget/widget-component.service'; import { FormBuilder, FormGroup, NgForm } from '@angular/forms'; import { ItemBufferService } from '@core/services/item-buffer.service'; +import { + DeviceCredentialsDialogComponent, + DeviceCredentialsDialogData +} from '@home/pages/device/device-credentials-dialog.component'; +import { DeviceCredentials } from '@shared/models/device.models'; +import { MatDialog } from '@angular/material/dialog'; +import { + EntityAliasesDialogComponent, + EntityAliasesDialogData +} from '@home/components/alias/entity-aliases-dialog.component'; +import { EntityAliases } from '@app/shared/models/alias.models'; @Component({ selector: 'tb-dashboard-page', @@ -89,6 +100,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC isAddingWidget = false; isToolbarOpened = false; + isToolbarOpenedAnimate = false; isRightLayoutOpened = false; editingWidget: Widget = null; @@ -189,7 +201,8 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC private widgetComponentService: WidgetComponentService, private dashboardService: DashboardService, private itembuffer: ItemBufferService, - private fb: FormBuilder) { + private fb: FormBuilder, + private dialog: MatDialog) { super(store); this.editingWidgetFormGroup = this.fb.group({ @@ -259,6 +272,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC this.isAddingWidget = false; this.isToolbarOpened = false; + this.isToolbarOpenedAnimate = false; this.isRightLayoutOpened = false; this.editingWidget = null; @@ -283,10 +297,12 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC } public openToolbar() { + this.isToolbarOpenedAnimate = true; this.isToolbarOpened = true; } public closeToolbar() { + this.isToolbarOpenedAnimate = true; this.isToolbarOpened = false; } @@ -420,8 +436,21 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC if ($event) { $event.stopPropagation(); } - // TODO: - this.dialogService.todo(); + this.dialog.open(EntityAliasesDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + entityAliases: deepClone(this.dashboard.configuration.entityAliases), + widgets: this.dashboardUtils.getWidgetsArray(this.dashboard), + isSingleEntityAlias: false + } + }).afterClosed().subscribe((entityAliases) => { + if (entityAliases) { + this.dashboard.configuration.entityAliases = entityAliases; + this.entityAliasesUpdated(); + } + }); } public openDashboardSettings($event: Event) { diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/edit-widget.component.html b/ui-ngx/src/app/modules/home/pages/dashboard/edit-widget.component.html index 8ee1481727..f13d6f63e7 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/edit-widget.component.html +++ b/ui-ngx/src/app/modules/home/pages/dashboard/edit-widget.component.html @@ -18,7 +18,6 @@
| null; - entityTypeValue: EntityType; - @Input() - set entityType(entityType: EntityType) { - if (this.entityTypeValue !== entityType) { - this.entityTypeValue = entityType; - this.reset(); - } - } + entityType: EntityType; private requiredValue: boolean; get required(): boolean { @@ -143,6 +136,17 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV ); } + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (propName === 'entityType') { + this.reset(); + } + } + } + } + ngAfterViewInit(): void { } @@ -159,7 +163,7 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV this.searchText = ''; if (value != null && value.length > 0) { this.modelValue = [...value]; - this.entityService.getEntities(this.entityTypeValue, value).subscribe( + this.entityService.getEntities(this.entityType, value).subscribe( (entities) => { this.entities = entities; this.entityListFormGroup.get('entities').setValue(this.entities); @@ -218,7 +222,7 @@ export class EntityListComponent implements ControlValueAccessor, OnInit, AfterV fetchEntities(searchText?: string): Observable>> { this.searchText = searchText; - return this.entityService.getEntitiesByNameFilter(this.entityTypeValue, searchText, + return this.entityService.getEntitiesByNameFilter(this.entityType, searchText, 50, '', false, true).pipe( map((data) => data ? data : [])); } diff --git a/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.html new file mode 100644 index 0000000000..83311c58e6 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.html @@ -0,0 +1,57 @@ + + + + + {{entitySubtype}} + close + + + + + + + + + + {{ translate.get(noSubtypesMathingText, {entitySubtype: searchText}) | async }} + + + + + {{ subtypeListEmptyText | translate }} + + diff --git a/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.scss b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.scss new file mode 100644 index 0000000000..c7191cf095 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.scss @@ -0,0 +1,22 @@ +/** + * 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 { + .mat-form-field-infix { + border-top: none; + } + } +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts new file mode 100644 index 0000000000..a98e06210c --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts @@ -0,0 +1,317 @@ +/// +/// 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 { + AfterContentInit, + AfterViewInit, + Component, + ElementRef, + forwardRef, + Input, OnDestroy, + OnInit, + SkipSelf, + ViewChild +} from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + FormControl, + FormGroup, + FormGroupDirective, + NG_VALUE_ACCESSOR, NgForm, Validators +} from '@angular/forms'; +import { Observable, of, Subscription, throwError } from 'rxjs'; +import { map, mergeMap, startWith, tap, share, pairwise, filter, publishReplay, refCount } from 'rxjs/operators'; +import {Store} from '@ngrx/store'; +import {AppState} from '@app/core/core.state'; +import {TranslateService} from '@ngx-translate/core'; +import { AliasEntityType, EntitySubtype, EntityType, entityTypeTranslations } 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'; +import { emptyPageData } from '@shared/models/page/page-data'; +import { AssetService } from '@core/http/asset.service'; +import { DeviceService } from '@core/http/device.service'; +import { EntityViewService } from '@core/http/entity-view.service'; +import { BroadcastService } from '@core/services/broadcast.service'; +import { MatChipInputEvent } from '@angular/material/chips'; +import { COMMA, ENTER, SEMICOLON } from '@angular/cdk/keycodes'; + +@Component({ + selector: 'tb-entity-subtype-list', + templateUrl: './entity-subtype-list.component.html', + styleUrls: ['./entity-subtype-list.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntitySubTypeListComponent), + multi: true + } + ] +}) +export class EntitySubTypeListComponent implements ControlValueAccessor, OnInit, AfterViewInit, OnDestroy { + + entitySubtypeListFormGroup: FormGroup; + + modelValue: Array | null; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + const newVal = coerceBooleanProperty(value); + if (this.requiredValue !== newVal) { + this.requiredValue = newVal; + this.updateValidators(); + } + } + + @Input() + disabled: boolean; + + @Input() + entityType: EntityType; + + @ViewChild('entitySubtypeInput', {static: false}) entitySubtypeInput: ElementRef; + @ViewChild('entitySubtypeAutocomplete', {static: false}) entitySubtypeAutocomplete: MatAutocomplete; + @ViewChild('chipList', {static: true}) chipList: MatChipList; + + entitySubtypeList: Array = []; + filteredEntitySubtypeList: Observable>; + entitySubtypes: Observable>; + + private broadcastSubscription: Subscription; + + placeholder: string; + secondaryPlaceholder: string; + noSubtypesMathingText: string; + subtypeListEmptyText: string; + + separatorKeysCodes: number[] = [ENTER, COMMA, SEMICOLON]; + + private searchText = ''; + + private dirty = false; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + private broadcast: BroadcastService, + public translate: TranslateService, + private assetService: AssetService, + private deviceService: DeviceService, + private entityViewService: EntityViewService, + private fb: FormBuilder) { + this.entitySubtypeListFormGroup = this.fb.group({ + entitySubtypeList: [this.entitySubtypeList, this.required ? [Validators.required] : []], + entitySubtype: [null] + }); + } + + updateValidators() { + this.entitySubtypeListFormGroup.get('entitySubtypeList').setValidators(this.required ? [Validators.required] : []); + this.entitySubtypeListFormGroup.get('entitySubtypeList').updateValueAndValidity(); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + + switch (this.entityType) { + case EntityType.ASSET: + this.placeholder = this.required ? this.translate.instant('asset.enter-asset-type') + : this.translate.instant('asset.any-asset'); + this.secondaryPlaceholder = '+' + this.translate.instant('asset.asset-type'); + this.noSubtypesMathingText = 'asset.no-asset-types-matching'; + this.subtypeListEmptyText = 'asset.asset-type-list-empty'; + this.broadcastSubscription = this.broadcast.on('assetSaved', () => { + this.entitySubtypes = null; + }); + break; + case EntityType.DEVICE: + this.placeholder = this.required ? this.translate.instant('device.enter-device-type') + : this.translate.instant('device.any-device'); + this.secondaryPlaceholder = '+' + this.translate.instant('device.device-type'); + this.noSubtypesMathingText = 'device.no-device-types-matching'; + this.subtypeListEmptyText = 'device.device-type-list-empty'; + this.broadcastSubscription = this.broadcast.on('deviceSaved', () => { + this.entitySubtypes = null; + }); + break; + case EntityType.ENTITY_VIEW: + this.placeholder = this.required ? this.translate.instant('entity-view.enter-entity-view-type') + : this.translate.instant('entity-view.any-entity-view'); + this.secondaryPlaceholder = '+' + this.translate.instant('entity-view.entity-view-type'); + this.noSubtypesMathingText = 'entity-view.no-entity-view-types-matching'; + this.subtypeListEmptyText = 'entity-view.entity-view-type-list-empty'; + this.broadcastSubscription = this.broadcast.on('entityViewSaved', () => { + this.entitySubtypes = null; + }); + break; + } + + this.filteredEntitySubtypeList = this.entitySubtypeListFormGroup.get('entitySubtype').valueChanges + .pipe( + map(value => value ? value : ''), + mergeMap(name => this.fetchEntitySubtypes(name) ), + share() + ); + } + + ngAfterViewInit(): void { + } + + ngOnDestroy(): void { + if (this.broadcastSubscription) { + this.broadcastSubscription.unsubscribe(); + } + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.entitySubtypeListFormGroup.disable({emitEvent: false}); + } else { + this.entitySubtypeListFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: Array | null): void { + this.searchText = ''; + if (value != null && value.length > 0) { + this.modelValue = [...value]; + this.entitySubtypeList = [...value]; + this.entitySubtypeListFormGroup.get('entitySubtypeList').setValue(this.entitySubtypeList); + } else { + this.entitySubtypeList = []; + this.entitySubtypeListFormGroup.get('entitySubtypeList').setValue(this.entitySubtypeList); + this.modelValue = null; + } + this.dirty = true; + } + + add(entitySubtype: string): void { + if (!this.modelValue || this.modelValue.indexOf(entitySubtype) === -1) { + if (!this.modelValue) { + this.modelValue = []; + } + this.modelValue.push(entitySubtype); + this.entitySubtypeList.push(entitySubtype); + this.entitySubtypeListFormGroup.get('entitySubtypeList').setValue(this.entitySubtypeList); + } + this.propagateChange(this.modelValue); + this.clear(); + } + + chipAdd(event: MatChipInputEvent): void { + const value = event.value; + if ((value || '').trim()) { + this.add(value.trim()); + } + this.clear(''); + } + + remove(entitySubtype: string) { + const index = this.entitySubtypeList.indexOf(entitySubtype); + if (index >= 0) { + this.entitySubtypeList.splice(index, 1); + this.entitySubtypeListFormGroup.get('entitySubtypeList').setValue(this.entitySubtypeList); + this.modelValue.splice(index, 1); + if (!this.modelValue.length) { + this.modelValue = null; + } + this.propagateChange(this.modelValue); + this.clear(); + } + } + + selected(event: MatAutocompleteSelectedEvent): void { + this.add(event.option.viewValue); + this.clear(''); + } + + displayEntitySubtypeFn(entitySubtype?: string): string | undefined { + return entitySubtype ? entitySubtype : undefined; + } + + fetchEntitySubtypes(searchText?: string): Observable> { + this.searchText = searchText; + return this.getEntitySubtypes().pipe( + map(subTypes => { + let result = subTypes.filter( subType => { + return searchText ? subType.toUpperCase().startsWith(searchText.toUpperCase()) : true; + }); + if (!result.length) { + result = [searchText]; + } + return result; + }) + ); + } + + getEntitySubtypes(): Observable> { + if (!this.entitySubtypes) { + let subTypesObservable: Observable>; + switch (this.entityType) { + case EntityType.ASSET: + subTypesObservable = this.assetService.getAssetTypes(false, true); + break; + case EntityType.DEVICE: + subTypesObservable = this.deviceService.getDeviceTypes(false, true); + break; + case EntityType.ENTITY_VIEW: + subTypesObservable = this.entityViewService.getEntityViewTypes(false, true); + break; + } + if (subTypesObservable) { + this.entitySubtypes = subTypesObservable.pipe( + map(subTypes => subTypes.map(subType => subType.type)), + publishReplay(1), + refCount() + ); + } else { + return throwError(null); + } + } + return this.entitySubtypes; + } + + onFocus() { + if (this.dirty) { + this.entitySubtypeListFormGroup.get('entitySubtype').updateValueAndValidity({onlySelf: true, emitEvent: true}); + this.dirty = false; + } + } + + clear(value: string = '') { + this.entitySubtypeInput.nativeElement.value = value; + this.entitySubtypeListFormGroup.get('entitySubtype').patchValue(value, {emitEvent: true}); + setTimeout(() => { + this.entitySubtypeInput.nativeElement.blur(); + this.entitySubtypeInput.nativeElement.focus(); + }, 0); + } + +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-type-list.component.html new file mode 100644 index 0000000000..f1810d1e78 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-type-list.component.html @@ -0,0 +1,54 @@ + + + + + {{entityType.name}} + close + + + + + + + + + + {{ translate.get('entity.no-entity-types-matching', {entityType: searchText}) | async }} + + + + + {{ 'entity.entity-type-list-empty' | translate }} + + diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-list.component.ts b/ui-ngx/src/app/shared/components/entity/entity-type-list.component.ts new file mode 100644 index 0000000000..8360699f60 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-type-list.component.ts @@ -0,0 +1,262 @@ +/// +/// 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 { + AfterContentInit, + AfterViewInit, + Component, + ElementRef, + forwardRef, + Input, + OnInit, + SkipSelf, + ViewChild +} from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + FormControl, + FormGroup, + FormGroupDirective, + NG_VALUE_ACCESSOR, NgForm, Validators +} from '@angular/forms'; +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'; +import {TranslateService} from '@ngx-translate/core'; +import { AliasEntityType, EntityType, entityTypeTranslations } 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'; +import { emptyPageData } from '@shared/models/page/page-data'; + +interface EntityTypeInfo { + name: string; + value: EntityType; +} + +@Component({ + selector: 'tb-entity-type-list', + templateUrl: './entity-type-list.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityTypeListComponent), + multi: true + } + ] +}) +export class EntityTypeListComponent implements ControlValueAccessor, OnInit, AfterViewInit { + + entityTypeListFormGroup: FormGroup; + + modelValue: Array | null; + + private requiredValue: boolean; + get required(): boolean { + return this.requiredValue; + } + @Input() + set required(value: boolean) { + const newVal = coerceBooleanProperty(value); + if (this.requiredValue !== newVal) { + this.requiredValue = newVal; + this.updateValidators(); + } + } + + @Input() + disabled: boolean; + + @Input() + allowedEntityTypes: Array; + + @Input() + ignoreAuthorityFilter: boolean; + + @ViewChild('entityTypeInput', {static: false}) entityTypeInput: ElementRef; + @ViewChild('entityTypeAutocomplete', {static: false}) entityTypeAutocomplete: MatAutocomplete; + @ViewChild('chipList', {static: true}) chipList: MatChipList; + + allEntityTypeList: Array = []; + entityTypeList: Array = []; + filteredEntityTypeList: Observable>; + + placeholder: string; + secondaryPlaceholder: string; + + private searchText = ''; + + private dirty = false; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + public translate: TranslateService, + private entityService: EntityService, + private fb: FormBuilder) { + this.entityTypeListFormGroup = this.fb.group({ + entityTypeList: [this.entityTypeList, this.required ? [Validators.required] : []], + entityType: [null] + }); + } + + updateValidators() { + this.entityTypeListFormGroup.get('entityTypeList').setValidators(this.required ? [Validators.required] : []); + this.entityTypeListFormGroup.get('entityTypeList').updateValueAndValidity(); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + + this.placeholder = this.required ? this.translate.instant('entity.enter-entity-type') + : this.translate.instant('entity.any-entity'); + this.secondaryPlaceholder = '+' + this.translate.instant('entity.entity-type'); + + let entityTypes: Array; + if (this.ignoreAuthorityFilter && this.allowedEntityTypes + && this.allowedEntityTypes.length) { + entityTypes = []; + this.allowedEntityTypes.forEach((entityTypeValue) => { + entityTypes.push(entityTypeValue); + }); + } else { + entityTypes = this.entityService.prepareAllowedEntityTypesList(this.allowedEntityTypes) as Array; + } + + entityTypes.forEach((entityType) => { + this.allEntityTypeList.push({ + name: this.translate.instant(entityTypeTranslations.get(entityType).type), + value: entityType as EntityType + }); + }); + + this.filteredEntityTypeList = this.entityTypeListFormGroup.get('entityType').valueChanges + .pipe( + // startWith>(''), + tap((value) => { + if (value && typeof value !== 'string') { + this.add(value); + } else if (value === null) { + this.clear(this.entityTypeInput.nativeElement.value); + } + }), + filter((value) => typeof value === 'string'), + map((value) => value ? (typeof value === 'string' ? value : value.name) : ''), + mergeMap(name => this.fetchEntityTypes(name) ), + share() + ); + } + + ngAfterViewInit(): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.entityTypeListFormGroup.disable({emitEvent: false}); + } else { + this.entityTypeListFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: Array | null): void { + this.searchText = ''; + if (value != null && value.length > 0) { + this.modelValue = [...value]; + this.entityTypeList = []; + value.forEach((entityType) => { + this.entityTypeList.push({ + name: this.translate.instant(entityTypeTranslations.get(entityType).type), + value: entityType + }); + }); + this.entityTypeListFormGroup.get('entityTypeList').setValue(this.entityTypeList); + } else { + this.entityTypeList = []; + this.entityTypeListFormGroup.get('entityTypeList').setValue(this.entityTypeList); + this.modelValue = null; + } + this.dirty = true; + } + + add(entityType: EntityTypeInfo): void { + if (!this.modelValue || this.modelValue.indexOf(entityType.value) === -1) { + if (!this.modelValue) { + this.modelValue = []; + } + this.modelValue.push(entityType.value); + this.entityTypeList.push(entityType); + this.entityTypeListFormGroup.get('entityTypeList').setValue(this.entityTypeList); + } + this.propagateChange(this.modelValue); + this.clear(); + } + + remove(entityType: EntityTypeInfo) { + const index = this.entityTypeList.indexOf(entityType); + if (index >= 0) { + this.entityTypeList.splice(index, 1); + this.entityTypeListFormGroup.get('entityTypeList').setValue(this.entityTypeList); + this.modelValue.splice(index, 1); + if (!this.modelValue.length) { + this.modelValue = null; + } + this.propagateChange(this.modelValue); + this.clear(); + } + } + + displayEntityTypeFn(entityType?: EntityTypeInfo): string | undefined { + return entityType ? entityType.name : undefined; + } + + fetchEntityTypes(searchText?: string): Observable> { + this.searchText = searchText; + let result = this.allEntityTypeList; + if (searchText && searchText.length) { + result = this.allEntityTypeList.filter((entityTypeInfo) => entityTypeInfo.name.toLowerCase().includes(searchText.toLowerCase())); + } + return of(result); + } + + onFocus() { + if (this.dirty) { + this.entityTypeListFormGroup.get('entityType').updateValueAndValidity({onlySelf: true, emitEvent: true}); + this.dirty = false; + } + } + + clear(value: string = '') { + this.entityTypeInput.nativeElement.value = value; + this.entityTypeListFormGroup.get('entityType').patchValue(value, {emitEvent: true}); + setTimeout(() => { + this.entityTypeInput.nativeElement.blur(); + this.entityTypeInput.nativeElement.focus(); + }, 0); + } + +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html index c01683d02d..d10f7ad647 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - + {{ 'entity.type' | translate }} diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.scss b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.scss index bb18c2c7b6..2dee7d9fa8 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.scss +++ b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.scss @@ -15,3 +15,13 @@ */ :host { } + +:host ::ng-deep { + .mat-form-field-infix { + width: 100%; + } + .mat-select-value { + max-width: 200px; + min-width: 100px; + } +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts index 386a8bd424..4b445572a6 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts @@ -45,8 +45,14 @@ export class EntityTypeSelectComponent implements ControlValueAccessor, OnInit, @Input() useAliasEntityTypes: boolean; + private showLabelValue: boolean; + get showLabel(): boolean { + return this.showLabelValue; + } @Input() - showLabel: boolean; + set showLabel(value: boolean) { + this.showLabelValue = coerceBooleanProperty(value); + } private requiredValue: boolean; get required(): boolean { diff --git a/ui-ngx/src/app/shared/components/tb-error.component.ts b/ui-ngx/src/app/shared/components/tb-error.component.ts new file mode 100644 index 0000000000..968cd3aa57 --- /dev/null +++ b/ui-ngx/src/app/shared/components/tb-error.component.ts @@ -0,0 +1,67 @@ +/// +/// 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, Input } from '@angular/core'; +import { trigger, state, style, transition, animate } from '@angular/animations'; + +@Component({ + selector: 'tb-error', + template: ` +
+ + {{message}} + +
+ `, + styles: [` + :host { + height: 24px; + } + `], + animations: [ + trigger('animation', [ + state('show', style({ + opacity: 1, + })), + state('hide', style({ + opacity: 0, + transform: 'translateY(-1rem)' + })), + transition('show => hide', animate('200ms ease-out')), + transition('* => show', animate('200ms ease-in')) + + ]), + ] +}) +export class TbErrorComponent { + errorValue: any; + state: any; + message; + + @Input() + set error(value) { + if (value && !this.message) { + this.message = value; + this.state = 'hide'; + setTimeout(() => { + this.state = 'show'; + }); + } else { + this.errorValue = value; + this.state = value ? 'show' : 'hide'; + } + } +} diff --git a/ui-ngx/src/app/shared/models/alias.models.ts b/ui-ngx/src/app/shared/models/alias.models.ts index d6d9a58c20..8e10e4a369 100644 --- a/ui-ngx/src/app/shared/models/alias.models.ts +++ b/ui-ngx/src/app/shared/models/alias.models.ts @@ -14,7 +14,10 @@ /// limitations under the License. /// -import { EntityType } from '@shared/models/entity-type.models'; +import { AliasEntityType, EntityType } from '@shared/models/entity-type.models'; +import { EntityId } from '@shared/models/id/entity-id'; +import { EntitySearchDirection, EntityTypeFilter } from '@shared/models/relation.models'; +import { EntityInfo } from './entity.models'; export enum AliasFilterType { singleEntity = 'singleEntity', @@ -46,15 +49,87 @@ export const aliasFilterTypeTranslationMap = new Map( ] ); -export interface EntityAliasFilter { - type: AliasFilterType; - entityType: EntityType; - resolveMultiple: boolean; +export interface SingleEntityFilter { + singleEntity?: EntityId; +} + +export interface EntityListFilter { + entityType?: EntityType; entityList?: string[]; +} + +export interface EntityNameFilter { + entityType?: EntityType; entityNameFilter?: string; - [key: string]: any; - // TODO: +} + +export interface StateEntityFilter { + stateEntityParamName?: string; + defaultStateEntity?: EntityId; +} + +export interface AssetTypeFilter { + assetType?: string; + assetNameFilter?: string; +} + +export interface DeviceTypeFilter { + deviceType?: string; + deviceNameFilter?: string; +} + +export interface EntityViewFilter { + entityViewType?: string; + entityViewNameFilter?: string; +} +export interface RelationsQueryFilter { + rootStateEntity?: boolean; + stateEntityParamName?: string; + defaultStateEntity?: EntityId; + rootEntity?: EntityId; + direction?: EntitySearchDirection; + filters?: Array; + maxLevel?: number; +} + +export interface EntitySearchQueryFilter { + rootStateEntity?: boolean; + stateEntityParamName?: string; + defaultStateEntity?: EntityId; + rootEntity?: EntityId; + relationType?: string; + direction?: EntitySearchDirection; +} + +export interface AssetSearchQueryFilter extends EntitySearchQueryFilter { + assetTypes?: string[]; +} + +export interface DeviceSearchQueryFilter extends EntitySearchQueryFilter { + deviceTypes?: string[]; +} + +export interface EntityViewSearchQueryFilter extends EntitySearchQueryFilter { + entityViewTypes?: string[]; +} + +export type EntityFilters = + SingleEntityFilter & + EntityListFilter & + EntityNameFilter & + StateEntityFilter & + AssetTypeFilter & + DeviceTypeFilter & + EntityViewFilter & + RelationsQueryFilter & + AssetSearchQueryFilter & + DeviceSearchQueryFilter & + EntityViewSearchQueryFilter; + +export interface EntityAliasFilter extends EntityFilters { + type?: AliasFilterType; + resolveMultiple: boolean; } export interface EntityAlias { @@ -62,9 +137,14 @@ export interface EntityAlias { alias: string; filter: EntityAliasFilter; [key: string]: any; - // TODO: } export interface EntityAliases { [id: string]: EntityAlias; } + +export interface EntityAliasFilterResult { + entities: Array; + stateEntity: boolean; + entityParamName?: string; +} diff --git a/ui-ngx/src/app/shared/models/asset.models.ts b/ui-ngx/src/app/shared/models/asset.models.ts index b048317d22..c7b5c41563 100644 --- a/ui-ngx/src/app/shared/models/asset.models.ts +++ b/ui-ngx/src/app/shared/models/asset.models.ts @@ -19,6 +19,7 @@ import {AssetId} from './id/asset-id'; import {TenantId} from '@shared/models/id/tenant-id'; import {CustomerId} from '@shared/models/id/customer-id'; import {DeviceCredentialsId} from '@shared/models/id/device-credentials-id'; +import { EntitySearchQuery } from '@shared/models/relation.models'; export interface Asset extends BaseData { tenantId: TenantId; @@ -32,3 +33,7 @@ export interface AssetInfo extends Asset { customerTitle: string; customerIsPublic: boolean; } + +export interface AssetSearchQuery extends EntitySearchQuery { + assetTypes: Array; +} diff --git a/ui-ngx/src/app/shared/models/device.models.ts b/ui-ngx/src/app/shared/models/device.models.ts index be0d1637da..6ddd015a24 100644 --- a/ui-ngx/src/app/shared/models/device.models.ts +++ b/ui-ngx/src/app/shared/models/device.models.ts @@ -19,6 +19,7 @@ import {DeviceId} from './id/device-id'; import {TenantId} from '@shared/models/id/tenant-id'; import {CustomerId} from '@shared/models/id/customer-id'; import {DeviceCredentialsId} from '@shared/models/id/device-credentials-id'; +import { EntitySearchQuery } from '@shared/models/relation.models'; export interface Device extends BaseData { tenantId: TenantId; @@ -52,3 +53,7 @@ export interface DeviceCredentials extends BaseData { credentialsId: string; credentialsValue: string; } + +export interface DeviceSearchQuery extends EntitySearchQuery { + deviceTypes: Array; +} diff --git a/ui-ngx/src/app/shared/models/entity-view.models.ts b/ui-ngx/src/app/shared/models/entity-view.models.ts index 88f4a1aa64..aa03ea4039 100644 --- a/ui-ngx/src/app/shared/models/entity-view.models.ts +++ b/ui-ngx/src/app/shared/models/entity-view.models.ts @@ -20,6 +20,7 @@ import {TenantId} from '@shared/models/id/tenant-id'; import {CustomerId} from '@shared/models/id/customer-id'; import {EntityViewId} from '@shared/models/id/entity-view-id'; import {EntityId} from '@shared/models/id/entity-id'; +import { EntitySearchQuery } from '@shared/models/relation.models'; export interface AttributesEntityView { cs: Array; @@ -48,3 +49,7 @@ export interface EntityViewInfo extends EntityView { customerTitle: string; customerIsPublic: boolean; } + +export interface EntityViewSearchQuery extends EntitySearchQuery { + entityViewTypes: Array; +} diff --git a/ui-ngx/src/app/shared/models/relation.models.ts b/ui-ngx/src/app/shared/models/relation.models.ts index a33e66fd3c..233742b286 100644 --- a/ui-ngx/src/app/shared/models/relation.models.ts +++ b/ui-ngx/src/app/shared/models/relation.models.ts @@ -62,8 +62,8 @@ export interface RelationsSearchParameters { rootId: string; rootType: EntityType; direction: EntitySearchDirection; - relationTypeGroup: RelationTypeGroup; - maxLevel: number; + relationTypeGroup?: RelationTypeGroup; + maxLevel?: number; } export interface EntityRelationsQuery { @@ -71,6 +71,11 @@ export interface EntityRelationsQuery { filters: Array; } +export interface EntitySearchQuery { + parameters: RelationsSearchParameters; + relationType: string; +} + export interface EntityRelation { from: EntityId; to: EntityId; diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index c578d07bca..57d376dd28 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -96,6 +96,9 @@ import { DashboardSelectPanelComponent } from '@shared/components/dashboard-sele import { DashboardSelectComponent } from '@shared/components/dashboard-select.component'; import { WidgetsBundleSelectComponent } from './components/widgets-bundle-select.component'; import { KeyboardShortcutPipe } from './pipe/keyboard-shortcut.pipe'; +import { TbErrorComponent } from './components/tb-error.component'; +import { EntityTypeListComponent } from './components/entity/entity-type-list.component'; +import { EntitySubTypeListComponent } from './components/entity/entity-subtype-list.component'; @NgModule({ providers: [ @@ -122,6 +125,7 @@ import { KeyboardShortcutPipe } from './pipe/keyboard-shortcut.pipe'; HelpComponent, TbCheckboxComponent, TbSnackBarComponent, + TbErrorComponent, BreadcrumbComponent, UserMenuComponent, TimewindowComponent, @@ -135,12 +139,14 @@ import { KeyboardShortcutPipe } from './pipe/keyboard-shortcut.pipe'; DashboardAutocompleteComponent, EntitySubTypeAutocompleteComponent, EntitySubTypeSelectComponent, + EntitySubTypeListComponent, EntityAutocompleteComponent, EntityListComponent, EntityTypeSelectComponent, EntitySelectComponent, EntityKeysListComponent, EntityListSelectComponent, + EntityTypeListComponent, RelationTypeAutocompleteComponent, SocialSharePanelComponent, JsonObjectEditComponent, @@ -207,6 +213,7 @@ import { KeyboardShortcutPipe } from './pipe/keyboard-shortcut.pipe'; TbAnchorComponent, HelpComponent, TbCheckboxComponent, + TbErrorComponent, BreadcrumbComponent, UserMenuComponent, TimewindowComponent, @@ -218,12 +225,14 @@ import { KeyboardShortcutPipe } from './pipe/keyboard-shortcut.pipe'; DashboardAutocompleteComponent, EntitySubTypeAutocompleteComponent, EntitySubTypeSelectComponent, + EntitySubTypeListComponent, EntityAutocompleteComponent, EntityListComponent, EntityTypeSelectComponent, EntitySelectComponent, EntityKeysListComponent, EntityListSelectComponent, + EntityTypeListComponent, RelationTypeAutocompleteComponent, SocialSharePanelComponent, JsonObjectEditComponent, diff --git a/ui-ngx/src/styles.scss b/ui-ngx/src/styles.scss index d3583f98e7..21f754eddf 100644 --- a/ui-ngx/src/styles.scss +++ b/ui-ngx/src/styles.scss @@ -246,6 +246,15 @@ div { color: #808080; } +.mat-caption { + &.tb-required::after { + font-size: 10px; + color: rgba(0, 0, 0, .54); + vertical-align: top; + content: " *"; + } +} + pre.tb-highlight { display: block; padding: 15px; @@ -303,6 +312,15 @@ pre.tb-highlight { margin-top: 32px; } +.tb-prompt { + display: flex; + font-size: 18px; + font-weight: 400; + line-height: 18px; + color: rgba(0, 0, 0, .38); + text-transform: uppercase; +} + .tb-fullscreen { position: fixed !important; top: 0;