From 9600cc04201bf43d4ffd28e3fa2c0c41f367ae10 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 30 Jun 2020 19:37:50 +0300 Subject: [PATCH] UI: Data query - key filters initial implementation. --- ui-ngx/src/app/core/api/alias-controller.ts | 51 +++- .../app/core/api/entity-data-subscription.ts | 6 +- .../src/app/core/api/entity-data.service.ts | 7 + ui-ngx/src/app/core/api/widget-api.models.ts | 16 +- .../src/app/core/api/widget-subscription.ts | 45 +++- .../core/services/dashboard-utils.service.ts | 3 + .../app/core/services/item-buffer.service.ts | 107 +++++++- ui-ngx/src/app/core/services/utils.service.ts | 3 + .../alias/entity-alias-select.component.html | 5 +- .../alias/entity-alias-select.component.ts | 3 + ...dd-widget-to-dashboard-dialog.component.ts | 6 +- .../attribute/attribute-table.component.ts | 5 +- .../boolean-filter-predicate.component.html | 30 +++ .../boolean-filter-predicate.component.ts | 101 +++++++ ...lex-filter-predicate-dialog.component.html | 59 +++++ ...mplex-filter-predicate-dialog.component.ts | 94 +++++++ .../complex-filter-predicate.component.html | 28 ++ .../complex-filter-predicate.component.ts | 97 +++++++ .../filter/filter-dialog.component.html | 63 +++++ .../filter/filter-dialog.component.ts | 137 ++++++++++ .../filter-predicate-list.component.html | 57 ++++ .../filter/filter-predicate-list.component.ts | 159 +++++++++++ .../filter/filter-predicate.component.html | 42 +++ .../filter/filter-predicate.component.ts | 93 +++++++ .../filter/filter-select.component.html | 61 +++++ .../filter/filter-select.component.models.ts | 22 ++ .../filter/filter-select.component.scss | 24 ++ .../filter/filter-select.component.ts | 249 ++++++++++++++++++ .../filter/filters-dialog.component.html | 99 +++++++ .../filter/filters-dialog.component.scss | 44 ++++ .../filter/filters-dialog.component.ts | 241 +++++++++++++++++ .../filter/key-filter-dialog.component.html | 86 ++++++ .../filter/key-filter-dialog.component.ts | 105 ++++++++ .../filter/key-filter-list.component.html | 53 ++++ .../filter/key-filter-list.component.ts | 165 ++++++++++++ .../numeric-filter-predicate.component.html | 31 +++ .../numeric-filter-predicate.component.ts | 99 +++++++ .../string-filter-predicate.component.html | 34 +++ .../string-filter-predicate.component.ts | 104 ++++++++ .../home/components/home-components.module.ts | 40 ++- .../import-export/import-export.service.ts | 23 +- .../components/widget/data-keys.component.ts | 8 +- .../lib/entities-table-widget.component.ts | 2 +- .../widget/widget-config.component.html | 28 +- .../widget/widget-config.component.models.ts | 3 +- .../widget/widget-config.component.scss | 10 +- .../widget/widget-config.component.ts | 31 ++- .../components/widget/widget.component.ts | 14 + .../add-widget-dialog.component.html | 1 + .../dashboard/dashboard-page.component.html | 6 + .../dashboard/dashboard-page.component.ts | 35 ++- .../dashboard/edit-widget.component.html | 1 + .../pages/widget/widget-library.component.ts | 1 + .../src/app/shared/models/dashboard.models.ts | 2 + .../app/shared/models/query/query.models.ts | 157 +++++++++++ ui-ngx/src/app/shared/models/widget.models.ts | 1 + .../assets/locale/locale.constant-en_US.json | 61 +++++ 57 files changed, 3015 insertions(+), 43 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/filter/boolean-filter-predicate.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/boolean-filter-predicate.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-select.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-select.component.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-select.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/filter/filter-select.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.html create mode 100644 ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.ts diff --git a/ui-ngx/src/app/core/api/alias-controller.ts b/ui-ngx/src/app/core/api/alias-controller.ts index 124844a885..d329b12dac 100644 --- a/ui-ngx/src/app/core/api/alias-controller.ts +++ b/ui-ngx/src/app/core/api/alias-controller.ts @@ -24,7 +24,7 @@ import { EntityAliases } from '@shared/models/alias.models'; import { EntityInfo } from '@shared/models/entity.models'; import { map, mergeMap } from 'rxjs/operators'; import { - defaultEntityDataPageLink, singleEntityDataPageLink, + defaultEntityDataPageLink, FilterInfo, filterInfoToKeyFilters, Filters, KeyFilter, singleEntityDataPageLink, updateDatasourceFromEntityInfo } from '@shared/models/query/query.models'; @@ -33,10 +33,14 @@ export class AliasController implements IAliasController { entityAliasesChangedSubject = new Subject>(); entityAliasesChanged: Observable> = this.entityAliasesChangedSubject.asObservable(); + filtersChangedSubject = new Subject>(); + filtersChanged: Observable> = this.filtersChangedSubject.asObservable(); + private entityAliasResolvedSubject = new Subject(); entityAliasResolved: Observable = this.entityAliasResolvedSubject.asObservable(); entityAliases: EntityAliases; + filters: Filters; resolvedAliases: {[aliasId: string]: AliasInfo} = {}; resolvedAliasesObservable: {[aliasId: string]: Observable} = {}; @@ -46,11 +50,12 @@ export class AliasController implements IAliasController { constructor(private utils: UtilsService, private entityService: EntityService, private stateControllerHolder: StateControllerHolder, - private origEntityAliases: EntityAliases) { + private origEntityAliases: EntityAliases, + private origFilters: Filters) { this.entityAliases = deepClone(this.origEntityAliases); + this.filters = deepClone(this.origFilters); } - updateEntityAliases(newEntityAliases: EntityAliases) { const changedAliasIds: Array = []; for (const aliasId of Object.keys(newEntityAliases)) { @@ -73,6 +78,26 @@ export class AliasController implements IAliasController { } } + updateFilters(newFilters: Filters) { + const changedFilterIds: Array = []; + for (const filterId of Object.keys(newFilters)) { + const newFilter = newFilters[filterId]; + const prevFilter = this.filters[filterId]; + if (!isEqual(newFilter, prevFilter)) { + changedFilterIds.push(filterId); + } + } + for (const filterId of Object.keys(this.filters)) { + if (!newFilters[filterId]) { + changedFilterIds.push(filterId); + } + } + this.filters = deepClone(newFilters); + if (changedFilterIds.length) { + this.filtersChangedSubject.next(changedFilterIds); + } + } + updateAliases(aliasIds?: Array) { if (!aliasIds) { aliasIds = []; @@ -116,6 +141,23 @@ export class AliasController implements IAliasController { return this.entityAliases; } + getFilters(): Filters { + return this.filters; + } + + getFilterInfo(filterId: string): FilterInfo { + return this.filters[filterId]; + } + + getKeyFilters(filterId: string): Array { + const filter = this.getFilterInfo(filterId); + if (filter) { + return filterInfoToKeyFilters(filter); + } else { + return []; + } + } + getEntityAliasId(aliasName: string): string { for (const aliasId of Object.keys(this.entityAliases)) { const alias = this.entityAliases[aliasId]; @@ -191,6 +233,9 @@ export class AliasController implements IAliasController { private resolveDatasource(datasource: Datasource, forceFilter = false): Observable { const newDatasource = deepClone(datasource); if (newDatasource.type === DatasourceType.entity) { + if (newDatasource.filterId) { + newDatasource.keyFilters = this.getKeyFilters(newDatasource.filterId); + } if (newDatasource.entityAliasId) { return this.getAliasInfo(newDatasource.entityAliasId).pipe( mergeMap((aliasInfo) => { diff --git a/ui-ngx/src/app/core/api/entity-data-subscription.ts b/ui-ngx/src/app/core/api/entity-data-subscription.ts index bcd1829763..da329a9f12 100644 --- a/ui-ngx/src/app/core/api/entity-data-subscription.ts +++ b/ui-ngx/src/app/core/api/entity-data-subscription.ts @@ -36,7 +36,7 @@ import { } from '@shared/models/telemetry/telemetry.models'; import { UtilsService } from '@core/services/utils.service'; import { EntityDataListener, EntityDataLoadResult } from '@core/api/entity-data.service'; -import { deepClone, isDefinedAndNotNull, isObject, objectHashCode } from '@core/utils'; +import { deepClone, isDefined, isDefinedAndNotNull, isObject, objectHashCode } from '@core/utils'; import { PageData } from '@shared/models/page/page-data'; import { DataAggregator } from '@core/api/data-aggregator'; import { NULL_UUID } from '@shared/models/id/has-uuid'; @@ -497,7 +497,9 @@ export class EntityDataSubscription { private onDataUpdate(update: Array) { for (const entityData of update) { const dataIndex = this.entityIdToDataIndex[entityData.entityId.id]; - this.processEntityData(entityData, dataIndex, true, this.notifyListener.bind(this)); + if (isDefined(dataIndex) && dataIndex >= 0) { + this.processEntityData(entityData, dataIndex, true, this.notifyListener.bind(this)); + } } } diff --git a/ui-ngx/src/app/core/api/entity-data.service.ts b/ui-ngx/src/app/core/api/entity-data.service.ts index 675348d37a..9159033a92 100644 --- a/ui-ngx/src/app/core/api/entity-data.service.ts +++ b/ui-ngx/src/app/core/api/entity-data.service.ts @@ -86,6 +86,13 @@ export class EntityDataService { if (datasource.type === DatasourceType.entity && (!datasource.entityFilter || !pageLink)) { return of(null); } + if (datasource.keyFilters) { + if (keyFilters) { + keyFilters = keyFilters.concat(datasource.keyFilters); + } else { + keyFilters = datasource.keyFilters; + } + } listener.subscription = this.createSubscription(listener, pageLink, keyFilters, true); if (listener.subscriptionType === widgetType.timeseries) { diff --git a/ui-ngx/src/app/core/api/widget-api.models.ts b/ui-ngx/src/app/core/api/widget-api.models.ts index 9a2a877369..274c807b5c 100644 --- a/ui-ngx/src/app/core/api/widget-api.models.ts +++ b/ui-ngx/src/app/core/api/widget-api.models.ts @@ -40,7 +40,14 @@ import { EntityAliases } from '@shared/models/alias.models'; import { EntityInfo } from '@app/shared/models/entity.models'; import { IDashboardComponent } from '@home/models/dashboard-component.models'; import * as moment_ from 'moment'; -import { EntityData, EntityDataPageLink, EntityFilter, KeyFilter } from '@shared/models/query/query.models'; +import { + EntityData, + EntityDataPageLink, + EntityFilter, + Filter, FilterInfo, + Filters, + KeyFilter +} from '@shared/models/query/query.models'; import { EntityDataService } from '@core/api/entity-data.service'; import { PageData } from '@shared/models/page/page-data'; import { TranslateService } from '@ngx-translate/core'; @@ -93,6 +100,7 @@ export interface StateEntityInfo { export interface IAliasController { entityAliasesChanged: Observable>; entityAliasResolved: Observable; + filtersChanged: Observable>; getAliasInfo(aliasId: string): Observable; getEntityAliasId(aliasName: string): string; getInstantAliasInfo(aliasId: string): AliasInfo; @@ -100,8 +108,12 @@ export interface IAliasController { resolveDatasources(datasources: Array, singleEntity?: boolean): Observable>; resolveAlarmSource(alarmSource: Datasource): Observable; getEntityAliases(): EntityAliases; + getFilters(): Filters; + getFilterInfo(filterId: string): FilterInfo; + getKeyFilters(filterId: string): Array; updateCurrentAliasEntity(aliasId: string, currentEntity: EntityInfo); updateEntityAliases(entityAliases: EntityAliases); + updateFilters(filters: Filters); updateAliases(aliasIds?: Array); dashboardStateChanged(); } @@ -273,6 +285,8 @@ export interface IWidgetSubscription { onAliasesChanged(aliasIds: Array): boolean; + onFiltersChanged(filterIds: Array): boolean; + onDashboardTimewindowChanged(dashboardTimewindow: Timewindow): void; updateDataVisibility(index: number): void; diff --git a/ui-ngx/src/app/core/api/widget-subscription.ts b/ui-ngx/src/app/core/api/widget-subscription.ts index b4ef58e09c..cfcdbebff9 100644 --- a/ui-ngx/src/app/core/api/widget-subscription.ts +++ b/ui-ngx/src/app/core/api/widget-subscription.ts @@ -16,7 +16,8 @@ import { IWidgetSubscription, - SubscriptionEntityInfo, SubscriptionMessage, + SubscriptionEntityInfo, + SubscriptionMessage, WidgetSubscriptionCallbacks, WidgetSubscriptionContext, WidgetSubscriptionOptions @@ -57,7 +58,8 @@ import { EntityData, EntityDataPageLink, entityDataToEntityInfo, - KeyFilter, updateDatasourceFromEntityInfo + KeyFilter, + updateDatasourceFromEntityInfo } from '@shared/models/query/query.models'; import { map } from 'rxjs/operators'; @@ -523,6 +525,17 @@ export class WidgetSubscription implements IWidgetSubscription { return false; } + onFiltersChanged(filterIds: Array): boolean { + if (this.type !== widgetType.rpc) { + if (this.type === widgetType.alarm) { + return this.checkAlarmSourceFilters(filterIds); + } else { + return this.checkSubscriptionsFilters(filterIds); + } + } + return false; + } + private onDataUpdated(detectChanges?: boolean) { if (this.cafs.dataUpdated) { this.cafs.dataUpdated(); @@ -919,6 +932,14 @@ export class WidgetSubscription implements IWidgetSubscription { } } + private checkAlarmSourceFilters(filterIds: Array): boolean { + if (this.options.alarmSource && this.options.alarmSource.filterId) { + return filterIds.indexOf(this.options.alarmSource.filterId) > -1; + } else { + return false; + } + } + private checkSubscriptions(aliasIds: Array): boolean { let subscriptionsChanged = false; const datasources = this.options.datasources; @@ -939,6 +960,26 @@ export class WidgetSubscription implements IWidgetSubscription { return subscriptionsChanged; } + private checkSubscriptionsFilters(filterIds: Array): boolean { + let subscriptionsChanged = false; + const datasources = this.options.datasources; + if (datasources) { + for (const datasource of datasources) { + if (datasource.filterId) { + if (filterIds.indexOf(datasource.filterId) > -1) { + subscriptionsChanged = true; + break; + } + } + } + } + if (subscriptionsChanged && this.hasDataPageLink) { + subscriptionsChanged = false; + this.updateDataSubscriptions(); + } + return subscriptionsChanged; + } + private updateDataSubscriptions() { this.configuredDatasources = this.ctx.utils.validateDatasources(this.options.datasources); if (!this.ctx.aliasController) { 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 b3387f066e..0b21a3f9be 100644 --- a/ui-ngx/src/app/core/services/dashboard-utils.service.ts +++ b/ui-ngx/src/app/core/services/dashboard-utils.service.ts @@ -129,6 +129,9 @@ export class DashboardUtilsService { } dashboard.configuration = this.validateAndUpdateEntityAliases(dashboard.configuration, datasourcesByAliasId, targetDevicesByAliasId); + if (!dashboard.configuration.filters) { + dashboard.configuration.filters = {}; + } if (isUndefined(dashboard.configuration.timewindow)) { dashboard.configuration.timewindow = this.timeService.defaultTimewindow(); diff --git a/ui-ngx/src/app/core/services/item-buffer.service.ts b/ui-ngx/src/app/core/services/item-buffer.service.ts index 2b0d95e3be..7c0d13b867 100644 --- a/ui-ngx/src/app/core/services/item-buffer.service.ts +++ b/ui-ngx/src/app/core/services/item-buffer.service.ts @@ -26,6 +26,7 @@ import { map } from 'rxjs/operators'; import { FcRuleNode, ruleNodeTypeDescriptors } from '@shared/models/rule-node.models'; import { RuleChainService } from '@core/http/rule-chain.service'; import { RuleChainImport } from '@shared/models/rule-chain.models'; +import { Filter, FilterInfo, Filters, FiltersInfo } from '@shared/models/query/query.models'; const WIDGET_ITEM = 'widget_item'; const WIDGET_REFERENCE = 'widget_reference'; @@ -35,6 +36,7 @@ const RULE_CHAIN_IMPORT = 'rule_chain_import'; export interface WidgetItem { widget: Widget; aliasesInfo: AliasesInfo; + filtersInfo: FiltersInfo; originalSize: WidgetSize; originalColumns: number; } @@ -80,6 +82,9 @@ export class ItemBufferService { datasourceAliases: {}, targetDeviceAliases: {} }; + const filtersInfo: FiltersInfo = { + datasourceFilters: {} + }; const originalColumns = this.getOriginalColumns(dashboard, sourceState, sourceLayout); const originalSize = this.getOriginalSize(dashboard, sourceState, sourceLayout, widget); if (widget.config && dashboard.configuration @@ -108,9 +113,25 @@ export class ItemBufferService { } } } + if (widget.config && dashboard.configuration + && dashboard.configuration.filters) { + let filter: Filter; + if (widget.config.datasources) { + for (let i = 0; i < widget.config.datasources.length; i++) { + const datasource = widget.config.datasources[i]; + if (datasource.type === DatasourceType.entity && datasource.filterId) { + filter = dashboard.configuration.filters[datasource.filterId]; + if (filter) { + filtersInfo.datasourceFilters[i] = this.prepareFilterInfo(filter); + } + } + } + } + } return { widget, aliasesInfo, + filtersInfo, originalSize, originalColumns }; @@ -145,11 +166,13 @@ export class ItemBufferService { public pasteWidget(targetDashboard: Dashboard, targetState: string, targetLayout: DashboardLayoutId, position: WidgetPosition, - onAliasesUpdateFunction: () => void): Observable { + onAliasesUpdateFunction: () => void, + onFiltersUpdateFunction: () => void): Observable { const widgetItem: WidgetItem = this.storeGet(WIDGET_ITEM); if (widgetItem) { const widget = widgetItem.widget; const aliasesInfo = widgetItem.aliasesInfo; + const filtersInfo = widgetItem.filtersInfo; const originalColumns = widgetItem.originalColumns; const originalSize = widgetItem.originalSize; let targetRow = -1; @@ -160,9 +183,9 @@ export class ItemBufferService { } widget.id = this.utils.guid(); return this.addWidgetToDashboard(targetDashboard, targetState, - targetLayout, widget, aliasesInfo, - onAliasesUpdateFunction, originalColumns, - originalSize, targetRow, targetColumn).pipe( + targetLayout, widget, aliasesInfo, filtersInfo, + onAliasesUpdateFunction, onFiltersUpdateFunction, + originalColumns, originalSize, targetRow, targetColumn).pipe( map(() => widget) ); } else { @@ -186,7 +209,7 @@ export class ItemBufferService { } return this.addWidgetToDashboard(targetDashboard, targetState, targetLayout, widget, null, - null, originalColumns, + null, null, null, originalColumns, originalSize, targetRow, targetColumn).pipe( map(() => widget) ); @@ -201,7 +224,9 @@ export class ItemBufferService { public addWidgetToDashboard(dashboard: Dashboard, targetState: string, targetLayout: DashboardLayoutId, widget: Widget, aliasesInfo: AliasesInfo, + filtersInfo: FiltersInfo, onAliasesUpdateFunction: () => void, + onFiltersUpdateFunction: () => void, originalColumns: number, originalSize: WidgetSize, row: number, @@ -214,6 +239,7 @@ export class ItemBufferService { } theDashboard = this.dashboardUtils.validateAndUpdateDashboard(theDashboard); let callAliasUpdateFunction = false; + let callFilterUpdateFunction = false; if (aliasesInfo) { const newEntityAliases = this.updateAliases(theDashboard, widget, aliasesInfo); const aliasesUpdated = !isEqual(newEntityAliases, theDashboard.configuration.entityAliases); @@ -224,11 +250,24 @@ export class ItemBufferService { } } } + if (filtersInfo) { + const newFilters = this.updateFilters(theDashboard, widget, filtersInfo); + const filtersUpdated = !isEqual(newFilters, theDashboard.configuration.filters); + if (filtersUpdated) { + theDashboard.configuration.filters = newFilters; + if (onFiltersUpdateFunction) { + callFilterUpdateFunction = true; + } + } + } this.dashboardUtils.addWidgetToLayout(theDashboard, targetState, targetLayout, widget, originalColumns, originalSize, row, column); if (callAliasUpdateFunction) { onAliasesUpdateFunction(); } + if (callFilterUpdateFunction) { + onFiltersUpdateFunction(); + } return of(theDashboard); } @@ -368,6 +407,13 @@ export class ItemBufferService { }; } + private prepareFilterInfo(filter: Filter): FilterInfo { + return { + filter: filter.filter, + keyFilters: filter.keyFilters + }; + } + private prepareWidgetReference(dashboard: Dashboard, sourceState: string, sourceLayout: DashboardLayoutId, widget: Widget): WidgetReference { const originalColumns = this.getOriginalColumns(dashboard, sourceState, sourceLayout); @@ -401,6 +447,19 @@ export class ItemBufferService { return entityAliases; } + private updateFilters(dashboard: Dashboard, widget: Widget, filtersInfo: FiltersInfo): Filters { + const filters = deepClone(dashboard.configuration.filters); + let filterInfo: FilterInfo; + let newFilterId: string; + for (const datasourceIndexStr of Object.keys(filtersInfo.datasourceFilters)) { + const datasourceIndex = Number(datasourceIndexStr); + filterInfo = filtersInfo.datasourceFilters[datasourceIndex]; + newFilterId = this.getFilterId(filters, filterInfo); + widget.config.datasources[datasourceIndex].filterId = newFilterId; + } + return filters; + } + private isEntityAliasEqual(alias1: EntityAliasInfo, alias2: EntityAliasInfo): boolean { return isEqual(alias1.filter, alias2.filter); } @@ -439,6 +498,44 @@ export class ItemBufferService { return newAlias; } + private isFilterEqual(filter1: FilterInfo, filter2: FilterInfo): boolean { + return isEqual(filter1.keyFilters, filter2.keyFilters); + } + + private getFilterId(filters: Filters, filterInfo: FilterInfo): string { + let newFilterId: string; + for (const filterId of Object.keys(filters)) { + if (this.isFilterEqual(filters[filterId], filterInfo)) { + newFilterId = filterId; + break; + } + } + if (!newFilterId) { + const newFilterName = this.createFilterName(filters, filterInfo.filter); + newFilterId = this.utils.guid(); + filters[newFilterId] = {id: newFilterId, filter: newFilterName, keyFilters: filterInfo.keyFilters}; + } + return newFilterId; + } + + private createFilterName(filters: Filters, filter: string): string { + let c = 0; + let newFilter = filter; + let unique = false; + while (!unique) { + unique = true; + for (const entFilterId of Object.keys(filters)) { + const entFilter = filters[entFilterId]; + if (newFilter === entFilter.filter) { + c++; + newFilter = filter + c; + unique = false; + } + } + } + return newFilter; + } + private storeSet(key: string, elem: any) { localStorage.setItem(this.getNamespacedKey(key), JSON.stringify(elem)); } diff --git a/ui-ngx/src/app/core/services/utils.service.ts b/ui-ngx/src/app/core/services/utils.service.ts index 39ebb880db..3613c0f484 100644 --- a/ui-ngx/src/app/core/services/utils.service.ts +++ b/ui-ngx/src/app/core/services/utils.service.ts @@ -254,6 +254,9 @@ export class UtilsService { if (datasource.type === DatasourceType.entity && datasource.entityId) { datasource.name = datasource.entityName; } + if (!datasource.dataKeys) { + datasource.dataKeys = []; + } }); return datasources; } diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-alias-select.component.html b/ui-ngx/src/app/modules/home/components/alias/entity-alias-select.component.html index e9dd1fbd0c..c9624f6d25 100644 --- a/ui-ngx/src/app/modules/home/components/alias/entity-alias-select.component.html +++ b/ui-ngx/src/app/modules/home/components/alias/entity-alias-select.component.html @@ -15,8 +15,9 @@ limitations under the License. --> - - + + { return this.dashboardService.saveDashboard(theDashboard); diff --git a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts index 3e6825edff..55506c7cc2 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts @@ -80,6 +80,7 @@ import { AddWidgetToDashboardDialogData } from '@home/components/attribute/add-widget-to-dashboard-dialog.component'; import { deepClone } from '@core/utils'; +import { Filters } from '@shared/models/query/query.models'; @Component({ @@ -390,9 +391,11 @@ export class AttributeTableComponent extends PageComponent implements AfterViewI } }; + const filters: Filters = {}; + this.aliasController = new AliasController(this.utils, this.entityService, - () => stateController, entitiAliases); + () => stateController, entitiAliases, filters); const dataKeyType: DataKeyType = this.attributeScope === LatestTelemetry.LATEST_TELEMETRY ? DataKeyType.timeseries : DataKeyType.attribute; diff --git a/ui-ngx/src/app/modules/home/components/filter/boolean-filter-predicate.component.html b/ui-ngx/src/app/modules/home/components/filter/boolean-filter-predicate.component.html new file mode 100644 index 0000000000..b77de60689 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/boolean-filter-predicate.component.html @@ -0,0 +1,30 @@ + +
+ + filter.operation.operation + + + {{booleanOperationTranslations.get(booleanOperationEnum[operation]) | translate}} + + + + + {{ 'filter.value' | translate }} + +
diff --git a/ui-ngx/src/app/modules/home/components/filter/boolean-filter-predicate.component.ts b/ui-ngx/src/app/modules/home/components/filter/boolean-filter-predicate.component.ts new file mode 100644 index 0000000000..889d474bf4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/boolean-filter-predicate.component.ts @@ -0,0 +1,101 @@ +/// +/// Copyright © 2016-2020 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { + BooleanFilterPredicate, + BooleanOperation, booleanOperationTranslationMap, + FilterPredicateType +} from '@shared/models/query/query.models'; +import { isDefined } from '@core/utils'; + +@Component({ + selector: 'tb-boolean-filter-predicate', + templateUrl: './boolean-filter-predicate.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => BooleanFilterPredicateComponent), + multi: true + } + ] +}) +export class BooleanFilterPredicateComponent implements ControlValueAccessor, OnInit { + + @Input() disabled: boolean; + + @Input() userMode: boolean; + + booleanFilterPredicateFormGroup: FormGroup; + + booleanOperations = Object.keys(BooleanOperation); + booleanOperationEnum = BooleanOperation; + booleanOperationTranslations = booleanOperationTranslationMap; + + private propagateChange = null; + + constructor(private fb: FormBuilder) { + } + + ngOnInit(): void { + this.booleanFilterPredicateFormGroup = this.fb.group({ + operation: [BooleanOperation.EQUAL, [Validators.required]], + value: [false] + }); + if (this.userMode) { + this.booleanFilterPredicateFormGroup.get('operation').disable({emitEvent: false}); + } + this.booleanFilterPredicateFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState?(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.booleanFilterPredicateFormGroup.disable({emitEvent: false}); + } else { + this.booleanFilterPredicateFormGroup.enable({emitEvent: false}); + } + } + + writeValue(predicate: BooleanFilterPredicate): void { + this.booleanFilterPredicateFormGroup.get('operation').patchValue(predicate.operation, {emitEvent: false}); + this.booleanFilterPredicateFormGroup.get('value').patchValue(isDefined(predicate.value) ? predicate.value : false, {emitEvent: false}); + } + + private updateModel() { + let predicate: BooleanFilterPredicate = null; + if (this.booleanFilterPredicateFormGroup.valid) { + predicate = this.booleanFilterPredicateFormGroup.getRawValue(); + if (!isDefined(predicate.value)) { + predicate.value = false; + } + predicate.type = FilterPredicateType.BOOLEAN; + } + this.propagateChange(predicate); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.html b/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.html new file mode 100644 index 0000000000..ce17808ca5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.html @@ -0,0 +1,59 @@ + +
+ +

filter.complex-filter

+ + +
+
+
+ + filter.operation.operation + + + {{complexOperationTranslations.get(complexOperationEnum[operation]) | translate}} + + + + + +
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.ts b/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.ts new file mode 100644 index 0000000000..25c48ba212 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.ts @@ -0,0 +1,94 @@ +/// +/// Copyright © 2016-2020 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject, OnInit, SkipSelf } from '@angular/core'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormControl, FormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@app/shared/components/dialog.component'; +import { + BooleanOperation, booleanOperationTranslationMap, + ComplexFilterPredicate, ComplexOperation, complexOperationTranslationMap, + EntityKeyValueType, + FilterPredicateType +} from '@shared/models/query/query.models'; + +export interface ComplexFilterPredicateDialogData { + complexPredicate: ComplexFilterPredicate; + userMode: boolean; + disabled: boolean; + valueType: EntityKeyValueType; +} + +@Component({ + selector: 'tb-complex-filter-predicate-dialog', + templateUrl: './complex-filter-predicate-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: ComplexFilterPredicateDialogComponent}], + styleUrls: [] +}) +export class ComplexFilterPredicateDialogComponent extends + DialogComponent + implements OnInit, ErrorStateMatcher { + + complexFilterFormGroup: FormGroup; + + complexOperations = Object.keys(ComplexOperation); + complexOperationEnum = ComplexOperation; + complexOperationTranslations = complexOperationTranslationMap; + + submitted = false; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: ComplexFilterPredicateDialogData, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef, + private fb: FormBuilder) { + super(store, router, dialogRef); + + this.complexFilterFormGroup = this.fb.group( + { + operation: [this.data.complexPredicate.operation, [Validators.required]], + predicates: [this.data.complexPredicate.predicates, [Validators.required]] + } + ); + } + + 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); + } + + save(): void { + this.submitted = true; + if (this.complexFilterFormGroup.valid) { + const predicate: ComplexFilterPredicate = this.complexFilterFormGroup.getRawValue(); + predicate.type = FilterPredicateType.COMPLEX; + this.dialogRef.close(predicate); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.html b/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.html new file mode 100644 index 0000000000..bd71d0cd39 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.html @@ -0,0 +1,28 @@ + +
+ filter.complex-filter + +
diff --git a/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.ts b/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.ts new file mode 100644 index 0000000000..fd09c42169 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.ts @@ -0,0 +1,97 @@ +/// +/// Copyright © 2016-2020 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { ComplexFilterPredicate, EntityKeyValueType } from '@shared/models/query/query.models'; +import { MatDialog } from '@angular/material/dialog'; +import { + ComplexFilterPredicateDialogComponent, + ComplexFilterPredicateDialogData +} from '@home/components/filter/complex-filter-predicate-dialog.component'; +import { deepClone } from '@core/utils'; + +@Component({ + selector: 'tb-complex-filter-predicate', + templateUrl: './complex-filter-predicate.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ComplexFilterPredicateComponent), + multi: true + } + ] +}) +export class ComplexFilterPredicateComponent implements ControlValueAccessor, OnInit { + + @Input() disabled: boolean; + + @Input() userMode: boolean; + + @Input() valueType: EntityKeyValueType; + + private propagateChange = null; + + private complexFilterPredicate: ComplexFilterPredicate; + + constructor(private dialog: MatDialog) { + } + + ngOnInit(): void { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState?(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(predicate: ComplexFilterPredicate): void { + this.complexFilterPredicate = predicate; + } + + private openComplexFilterDialog() { + this.dialog.open(ComplexFilterPredicateDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + complexPredicate: deepClone(this.complexFilterPredicate), + disabled: this.disabled, + userMode: this.userMode, + valueType: this.valueType + } + }).afterClosed().subscribe( + (result) => { + if (result) { + this.complexFilterPredicate = result; + this.updateModel(); + } + } + ); + } + + private updateModel() { + this.propagateChange(this.complexFilterPredicate); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html new file mode 100644 index 0000000000..7e59314074 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html @@ -0,0 +1,63 @@ + +
+ +

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

+ + +
+ + +
+
+
+ + filter.name + + + {{ 'filter.name-required' | translate }} + + + {{ 'filter.duplicate-filter' | translate }} + + + + +
+
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts new file mode 100644 index 0000000000..ca2ae9b9fe --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts @@ -0,0 +1,137 @@ +/// +/// Copyright © 2016-2020 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject, OnInit, SkipSelf } from '@angular/core'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + FormBuilder, + FormControl, + FormGroup, + FormGroupDirective, + NgForm, + ValidatorFn, + Validators +} from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@app/shared/components/dialog.component'; +import { UtilsService } from '@core/services/utils.service'; +import { TranslateService } from '@ngx-translate/core'; +import { Filter, Filters } from '@shared/models/query/query.models'; + +export interface FilterDialogData { + isAdd: boolean; + userMode: boolean; + filters: Filters | Array; + filter?: Filter; +} + +@Component({ + selector: 'tb-filter-dialog', + templateUrl: './filter-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: FilterDialogComponent}], + styleUrls: [] +}) +export class FilterDialogComponent extends DialogComponent + implements OnInit, ErrorStateMatcher { + + isAdd: boolean; + userMode: boolean; + filters: Array; + + filter: Filter; + + filterFormGroup: FormGroup; + + submitted = false; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: FilterDialogData, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef, + private fb: FormBuilder, + private utils: UtilsService, + public translate: TranslateService) { + super(store, router, dialogRef); + this.isAdd = data.isAdd; + this.userMode = data.userMode; + if (Array.isArray(data.filters)) { + this.filters = data.filters; + } else { + this.filters = []; + for (const filterId of Object.keys(data.filters)) { + this.filters.push(data.filters[filterId]); + } + } + if (this.isAdd && !this.data.filter) { + this.filter = { + id: null, + filter: '', + keyFilters: [] + }; + } else { + this.filter = data.filter; + } + + this.filterFormGroup = this.fb.group({ + filter: [this.filter.filter, [this.validateDuplicateFilterName(), Validators.required]], + keyFilters: [this.filter.keyFilters, Validators.required] + }); + } + + validateDuplicateFilterName(): ValidatorFn { + return (c: FormControl) => { + const newFilter = c.value; + const found = this.filters.find((filter) => filter.filter === newFilter); + if (found) { + if (this.isAdd || this.filter.id !== found.id) { + return { + duplicateFilterName: { + 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); + } + + save(): void { + this.submitted = true; + this.filter.filter = this.filterFormGroup.get('filter').value; + this.filter.keyFilters = this.filterFormGroup.get('keyFilters').value; + if (this.isAdd) { + this.filter.id = this.utils.guid(); + } + this.dialogRef.close(this.filter); + } +} diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.html b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.html new file mode 100644 index 0000000000..a2f65cb337 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.html @@ -0,0 +1,57 @@ + +
+
+ + + +
+ filter.no-filters +
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.ts b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.ts new file mode 100644 index 0000000000..9a45ef2eec --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.ts @@ -0,0 +1,159 @@ +/// +/// Copyright © 2016-2020 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + FormArray, + FormBuilder, + FormGroup, + NG_VALUE_ACCESSOR, + Validators +} from '@angular/forms'; +import { Observable, of, Subscription } from 'rxjs'; +import { + ComplexFilterPredicate, + createDefaultFilterPredicate, + EntityKeyValueType, + KeyFilterPredicate +} from '@shared/models/query/query.models'; +import { + ComplexFilterPredicateDialogComponent, + ComplexFilterPredicateDialogData +} from '@home/components/filter/complex-filter-predicate-dialog.component'; +import { MatDialog } from '@angular/material/dialog'; + +@Component({ + selector: 'tb-filter-predicate-list', + templateUrl: './filter-predicate-list.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FilterPredicateListComponent), + multi: true + } + ] +}) +export class FilterPredicateListComponent implements ControlValueAccessor, OnInit { + + @Input() disabled: boolean; + + @Input() userMode: boolean; + + @Input() valueType: EntityKeyValueType; + + filterListFormGroup: FormGroup; + + private propagateChange = null; + + private valueChangeSubscription: Subscription = null; + + constructor(private fb: FormBuilder, + private dialog: MatDialog) { + } + + ngOnInit(): void { + this.filterListFormGroup = this.fb.group({}); + this.filterListFormGroup.addControl('predicates', + this.fb.array([])); + } + + predicatesFormArray(): FormArray { + return this.filterListFormGroup.get('predicates') as FormArray; + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState?(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.filterListFormGroup.disable({emitEvent: false}); + } else { + this.filterListFormGroup.enable({emitEvent: false}); + } + } + + writeValue(predicates: Array): void { + if (this.valueChangeSubscription) { + this.valueChangeSubscription.unsubscribe(); + } + const predicateControls: Array = []; + if (predicates) { + for (const predicate of predicates) { + predicateControls.push(this.fb.control(predicate, [Validators.required])); + } + } + this.filterListFormGroup.setControl('predicates', this.fb.array(predicateControls)); + this.valueChangeSubscription = this.filterListFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + if (this.disabled) { + this.filterListFormGroup.disable({emitEvent: false}); + } else { + this.filterListFormGroup.enable({emitEvent: false}); + } + } + + public removePredicate(index: number) { + (this.filterListFormGroup.get('predicates') as FormArray).removeAt(index); + } + + public addPredicate(complex: boolean) { + const predicatesFormArray = this.filterListFormGroup.get('predicates') as FormArray; + const predicate = createDefaultFilterPredicate(this.valueType, complex); + let observable: Observable; + if (complex) { + observable = this.openComplexFilterDialog(predicate as ComplexFilterPredicate); + } else { + observable = of(predicate); + } + observable.subscribe((result) => { + if (result) { + predicatesFormArray.push(this.fb.control(result, [Validators.required])); + } + }); + } + + private openComplexFilterDialog(predicate: ComplexFilterPredicate): Observable { + return this.dialog.open(ComplexFilterPredicateDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + complexPredicate: predicate, + disabled: this.disabled, + userMode: this.userMode, + valueType: this.valueType + } + }).afterClosed(); + } + + private updateModel() { + const predicates: Array = this.filterListFormGroup.getRawValue().predicates; + if (predicates.length) { + this.propagateChange(predicates); + } else { + this.propagateChange(null); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.html b/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.html new file mode 100644 index 0000000000..50497a71c2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.html @@ -0,0 +1,42 @@ + +
+ + + + + + + + + + + + + + + + +
diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.ts b/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.ts new file mode 100644 index 0000000000..8132e4aa70 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.ts @@ -0,0 +1,93 @@ +/// +/// Copyright © 2016-2020 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { + EntityKeyValueType, + FilterPredicateType, KeyFilterPredicate +} from '@shared/models/query/query.models'; + +@Component({ + selector: 'tb-filter-predicate', + templateUrl: './filter-predicate.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FilterPredicateComponent), + multi: true + } + ] +}) +export class FilterPredicateComponent implements ControlValueAccessor, OnInit { + + @Input() disabled: boolean; + + @Input() userMode: boolean; + + @Input() valueType: EntityKeyValueType; + + filterPredicateFormGroup: FormGroup; + + type: FilterPredicateType; + + filterPredicateType = FilterPredicateType; + + private propagateChange = null; + + constructor(private fb: FormBuilder) { + } + + ngOnInit(): void { + this.filterPredicateFormGroup = this.fb.group({ + predicate: [null, [Validators.required]] + }); + this.filterPredicateFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState?(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.filterPredicateFormGroup.disable({emitEvent: false}); + } else { + this.filterPredicateFormGroup.enable({emitEvent: false}); + } + } + + writeValue(predicate: KeyFilterPredicate): void { + this.type = predicate.type; + this.filterPredicateFormGroup.get('predicate').patchValue(predicate, {emitEvent: false}); + } + + private updateModel() { + let predicate: KeyFilterPredicate = null; + if (this.filterPredicateFormGroup.valid) { + predicate = this.filterPredicateFormGroup.getRawValue().predicate; + } + this.propagateChange(predicate); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-select.component.html b/ui-ngx/src/app/modules/home/components/filter/filter-select.component.html new file mode 100644 index 0000000000..65249441ed --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/filter-select.component.html @@ -0,0 +1,61 @@ + + + + + + + + + + +
+
+ filter.no-filters-found +
+ + + {{ translate.get('filter.no-filter-matching', + {filter: truncate.transform(searchText, true, 6, '...')}) | async }} + + + + filter.create-new-filter + +
+
+
+ + {{ 'filter.filter-required' | translate }} + +
diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-select.component.models.ts b/ui-ngx/src/app/modules/home/components/filter/filter-select.component.models.ts new file mode 100644 index 0000000000..db443aede9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/filter-select.component.models.ts @@ -0,0 +1,22 @@ +/// +/// Copyright © 2016-2020 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Observable } from 'rxjs'; +import { Filter } from '@shared/models/query/query.models'; + +export interface FilterSelectCallbacks { + createFilter: (filter: string) => Observable; +} diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-select.component.scss b/ui-ngx/src/app/modules/home/components/filter/filter-select.component.scss new file mode 100644 index 0000000000..c14adfc443 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/filter-select.component.scss @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2020 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + +} + +:host ::ng-deep { + .mat-form-field-infix { + border-top: none; + } +} diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-select.component.ts b/ui-ngx/src/app/modules/home/components/filter/filter-select.component.ts new file mode 100644 index 0000000000..1759c02291 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/filter-select.component.ts @@ -0,0 +1,249 @@ +/// +/// Copyright © 2016-2020 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { AfterViewInit, Component, ElementRef, forwardRef, Input, OnInit, SkipSelf, ViewChild } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + FormControl, + FormGroup, + FormGroupDirective, + NG_VALUE_ACCESSOR, + NgForm +} from '@angular/forms'; +import { Observable, of } from 'rxjs'; +import { map, mergeMap, share, tap } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { IAliasController } from '@core/api/widget-api.models'; +import { TruncatePipe } from '@shared/pipe/truncate.pipe'; +import { MatAutocomplete, MatAutocompleteTrigger } from '@angular/material/autocomplete'; +import { ENTER } from '@angular/cdk/keycodes'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { FilterSelectCallbacks } from '@home/components/filter/filter-select.component.models'; +import { Filter } from '@shared/models/query/query.models'; + +@Component({ + selector: 'tb-filter-select', + templateUrl: './filter-select.component.html', + styleUrls: ['./filter-select.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FilterSelectComponent), + multi: true + }, + { + provide: ErrorStateMatcher, + useExisting: FilterSelectComponent + }] +}) +export class FilterSelectComponent implements ControlValueAccessor, OnInit, AfterViewInit, ErrorStateMatcher { + + selectFilterFormGroup: FormGroup; + + modelValue: string | null; + + @Input() + aliasController: IAliasController; + + @Input() + callbacks: FilterSelectCallbacks; + + @Input() + showLabel: boolean; + + @ViewChild('filterAutocomplete') filterAutocomplete: MatAutocomplete; + @ViewChild('autocomplete', { read: MatAutocompleteTrigger }) autoCompleteTrigger: MatAutocompleteTrigger; + + + private requiredValue: boolean; + get tbRequired(): boolean { + return this.requiredValue; + } + @Input() + set tbRequired(value: boolean) { + this.requiredValue = coerceBooleanProperty(value); + } + + @Input() + disabled: boolean; + + @ViewChild('filterInput', {static: true}) filterInput: ElementRef; + + filterList: Array = []; + + filteredFilters: Observable>; + + searchText = ''; + + private dirty = false; + + private creatingFilter = false; + + private propagateChange = (v: any) => { }; + + constructor(private store: Store, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public translate: TranslateService, + public truncate: TruncatePipe, + private fb: FormBuilder) { + this.selectFilterFormGroup = this.fb.group({ + filter: [null] + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + ngOnInit() { + const filters = this.aliasController.getFilters(); + for (const filterId of Object.keys(filters)) { + this.filterList.push(filters[filterId]); + } + + this.filteredFilters = this.selectFilterFormGroup.get('filter').valueChanges + .pipe( + tap(value => { + let modelValue; + if (typeof value === 'string' || !value) { + modelValue = null; + } else { + modelValue = value; + } + this.updateView(modelValue); + if (value === null) { + this.clear(); + } + }), + map(value => value ? (typeof value === 'string' ? value : value.filter) : ''), + mergeMap(name => this.fetchFilters(name) ), + share() + ); + } + + isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this.errorStateMatcher.isErrorState(control, form); + const customErrorState = this.tbRequired && !this.modelValue; + return originalErrorState || customErrorState; + } + + ngAfterViewInit(): void {} + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.selectFilterFormGroup.disable({emitEvent: false}); + } else { + this.selectFilterFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: string | null): void { + this.searchText = ''; + let filter = null; + if (value != null) { + const filters = this.aliasController.getFilters(); + if (filters[value]) { + filter = filters[value]; + } + } + if (filter != null) { + this.modelValue = filter.id; + this.selectFilterFormGroup.get('filter').patchValue(filter, {emitEvent: false}); + } else { + this.modelValue = null; + this.selectFilterFormGroup.get('filter').patchValue('', {emitEvent: false}); + } + this.dirty = true; + } + + onFocus() { + if (this.dirty) { + this.selectFilterFormGroup.get('filter').updateValueAndValidity({onlySelf: true, emitEvent: true}); + this.dirty = false; + } + } + + updateView(value: Filter | null) { + const filterId = value ? value.id : null; + if (this.modelValue !== filterId) { + this.modelValue = filterId; + this.propagateChange(this.modelValue); + } + } + + displayFilterFn(filter?: Filter): string | undefined { + return filter ? filter.filter : undefined; + } + + fetchFilters(searchText?: string): Observable> { + this.searchText = searchText; + let result = this.filterList; + if (searchText && searchText.length) { + result = this.filterList.filter((filter) => filter.filter.toLowerCase().includes(searchText.toLowerCase())); + } + return of(result); + } + + clear(value: string = '') { + this.filterInput.nativeElement.value = value; + this.selectFilterFormGroup.get('filter').patchValue(value, {emitEvent: true}); + setTimeout(() => { + this.filterInput.nativeElement.blur(); + this.filterInput.nativeElement.focus(); + }, 0); + } + + textIsNotEmpty(text: string): boolean { + return (text && text != null && text.length > 0) ? true : false; + } + + filterEnter($event: KeyboardEvent) { + if ($event.keyCode === ENTER) { + $event.preventDefault(); + if (!this.modelValue) { + this.createFilter($event, this.searchText); + } + } + } + + createFilter($event: Event, filter: string) { + $event.preventDefault(); + this.creatingFilter = true; + if (this.callbacks && this.callbacks.createFilter) { + this.callbacks.createFilter(filter).subscribe((newFilter) => { + if (!newFilter) { + setTimeout(() => { + this.filterInput.nativeElement.blur(); + this.filterInput.nativeElement.focus(); + }, 0); + } else { + this.filterList.push(newFilter); + this.modelValue = newFilter.id; + this.selectFilterFormGroup.get('filter').patchValue(newFilter, {emitEvent: true}); + this.propagateChange(this.modelValue); + } + } + ); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html b/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html new file mode 100644 index 0000000000..7f8109c722 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html @@ -0,0 +1,99 @@ + +
+ +

{{ title | translate }}

+ + +
+ + +
+
+
+ +
+ filter.filter + +
+
+
+ +
+ {{$index + 1}}. +
+ + + + + {{ 'filter.filter-required' | translate }} + + + + +
+
+
+
+
+ + + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.scss b/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.scss new file mode 100644 index 0000000000..dced20db58 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.scss @@ -0,0 +1,44 @@ +/** + * Copyright © 2016-2020 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + + .tb-filters-header { + min-height: 40px; + padding: 0 11px; + margin: 5px; + + .tb-header-label { + font-size: 14px; + color: rgba(0, 0, 0, .570588); + } + } + + mat-divider{ + margin: -1px -24px; + } + + .tb-filter { + padding: 0 0 0 10px; + margin: 5px; + } +} + +:host ::ng-deep { + .mat-dialog-content { + padding-top: 0 !important; + padding-bottom: 0 !important; + } +} diff --git a/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.ts b/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.ts new file mode 100644 index 0000000000..333c2b61f4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.ts @@ -0,0 +1,241 @@ +/// +/// Copyright © 2016-2020 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject, OnInit, SkipSelf } from '@angular/core'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; +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 { DatasourceType, Widget } from '@shared/models/widget.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 { Filter, Filters, KeyFilterInfo } from '@shared/models/query/query.models'; +import { FilterDialogComponent, FilterDialogData } from '@home/components/filter/filter-dialog.component'; + +export interface FiltersDialogData { + filters: Filters; + widgets: Array; + isSingleFilter?: boolean; + isSingleWidget?: boolean; + disableAdd?: boolean; + singleFilter?: Filter; + customTitle?: string; +} + +@Component({ + selector: 'tb-filters-dialog', + templateUrl: './filters-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: FiltersDialogComponent}], + styleUrls: ['./filters-dialog.component.scss'] +}) +export class FiltersDialogComponent extends DialogComponent + implements OnInit, ErrorStateMatcher { + + title: string; + disableAdd: boolean; + + filterToWidgetsMap: {[filterId: string]: Array} = {}; + + filtersFormGroup: FormGroup; + + submitted = false; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: FiltersDialogData, + @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 : 'filter.filters'; + this.disableAdd = this.data.disableAdd; + + 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 filterId of Object.keys(this.data.filters)) { + this.filterToWidgetsMap[filterId] = widgetsTitleList; + } + } else { + this.data.widgets.forEach((widget) => { + const datasources = this.utils.validateDatasources(widget.config.datasources); + datasources.forEach((datasource) => { + if (datasource.type === DatasourceType.entity && datasource.filterId) { + widgetsTitleList = this.filterToWidgetsMap[datasource.filterId]; + if (!widgetsTitleList) { + widgetsTitleList = []; + this.filterToWidgetsMap[datasource.filterId] = widgetsTitleList; + } + widgetsTitleList.push(widget.config.title); + } + }); + }); + } + } + const filterControls: Array = []; + for (const filterId of Object.keys(this.data.filters)) { + const filter = this.data.filters[filterId]; + filterControls.push(this.createFilterFormControl(filterId, filter)); + } + + this.filtersFormGroup = this.fb.group({ + filters: this.fb.array(filterControls) + }); + } + + private createFilterFormControl(filterId: string, filter: Filter): AbstractControl { + const filterFormControl = this.fb.group({ + id: [filterId], + filter: [filter ? filter.filter : null, [Validators.required]], + keyFilters: [filter ? filter.keyFilters : [], [Validators.required]] + }); + return filterFormControl; + } + + + filtersFormArray(): FormArray { + return this.filtersFormGroup.get('filters') as FormArray; + } + + 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; + } + + removeFilter(index: number) { + const filter = (this.filtersFormGroup.get('filters').value as any[])[index]; + const widgetsTitleList = this.filterToWidgetsMap[filter.id]; + if (widgetsTitleList) { + let widgetsListHtml = ''; + for (const widgetTitle of widgetsTitleList) { + widgetsListHtml += '
\'' + widgetTitle + '\''; + } + const message = this.translate.instant('entity.unable-delete-filter-text', + {filter: filter.filter, widgetsList: widgetsListHtml}); + this.dialogs.alert(this.translate.instant('entity.unable-delete-filter-title'), + message, this.translate.instant('action.close'), true); + } else { + (this.filtersFormGroup.get('filters') as FormArray).removeAt(index); + this.filtersFormGroup.markAsDirty(); + } + } + + public addFilter() { + this.openFilterDialog(-1); + } + + public editFilter(index: number) { + this.openFilterDialog(index); + } + + private openFilterDialog(index: number) { + const isAdd = index === -1; + let filter; + const filtersArray = this.filtersFormGroup.get('filters').value as any[]; + if (!isAdd) { + filter = filtersArray[index]; + } + this.dialog.open(FilterDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + isAdd, + filters: filtersArray, + filter: isAdd ? null : deepClone(filter), + userMode: false + } + }).afterClosed().subscribe((result) => { + if (result) { + if (isAdd) { + (this.filtersFormGroup.get('filters') as FormArray) + .push(this.createFilterFormControl(result.id, result)); + } else { + const filterFormControl = (this.filtersFormGroup.get('filters') as FormArray).at(index); + filterFormControl.get('filter').patchValue(filter.filter); + filterFormControl.get('keyFilters').patchValue(filter.keyFilters); + } + this.filtersFormGroup.markAsDirty(); + } + }); + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + this.submitted = true; + const filters: Filters = {}; + const uniqueFilterList: {[filter: string]: string} = {}; + + let valid = true; + let message: string; + + const filtersArray = this.filtersFormGroup.get('filters').value as any[]; + for (const filterValue of filtersArray) { + const filterId: string = filterValue.id; + const filter: string = filterValue.filter; + const keyFilters: Array = filterValue.keyFilters; + if (uniqueFilterList[filter]) { + valid = false; + message = this.translate.instant('filter.duplicate-filter-error', {filter}); + break; + } else if (!keyFilters || !keyFilters.length) { + valid = false; + message = this.translate.instant('filter.missing-key-filters-error', {filter}); + break; + } else { + uniqueFilterList[filter] = filter; + filters[filterId] = {id: filterId, filter, keyFilters}; + } + } + if (valid) { + this.dialogRef.close(filters); + } else { + this.store.dispatch(new ActionNotificationShow( + { + message, + type: 'error' + })); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.html b/ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.html new file mode 100644 index 0000000000..0c210a4538 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.html @@ -0,0 +1,86 @@ + +
+ +

{{data.isAdd ? 'filter.add-key-filter' : 'filter.edit-key-filter'}}

+ + +
+
+
+
+
+ + filter.key-name + + + {{ 'filter.key-name-required' | translate }} + + + + filter.key-type.key-type + + + {{entityKeyTypeTranslations.get(type) | translate}} + + + +
+ + filter.value-type.value-type + + + + {{ entityKeyValueTypes.get(keyFilterFormGroup.get('valueType').value)?.name | translate }} + + + + {{ entityKeyValueTypes.get(entityKeyValueTypeEnum[valueType]).name | translate }} + + + + {{ 'filter.value-type-required' | translate }} + + +
+ + +
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.ts b/ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.ts new file mode 100644 index 0000000000..0e602f5dd1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.ts @@ -0,0 +1,105 @@ +/// +/// Copyright © 2016-2020 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject, OnInit, SkipSelf } from '@angular/core'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormControl, FormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@app/shared/components/dialog.component'; +import { + EntityKeyType, + entityKeyTypeTranslationMap, + EntityKeyValueType, + entityKeyValueTypesMap, + KeyFilterInfo +} from '@shared/models/query/query.models'; + +export interface KeyFilterDialogData { + keyFilter: KeyFilterInfo; + userMode: boolean; + isAdd: boolean; +} + +@Component({ + selector: 'tb-key-filter-dialog', + templateUrl: './key-filter-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: KeyFilterDialogComponent}], + styleUrls: [] +}) +export class KeyFilterDialogComponent extends + DialogComponent + implements OnInit, ErrorStateMatcher { + + keyFilterFormGroup: FormGroup; + + entityKeyTypes = [EntityKeyType.ENTITY_FIELD, EntityKeyType.ATTRIBUTE, EntityKeyType.TIME_SERIES]; + + entityKeyTypeTranslations = entityKeyTypeTranslationMap; + + entityKeyValueTypesKeys = Object.keys(EntityKeyValueType); + + entityKeyValueTypeEnum = EntityKeyValueType; + + entityKeyValueTypes = entityKeyValueTypesMap; + + submitted = false; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: KeyFilterDialogData, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef, + private fb: FormBuilder) { + super(store, router, dialogRef); + + this.keyFilterFormGroup = this.fb.group( + { + key: this.fb.group( + { + type: [this.data.keyFilter.key.type, [Validators.required]], + key: [this.data.keyFilter.key.key, [Validators.required]] + } + ), + valueType: [this.data.keyFilter.valueType, [Validators.required]], + predicates: [this.data.keyFilter.predicates, [Validators.required]] + } + ); + } + + 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); + } + + save(): void { + this.submitted = true; + if (this.keyFilterFormGroup.valid) { + const keyFilter: KeyFilterInfo = this.keyFilterFormGroup.getRawValue(); + this.dialogRef.close(keyFilter); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.html b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.html new file mode 100644 index 0000000000..90d150da53 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.html @@ -0,0 +1,53 @@ + +
+
+ {{ keyFilterControl.value.key.key }} + {{ keyFilterControl.value.key.type }} + + +
+ filter.no-key-filters +
+ +
+
diff --git a/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts new file mode 100644 index 0000000000..8842dc3020 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts @@ -0,0 +1,165 @@ +/// +/// Copyright © 2016-2020 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + FormArray, + FormBuilder, + FormGroup, + NG_VALUE_ACCESSOR, + Validators +} from '@angular/forms'; +import { Observable, Subscription } from 'rxjs'; +import { EntityKeyType, KeyFilterInfo } from '@shared/models/query/query.models'; +import { MatDialog } from '@angular/material/dialog'; +import { deepClone } from '@core/utils'; +import { KeyFilterDialogComponent, KeyFilterDialogData } from '@home/components/filter/key-filter-dialog.component'; + +@Component({ + selector: 'tb-key-filter-list', + templateUrl: './key-filter-list.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => KeyFilterListComponent), + multi: true + } + ] +}) +export class KeyFilterListComponent implements ControlValueAccessor, OnInit { + + @Input() disabled: boolean; + + @Input() userMode: boolean; + + keyFilterListFormGroup: FormGroup; + + private propagateChange = null; + + private valueChangeSubscription: Subscription = null; + + constructor(private fb: FormBuilder, + private dialog: MatDialog) { + } + + ngOnInit(): void { + this.keyFilterListFormGroup = this.fb.group({}); + this.keyFilterListFormGroup.addControl('keyFilters', + this.fb.array([])); + } + + keyFiltersFormArray(): FormArray { + return this.keyFilterListFormGroup.get('keyFilters') as FormArray; + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState?(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.keyFilterListFormGroup.disable({emitEvent: false}); + } else { + this.keyFilterListFormGroup.enable({emitEvent: false}); + } + } + + writeValue(keyFilters: Array): void { + if (this.valueChangeSubscription) { + this.valueChangeSubscription.unsubscribe(); + } + const keyFilterControls: Array = []; + if (keyFilters) { + for (const keyFilter of keyFilters) { + keyFilterControls.push(this.fb.control(keyFilter, [Validators.required])); + } + } + this.keyFilterListFormGroup.setControl('keyFilters', this.fb.array(keyFilterControls)); + this.valueChangeSubscription = this.keyFilterListFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + if (this.disabled) { + this.keyFilterListFormGroup.disable({emitEvent: false}); + } else { + this.keyFilterListFormGroup.enable({emitEvent: false}); + } + } + + public removeKeyFilter(index: number) { + (this.keyFilterListFormGroup.get('keyFilters') as FormArray).removeAt(index); + } + + public addKeyFilter() { + const keyFiltersFormArray = this.keyFilterListFormGroup.get('keyFilters') as FormArray; + this.openKeyFilterDialog(null).subscribe((result) => { + if (result) { + keyFiltersFormArray.push(this.fb.control(result, [Validators.required])); + } + }); + } + + public editKeyFilter(index: number) { + const keyFilter: KeyFilterInfo = + (this.keyFilterListFormGroup.get('keyFilters') as FormArray).at(index).value; + this.openKeyFilterDialog(keyFilter).subscribe( + (result) => { + if (result) { + (this.keyFilterListFormGroup.get('keyFilters') as FormArray).at(index).patchValue(result); + } + } + ); + } + + private openKeyFilterDialog(keyFilter?: KeyFilterInfo): Observable { + const isAdd = !keyFilter; + if (!keyFilter) { + keyFilter = { + key: { + key: '', + type: EntityKeyType.ATTRIBUTE + }, + valueType: null, + predicates: [] + }; + } + return this.dialog.open(KeyFilterDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + keyFilter: keyFilter ? deepClone(keyFilter): null, + userMode: this.userMode, + isAdd + } + }).afterClosed(); + } + + private updateModel() { + const keyFilters: Array = this.keyFilterListFormGroup.getRawValue().keyFilters; + if (keyFilters.length) { + this.propagateChange(keyFilters); + } else { + this.propagateChange(null); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.html b/ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.html new file mode 100644 index 0000000000..c754926684 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.html @@ -0,0 +1,31 @@ + +
+ + filter.operation.operation + + + {{numericOperationTranslations.get(numericOperationEnum[operation]) | translate}} + + + + + filter.value + + +
diff --git a/ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.ts b/ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.ts new file mode 100644 index 0000000000..71e004c834 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.ts @@ -0,0 +1,99 @@ +/// +/// Copyright © 2016-2020 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { + FilterPredicateType, NumericFilterPredicate, NumericOperation, numericOperationTranslationMap, +} from '@shared/models/query/query.models'; +import { isDefined } from '@core/utils'; + +@Component({ + selector: 'tb-numeric-filter-predicate', + templateUrl: './numeric-filter-predicate.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => NumericFilterPredicateComponent), + multi: true + } + ] +}) +export class NumericFilterPredicateComponent implements ControlValueAccessor, OnInit { + + @Input() disabled: boolean; + + @Input() userMode: boolean; + + numericFilterPredicateFormGroup: FormGroup; + + numericOperations = Object.keys(NumericOperation); + numericOperationEnum = NumericOperation; + numericOperationTranslations = numericOperationTranslationMap; + + private propagateChange = null; + + constructor(private fb: FormBuilder) { + } + + ngOnInit(): void { + this.numericFilterPredicateFormGroup = this.fb.group({ + operation: [NumericOperation.EQUAL, [Validators.required]], + value: [0, [Validators.required]] + }); + if (this.userMode) { + this.numericFilterPredicateFormGroup.get('operation').disable({emitEvent: false}); + } + this.numericFilterPredicateFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState?(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.numericFilterPredicateFormGroup.disable({emitEvent: false}); + } else { + this.numericFilterPredicateFormGroup.enable({emitEvent: false}); + } + } + + writeValue(predicate: NumericFilterPredicate): void { + this.numericFilterPredicateFormGroup.get('operation').patchValue(predicate.operation, {emitEvent: false}); + this.numericFilterPredicateFormGroup.get('value').patchValue(isDefined(predicate.value) ? predicate.value : 0, {emitEvent: false}); + } + + private updateModel() { + let predicate: NumericFilterPredicate = null; + if (this.numericFilterPredicateFormGroup.valid) { + predicate = this.numericFilterPredicateFormGroup.getRawValue(); + if (!predicate.value) { + predicate.value = 0; + } + predicate.type = FilterPredicateType.NUMERIC; + } + this.propagateChange(predicate); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.html b/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.html new file mode 100644 index 0000000000..a48d8a5f20 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.html @@ -0,0 +1,34 @@ + +
+ + filter.operation.operation + + + {{stringOperationTranslations.get(stringOperationEnum[operation]) | translate}} + + + + + {{ 'filter.ignore-case' | translate }} + + + filter.value + + +
diff --git a/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.ts b/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.ts new file mode 100644 index 0000000000..bc29782ff9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.ts @@ -0,0 +1,104 @@ +/// +/// Copyright © 2016-2020 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { + FilterPredicateType, + StringFilterPredicate, + StringOperation, + stringOperationTranslationMap +} from '@shared/models/query/query.models'; + +@Component({ + selector: 'tb-string-filter-predicate', + templateUrl: './string-filter-predicate.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => StringFilterPredicateComponent), + multi: true + } + ] +}) +export class StringFilterPredicateComponent implements ControlValueAccessor, OnInit { + + @Input() disabled: boolean; + + @Input() userMode: boolean; + + stringFilterPredicateFormGroup: FormGroup; + + stringOperations = Object.keys(StringOperation); + stringOperationEnum = StringOperation; + stringOperationTranslations = stringOperationTranslationMap; + + private propagateChange = null; + + constructor(private fb: FormBuilder) { + } + + ngOnInit(): void { + this.stringFilterPredicateFormGroup = this.fb.group({ + operation: [StringOperation.STARTS_WITH, [Validators.required]], + value: [''], + ignoreCase: [false] + }); + if (this.userMode) { + this.stringFilterPredicateFormGroup.get('operation').disable({emitEvent: false}); + this.stringFilterPredicateFormGroup.get('ignoreCase').disable({emitEvent: false}); + } + this.stringFilterPredicateFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState?(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.stringFilterPredicateFormGroup.disable({emitEvent: false}); + } else { + this.stringFilterPredicateFormGroup.enable({emitEvent: false}); + } + } + + writeValue(predicate: StringFilterPredicate): void { + this.stringFilterPredicateFormGroup.get('operation').patchValue(predicate.operation, {emitEvent: false}); + this.stringFilterPredicateFormGroup.get('value').patchValue(predicate.value ? predicate.value : '', {emitEvent: false}); + this.stringFilterPredicateFormGroup.get('ignoreCase').patchValue(predicate.ignoreCase, {emitEvent: false}); + } + + private updateModel() { + let predicate: StringFilterPredicate = null; + if (this.stringFilterPredicateFormGroup.valid) { + predicate = this.stringFilterPredicateFormGroup.getRawValue(); + if (!predicate.value) { + predicate.value = ''; + } + predicate.type = FilterPredicateType.STRING; + } + this.propagateChange(predicate); + } + +} 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 6ccab324f0..38fadffe33 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 @@ -66,6 +66,18 @@ import { SharedHomeComponentsModule } from '@home/components/shared-home-compone import { SelectTargetLayoutDialogComponent } from '@home/components/dashboard/select-target-layout-dialog.component'; import { SelectTargetStateDialogComponent } from '@home/components/dashboard/select-target-state-dialog.component'; import { AliasesEntityAutocompleteComponent } from '@home/components/alias/aliases-entity-autocomplete.component'; +import { BooleanFilterPredicateComponent } from '@home/components/filter/boolean-filter-predicate.component'; +import { StringFilterPredicateComponent } from '@home/components/filter/string-filter-predicate.component'; +import { NumericFilterPredicateComponent } from '@home/components/filter/numeric-filter-predicate.component'; +import { ComplexFilterPredicateComponent } from '@home/components/filter/complex-filter-predicate.component'; +import { FilterPredicateComponent } from '@home/components/filter/filter-predicate.component'; +import { FilterPredicateListComponent } from '@home/components/filter/filter-predicate-list.component'; +import { KeyFilterListComponent } from '@home/components/filter/key-filter-list.component'; +import { ComplexFilterPredicateDialogComponent } from '@home/components/filter/complex-filter-predicate-dialog.component'; +import { KeyFilterDialogComponent } from '@home/components/filter/key-filter-dialog.component'; +import { FiltersDialogComponent } from '@home/components/filter/filters-dialog.component'; +import { FilterDialogComponent } from '@home/components/filter/filter-dialog.component'; +import { FilterSelectComponent } from './filter/filter-select.component'; @NgModule({ declarations: @@ -114,7 +126,19 @@ import { AliasesEntityAutocompleteComponent } from '@home/components/alias/alias SelectTargetLayoutDialogComponent, SelectTargetStateDialogComponent, AddWidgetToDashboardDialogComponent, - TableColumnsAssignmentComponent + TableColumnsAssignmentComponent, + BooleanFilterPredicateComponent, + StringFilterPredicateComponent, + NumericFilterPredicateComponent, + ComplexFilterPredicateComponent, + ComplexFilterPredicateDialogComponent, + FilterPredicateComponent, + FilterPredicateListComponent, + KeyFilterListComponent, + KeyFilterDialogComponent, + FilterDialogComponent, + FiltersDialogComponent, + FilterSelectComponent ], imports: [ CommonModule, @@ -156,7 +180,19 @@ import { AliasesEntityAutocompleteComponent } from '@home/components/alias/alias ImportDialogCsvComponent, TableColumnsAssignmentComponent, SelectTargetLayoutDialogComponent, - SelectTargetStateDialogComponent + SelectTargetStateDialogComponent, + BooleanFilterPredicateComponent, + StringFilterPredicateComponent, + NumericFilterPredicateComponent, + ComplexFilterPredicateComponent, + ComplexFilterPredicateDialogComponent, + FilterPredicateComponent, + FilterPredicateListComponent, + KeyFilterListComponent, + KeyFilterDialogComponent, + FilterDialogComponent, + FiltersDialogComponent, + FilterSelectComponent ], providers: [ WidgetComponentService, diff --git a/ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts b/ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts index cb5ed40f93..210c04d6b2 100644 --- a/ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts +++ b/ui-ngx/src/app/modules/home/components/import-export/import-export.service.ts @@ -55,6 +55,7 @@ import { RequestConfig } from '@core/http/http-utils'; import { RuleChain, RuleChainImport, RuleChainMetaData } from '@shared/models/rule-chain.models'; import { RuleChainService } from '@core/http/rule-chain.service'; import * as JSZip from 'jszip'; +import { FiltersInfo } from '@shared/models/query/query.models'; // @dynamic @Injectable() @@ -142,7 +143,8 @@ export class ImportExportService { public importWidget(dashboard: Dashboard, targetState: string, targetLayoutFunction: () => Observable, - onAliasesUpdateFunction: () => void): Observable { + onAliasesUpdateFunction: () => void, + onFiltersUpdateFunction: () => void): Observable { return this.openImportDialog('dashboard.import-widget', 'dashboard.widget-file').pipe( mergeMap((widgetItem: WidgetItem) => { if (!this.validateImportedWidget(widgetItem)) { @@ -154,6 +156,9 @@ export class ImportExportService { let widget = widgetItem.widget; widget = this.dashboardUtils.validateAndUpdateWidget(widget); const aliasesInfo = this.prepareAliasesInfo(widgetItem.aliasesInfo); + const filtersInfo: FiltersInfo = widgetItem.filtersInfo || { + datasourceFilters: {} + }; const originalColumns = widgetItem.originalColumns; const originalSize = widgetItem.originalSize; @@ -202,23 +207,23 @@ export class ImportExportService { } } return this.addImportedWidget(dashboard, targetState, targetLayoutFunction, widget, - aliasesInfo, onAliasesUpdateFunction, originalColumns, originalSize); + aliasesInfo, filtersInfo, onAliasesUpdateFunction, onFiltersUpdateFunction, originalColumns, originalSize); } )); } else { return this.addImportedWidget(dashboard, targetState, targetLayoutFunction, widget, - aliasesInfo, onAliasesUpdateFunction, originalColumns, originalSize); + aliasesInfo, filtersInfo, onAliasesUpdateFunction, onFiltersUpdateFunction, originalColumns, originalSize); } } ) ); } else { return this.addImportedWidget(dashboard, targetState, targetLayoutFunction, widget, - aliasesInfo, onAliasesUpdateFunction, originalColumns, originalSize); + aliasesInfo, filtersInfo, onAliasesUpdateFunction, onFiltersUpdateFunction, originalColumns, originalSize); } } else { return this.addImportedWidget(dashboard, targetState, targetLayoutFunction, widget, - aliasesInfo, onAliasesUpdateFunction, originalColumns, originalSize); + aliasesInfo, filtersInfo, onAliasesUpdateFunction, onFiltersUpdateFunction, originalColumns, originalSize); } } }), @@ -535,12 +540,16 @@ export class ImportExportService { private addImportedWidget(dashboard: Dashboard, targetState: string, targetLayoutFunction: () => Observable, - widget: Widget, aliasesInfo: AliasesInfo, onAliasesUpdateFunction: () => void, + widget: Widget, aliasesInfo: AliasesInfo, + filtersInfo: FiltersInfo, + onAliasesUpdateFunction: () => void, + onFiltersUpdateFunction: () => void, originalColumns: number, originalSize: WidgetSize): Observable { return targetLayoutFunction().pipe( mergeMap((targetLayout) => { return this.itembuffer.addWidgetToDashboard(dashboard, targetState, targetLayout, - widget, aliasesInfo, onAliasesUpdateFunction, originalColumns, originalSize, -1, -1).pipe( + widget, aliasesInfo, filtersInfo, onAliasesUpdateFunction, onFiltersUpdateFunction, + originalColumns, originalSize, -1, -1).pipe( map(() => ({widget, layoutId: targetLayout} as ImportWidgetResult)) ); } diff --git a/ui-ngx/src/app/modules/home/components/widget/data-keys.component.ts b/ui-ngx/src/app/modules/home/components/widget/data-keys.component.ts index 29482e63b9..a08aeac886 100644 --- a/ui-ngx/src/app/modules/home/components/widget/data-keys.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/data-keys.component.ts @@ -155,10 +155,6 @@ export class DataKeysComponent implements ControlValueAccessor, OnInit, AfterVie private dialog: MatDialog, private fb: FormBuilder, public truncate: TruncatePipe) { - this.keysListFormGroup = this.fb.group({ - keys: [null, this.required ? [Validators.required] : []], - key: [null] - }); } updateValidators() { @@ -174,6 +170,10 @@ export class DataKeysComponent implements ControlValueAccessor, OnInit, AfterVie } ngOnInit() { + this.keysListFormGroup = this.fb.group({ + keys: [null, this.required ? [Validators.required] : []], + key: [null] + }); this.alarmKeys = []; for (const name of Object.keys(alarmFields)) { this.alarmKeys.push({ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.ts index 3010e22bd6..3656b8ee80 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entities-table-widget.component.ts @@ -332,7 +332,7 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni const datasource = this.subscription.options.datasources ? this.subscription.options.datasources[0] : null; - if (datasource) { + if (datasource && datasource.dataKeys) { datasource.dataKeys.forEach((entityDataKey) => { const dataKey: EntityColumn = deepClone(entityDataKey) as EntityColumn; dataKey.entityKey = { 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 e8a36129cb..365421d76f 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 @@ -154,12 +154,21 @@
- - +
+ + + + +
+ + { + const singleFilter: Filter = {id: null, filter, keyFilters: []}; + return this.dialog.open(FilterDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + isAdd: true, + filters: this.filters, + filter: singleFilter, + userMode: false + } + }).afterClosed().pipe( + tap((result) => { + if (result) { + this.filters[result.id] = result; + this.aliasController.updateFilters(this.filters); + } + }) + ); + } + private fetchEntityKeys(entityAliasId: string, query: string, dataKeyTypes: Array): Observable> { return this.aliasController.resolveSingleEntityInfo(entityAliasId).pipe( mergeMap((entity) => { @@ -805,7 +834,7 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont }; } } else if (this.widgetType === widgetType.alarm && this.modelValue.isDataEnabled) { - if (!config.alarmSource) { + if (!this.alarmSourceSettings.valid || !config.alarmSource) { return { alarmSource: { valid: false diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts index bea7801c7e..2cb4647a43 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts @@ -640,6 +640,20 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI } )); + this.rxSubscriptions.push(this.widgetContext.aliasController.filtersChanged.subscribe( + (filterIds) => { + let subscriptionChanged = false; + for (const id of Object.keys(this.widgetContext.subscriptions)) { + const subscription = this.widgetContext.subscriptions[id]; + subscriptionChanged = subscriptionChanged || subscription.onFiltersChanged(filterIds); + } + if (subscriptionChanged && !this.typeParameters.useCustomDatasources) { + this.displayNoData = false; + this.reInit(); + } + } + )); + this.rxSubscriptions.push(this.widgetContext.dashboard.dashboardTimewindowChanged.subscribe( (dashboardTimewindow) => { for (const id of Object.keys(this.widgetContext.subscriptions)) { diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/add-widget-dialog.component.html b/ui-ngx/src/app/modules/home/pages/dashboard/add-widget-dialog.component.html index 65f6900035..614c63d980 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/add-widget-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/dashboard/add-widget-dialog.component.html @@ -34,6 +34,7 @@ [aliasController]="aliasController" [functionsOnly]="false" [entityAliases]="dashboard.configuration.entityAliases" + [filters]="dashboard.configuration.filters" [dashboardStates]="dashboard.configuration.states" formControlName="widgetConfig"> 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 1c35a09282..0c3d8be048 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 @@ -95,6 +95,12 @@ tooltipPosition="below" [aliasController]="dashboardCtx.aliasController"> +