diff --git a/ui-ngx/package-lock.json b/ui-ngx/package-lock.json index 4e56832b72..19e65a601c 100644 --- a/ui-ngx/package-lock.json +++ b/ui-ngx/package-lock.json @@ -5432,9 +5432,9 @@ "dev": true }, "https-proxy-agent": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.2.2.tgz", - "integrity": "sha512-c8Ndjc9Bkpfx/vCJueCPy0jlP4ccCCSNDp8xwCZzPjKJUm+B+u9WX2x98Qx4n1PiMNTWo3D7KK5ifNV/yJyRzg==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.2.3.tgz", + "integrity": "sha512-Ytgnz23gm2DVftnzqRRz2dOXZbGd2uiajSw/95bPp6v53zPRspQjLm/AfBgqbJ2qfeRXWIOMVLpp86+/5yX39Q==", "dev": true, "requires": { "agent-base": "^4.3.0", diff --git a/ui-ngx/src/app/core/api/widget-subscription.ts b/ui-ngx/src/app/core/api/widget-subscription.ts index 49b643886b..e66cb58c24 100644 --- a/ui-ngx/src/app/core/api/widget-subscription.ts +++ b/ui-ngx/src/app/core/api/widget-subscription.ts @@ -15,7 +15,8 @@ /// import { - IWidgetSubscription, SubscriptionEntityInfo, + IWidgetSubscription, + SubscriptionEntityInfo, WidgetSubscriptionCallbacks, WidgetSubscriptionContext, WidgetSubscriptionOptions @@ -48,6 +49,7 @@ import { deepClone, isDefined } from '@core/utils'; import { AlarmSourceListener } from '@core/http/alarm.service'; import { DatasourceListener } from '@core/api/datasource.service'; import * as deepEqual from 'deep-equal'; +import { EntityId } from '@app/shared/models/id/entity-id'; export class WidgetSubscription implements IWidgetSubscription { @@ -339,7 +341,44 @@ export class WidgetSubscription implements IWidgetSubscription { } getFirstEntityInfo(): SubscriptionEntityInfo { - return undefined; + let entityId: EntityId; + let entityName: string; + if (this.type === widgetType.rpc) { + if (this.targetDeviceId) { + entityId = { + entityType: EntityType.DEVICE, + id: this.targetDeviceId + }; + entityName = this.targetDeviceName; + } + } else if (this.type === widgetType.alarm) { + if (this.alarmSource && this.alarmSource.entityType && this.alarmSource.entityId) { + entityId = { + entityType: this.alarmSource.entityType, + id: this.alarmSource.entityId + }; + entityName = this.alarmSource.entityName; + } + } else { + for (const datasource of this.datasources) { + if (datasource && datasource.entityType && datasource.entityId) { + entityId = { + entityType: datasource.entityType, + id: datasource.entityId + }; + entityName = datasource.entityName; + break; + } + } + } + if (entityId) { + return { + entityId, + entityName + }; + } else { + return null; + } } onAliasesChanged(aliasIds: Array): boolean { diff --git a/ui-ngx/src/app/core/services/dialog.service.ts b/ui-ngx/src/app/core/services/dialog.service.ts index 99cdfe84d0..4f627e3a6e 100644 --- a/ui-ngx/src/app/core/services/dialog.service.ts +++ b/ui-ngx/src/app/core/services/dialog.service.ts @@ -26,6 +26,10 @@ import { ColorPickerDialogComponent, ColorPickerDialogData } from '@shared/components/dialog/color-picker-dialog.component'; +import { + MaterialIconsDialogComponent, + MaterialIconsDialogData +} from '@shared/components/dialog/material-icons-dialog.component'; @Injectable( { @@ -85,6 +89,17 @@ export class DialogService { }).afterClosed(); } + materialIconPicker(icon: string): Observable { + return this.dialog.open(MaterialIconsDialogComponent, + { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + icon + } + }).afterClosed(); + } + private permissionDenied() { this.alert( this.translate.instant('access.permission-denied'), diff --git a/ui-ngx/src/app/core/services/utils.service.ts b/ui-ngx/src/app/core/services/utils.service.ts index 69bf1328ec..26a9ade0ba 100644 --- a/ui-ngx/src/app/core/services/utils.service.ts +++ b/ui-ngx/src/app/core/services/utils.service.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Inject, Injectable } from '@angular/core'; +import { Inject, Injectable, NgZone } from '@angular/core'; import { WINDOW } from '@core/services/window.service'; import { ExceptionData } from '@app/shared/models/error.models'; import { deepClone, deleteNullProperties, isDefined, isUndefined } from '@core/utils'; @@ -28,6 +28,8 @@ import { alarmFields } from '@shared/models/alarm.models'; import { materialColors } from '@app/shared/models/material.models'; import { WidgetInfo } from '@home/models/widget-component.models'; import jsonSchemaDefaults from 'json-schema-defaults'; +import * as materialIconsCodepoints from '!raw-loader!material-design-icons/iconfont/codepoints'; +import { Observable, of, ReplaySubject } from 'rxjs'; const varsRegex = /\$\{([^}]*)\}/g; @@ -58,6 +60,13 @@ const defaultAlarmFields: Array = [ alarmFields.status.keyName ]; +const commonMaterialIcons: Array = [ 'more_horiz', 'more_vert', 'open_in_new', + 'visibility', 'play_arrow', 'arrow_back', 'arrow_downward', + 'arrow_forward', 'arrow_upwards', 'close', 'refresh', 'menu', 'show_chart', 'multiline_chart', 'pie_chart', 'insert_chart', 'people', + 'person', 'domain', 'devices_other', 'now_widgets', 'dashboards', 'map', 'pin_drop', 'my_location', 'extension', 'search', + 'settings', 'notifications', 'notifications_active', 'info', 'info_outline', 'warning', 'list', 'file_download', 'import_export', + 'share', 'add', 'edit', 'done' ]; + @Injectable({ providedIn: 'root' }) @@ -85,7 +94,10 @@ export class UtilsService { defaultAlarmDataKeys: Array = []; + materialIcons: Array = []; + constructor(@Inject(WINDOW) private window: Window, + private zone: NgZone, private translate: TranslateService) { let frame: Element = null; try { @@ -282,6 +294,31 @@ export class UtilsService { return datasources; } + public getMaterialIcons(): Observable> { + if (this.materialIcons.length) { + return of(this.materialIcons); + } else { + const materialIconsSubject = new ReplaySubject>(); + this.zone.runOutsideAngular(() => { + const codepointsArray = materialIconsCodepoints + .split('\n') + .filter((codepoint) => codepoint && codepoint.length); + codepointsArray.forEach((codepoint) => { + const values = codepoint.split(' '); + if (values && values.length === 2) { + this.materialIcons.push(values[0]); + } + }); + materialIconsSubject.next(this.materialIcons); + }); + return materialIconsSubject.asObservable(); + } + } + + public getCommonMaterialIcons(): Array { + return commonMaterialIcons; + } + public getMaterialColor(index: number) { const colorIndex = index % materialColors.length; return materialColors[colorIndex].value; diff --git a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html index 135f397b20..6fe9e3f2bc 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html @@ -88,7 +88,7 @@ diff --git a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.scss b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.scss index b903b6fb2b..60c0f7c65e 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.scss +++ b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.scss @@ -39,6 +39,7 @@ gridster-item { transition: none; overflow: visible; + background: none; } } diff --git a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts index 605b2d43eb..b5e09abb49 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts @@ -15,12 +15,12 @@ /// import { - AfterViewInit, + AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, DoCheck, Input, IterableDiffers, - KeyValueDiffers, + KeyValueDiffers, NgZone, OnChanges, OnInit, SimpleChanges, @@ -162,7 +162,8 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo private dialogService: DialogService, private breakpointObserver: BreakpointObserver, private differs: IterableDiffers, - private kvDiffers: KeyValueDiffers) { + private kvDiffers: KeyValueDiffers, + private ngZone: NgZone) { super(store); this.authUser = getCurrentAuthUser(store); } @@ -259,20 +260,24 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo } onUpdateTimewindow(startTimeMs: number, endTimeMs: number, interval?: number): void { - if (!this.originalDashboardTimewindow) { - this.originalDashboardTimewindow = deepClone(this.dashboardTimewindow); - } - this.dashboardTimewindow = toHistoryTimewindow(this.dashboardTimewindow, - startTimeMs, endTimeMs, interval, this.timeService); - this.dashboardTimewindowChangedSubject.next(this.dashboardTimewindow); + this.ngZone.run(() => { + if (!this.originalDashboardTimewindow) { + this.originalDashboardTimewindow = deepClone(this.dashboardTimewindow); + } + this.dashboardTimewindow = toHistoryTimewindow(this.dashboardTimewindow, + startTimeMs, endTimeMs, interval, this.timeService); + this.dashboardTimewindowChangedSubject.next(this.dashboardTimewindow); + }); } onResetTimewindow(): void { - if (this.originalDashboardTimewindow) { - this.dashboardTimewindow = deepClone(this.originalDashboardTimewindow); - this.originalDashboardTimewindow = null; - this.dashboardTimewindowChangedSubject.next(this.dashboardTimewindow); - } + this.ngZone.run(() => { + if (this.originalDashboardTimewindow) { + this.dashboardTimewindow = deepClone(this.originalDashboardTimewindow); + this.originalDashboardTimewindow = null; + this.dashboardTimewindowChangedSubject.next(this.dashboardTimewindow); + } + }); } isAutofillHeight(): boolean { @@ -456,7 +461,7 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo this.gridsterOpts.draggable.enabled = this.isEdit; } - private notifyGridsterOptionsChanged() { + public notifyGridsterOptionsChanged() { if (this.gridster && this.gridster.options) { this.gridster.optionsChanged(); } diff --git a/ui-ngx/src/app/modules/home/components/details-panel.component.ts b/ui-ngx/src/app/modules/home/components/details-panel.component.ts index fa180657b5..a83616b654 100644 --- a/ui-ngx/src/app/modules/home/components/details-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/details-panel.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; 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 64d59a15b1..6bb19470b7 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 @@ -50,6 +50,12 @@ import { EntityAliasSelectComponent } from './alias/entity-alias-select.componen import { DataKeysComponent } from '@home/components/widget/data-keys.component'; import { DataKeyConfigDialogComponent } from './widget/data-key-config-dialog.component'; import { DataKeyConfigComponent } from './widget/data-key-config.component'; +import { LegendConfigPanelComponent } from './widget/legend-config-panel.component'; +import { LegendConfigComponent } from './widget/legend-config.component'; +import { ManageWidgetActionsComponent } from './widget/action/manage-widget-actions.component'; +import { WidgetActionDialogComponent } from './widget/action/widget-action-dialog.component'; +import { CustomActionPrettyResourcesTabsComponent } from './widget/action/custom-action-pretty-resources-tabs.component'; +import { CustomActionPrettyEditorComponent } from './widget/action/custom-action-pretty-editor.component'; @NgModule({ entryComponents: [ @@ -64,7 +70,9 @@ import { DataKeyConfigComponent } from './widget/data-key-config.component'; AliasesEntitySelectPanelComponent, EntityAliasesDialogComponent, EntityAliasDialogComponent, - DataKeyConfigDialogComponent + DataKeyConfigDialogComponent, + LegendConfigPanelComponent, + WidgetActionDialogComponent ], declarations: [ @@ -99,7 +107,13 @@ import { DataKeyConfigComponent } from './widget/data-key-config.component'; EntityAliasSelectComponent, DataKeysComponent, DataKeyConfigComponent, - DataKeyConfigDialogComponent + DataKeyConfigDialogComponent, + LegendConfigPanelComponent, + LegendConfigComponent, + ManageWidgetActionsComponent, + WidgetActionDialogComponent, + CustomActionPrettyResourcesTabsComponent, + CustomActionPrettyEditorComponent ], imports: [ CommonModule, @@ -130,7 +144,12 @@ import { DataKeyConfigComponent } from './widget/data-key-config.component'; EntityAliasSelectComponent, DataKeysComponent, DataKeyConfigComponent, - DataKeyConfigDialogComponent + DataKeyConfigDialogComponent, + LegendConfigComponent, + ManageWidgetActionsComponent, + WidgetActionDialogComponent, + CustomActionPrettyResourcesTabsComponent, + CustomActionPrettyEditorComponent ], providers: [ WidgetComponentService diff --git a/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.html b/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.html new file mode 100644 index 0000000000..8b531c641f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.html @@ -0,0 +1,53 @@ + +
+
+ +
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.scss b/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.scss new file mode 100644 index 0000000000..e4dc4f4a03 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.scss @@ -0,0 +1,107 @@ +/** + * Copyright © 2016-2019 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.tb-custom-action-pretty { + box-sizing: border-box; + position: relative; + padding: 8px; + background-color: #fff; + + .tb-fullscreen-panel { + .tb-custom-action-editor-container { + height: calc(100% - 40px); + } + + .right-panel { + padding-top: 8px; + padding-left: 3px; + } + + tb-js-func .tb-js-func-panel { + box-sizing: border-box; + } + + mat-tab-group { + .mat-tab-body-wrapper { + height: 100%; + mat-tab-body { + height: 100%; + & > div { + height: 100%; + } + } + } + } + } + + .tb-split { + box-sizing: border-box; + overflow-x: hidden; + overflow-y: auto; + } + + .tb-content { + border: 1px solid #c0c0c0; + } + + .gutter { + background-color: #eee; + background-repeat: no-repeat; + background-position: 50%; + } + + .gutter.gutter-horizontal { + cursor: col-resize; + background-image: url("../../../../../../assets/split.js/grips/vertical.png"); + } + + .tb-split.tb-split-horizontal, + .gutter.gutter-horizontal { + float: left; + height: 100%; + } + + .tb-action-expand-button { + position: absolute; + right: 14px; + z-index: 2; + + &.tb-fullscreen-editor { + position: relative; + right: 0; + .mat-button { + .mat-icon { + margin-right: 5px; + } + } + } + + .mat-button { + min-width: 36px; + padding: 0; + .mat-icon { + margin-right: 0; + } + } + } + + .tb-custom-action-editor { + &.tb-fullscreen-editor { + height: 100%; + } + } +} + + diff --git a/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.ts b/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.ts new file mode 100644 index 0000000000..c2570f2de3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.ts @@ -0,0 +1,138 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + forwardRef, + Input, + OnDestroy, + OnInit, + ViewChild, ViewEncapsulation, ViewChildren, QueryList, ComponentFactoryResolver +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { MatDialog } from '@angular/material/dialog'; +import { DialogService } from '@core/services/dialog.service'; +import { PageLink } from '@shared/models/page/page-link'; +import { Direction, SortOrder } from '@shared/models/page/sort-order'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort } from '@angular/material/sort'; +import { combineLatest, fromEvent, merge } from 'rxjs'; +import { debounceTime, distinctUntilChanged, tap } from 'rxjs/operators'; +import { + WidgetActionDescriptorInfo, + WidgetActionsData, + WidgetActionsDatasource, + WidgetActionCallbacks, toWidgetActionDescriptor +} from '@home/components/widget/action/manage-widget-actions.component.models'; +import { UtilsService } from '@core/services/utils.service'; +import { EntityRelation, EntitySearchDirection, RelationTypeGroup } from '@shared/models/relation.models'; +import { RelationDialogComponent, RelationDialogData } from '@home/components/relation/relation-dialog.component'; +import { CustomActionDescriptor, WidgetActionDescriptor, WidgetActionSource } from '@shared/models/widget.models'; +import { + WidgetActionDialogComponent, + WidgetActionDialogData +} from '@home/components/widget/action/widget-action-dialog.component'; +import { deepClone } from '@core/utils'; +import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; +import { CustomActionPrettyResourcesTabsComponent } from '@home/components/widget/action/custom-action-pretty-resources-tabs.component'; +import { MatTab, MatTabGroup } from '@angular/material/tabs'; + +@Component({ + selector: 'tb-custom-action-pretty-editor', + templateUrl: './custom-action-pretty-editor.component.html', + styleUrls: ['./custom-action-pretty-editor.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CustomActionPrettyEditorComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class CustomActionPrettyEditorComponent extends PageComponent implements OnInit, AfterViewInit, OnDestroy, ControlValueAccessor { + + @Input() disabled: boolean; + + action: CustomActionDescriptor; + + fullscreen = false; + + @ViewChildren('leftPanel') + leftPanelElmRef: QueryList>; + + @ViewChildren('rightPanel') + rightPanelElmRef: QueryList>; + + private propagateChange = (_: any) => {}; + + constructor(protected store: Store) { + super(store); + } + + ngOnInit(): void { + } + + ngAfterViewInit(): void { + combineLatest(this.leftPanelElmRef.changes, this.rightPanelElmRef.changes).subscribe(() => { + if (this.leftPanelElmRef.length && this.rightPanelElmRef.length) { + this.initSplitLayout(this.leftPanelElmRef.first.nativeElement, + this.rightPanelElmRef.first.nativeElement); + } + }); + } + + private initSplitLayout(leftPanel: any, rightPanel: any) { + Split([leftPanel, rightPanel], { + sizes: [50, 50], + gutterSize: 8, + cursor: 'col-resize' + }); + } + + ngOnDestroy(): void { + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(obj: CustomActionDescriptor): void { + this.action = obj; + } + + public onActionUpdated(valid: boolean = true) { + if (!valid) { + this.propagateChange(null); + } else { + this.propagateChange(this.action); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.html b/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.html new file mode 100644 index 0000000000..0ba01de5e6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.html @@ -0,0 +1,100 @@ + + + +
+
+
+ + + + +
+
+ +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+ + + + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.scss b/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.scss new file mode 100644 index 0000000000..6176b1fb6a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.scss @@ -0,0 +1,79 @@ +/** + * Copyright © 2016-2019 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.tb-custom-action-editor-container { + + mat-form-field.resource-field { + max-height: 40px; + margin: 10px 0px 0px 0px; + .mat-form-field-wrapper { + padding-bottom: 0; + .mat-form-field-flex { + max-height: 40px; + .mat-form-field-infix { + border: 0; + } + } + .mat-form-field-underline { + bottom: 0; + } + } + } + + .html-panel, + .css-panel { + width: 100%; + min-width: 200px; + height: 100%; + min-height: 200px; + } + + div.tb-editor-area-title-panel { + position: absolute; + top: 5px; + right: 20px; + z-index: 5; + font-size: .8rem; + font-weight: 500; + + label { + padding: 4px; + color: #00acc1; + text-transform: uppercase; + background: rgba(220, 220, 220, .35); + border-radius: 5px; + &:not(:last-child) { + margin-right: 4px; + } + } + + button.mat-button, button.mat-icon-button, button.mat-icon-button.tb-mat-32 { + align-items: center; + vertical-align: middle; + min-width: 32px; + min-height: 15px; + padding: 4px; + margin: 0; + font-size: .8rem; + line-height: 15px; + color: #7b7b7b; + background: rgba(220, 220, 220, .35); + &:not(:last-child) { + margin-right: 4px; + } + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.ts b/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.ts new file mode 100644 index 0000000000..3a7548843b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.ts @@ -0,0 +1,211 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + Component, + ElementRef, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, QueryList, + SimpleChanges, + ViewChild, ViewChildren, ViewEncapsulation +} from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { CustomActionDescriptor } from '@shared/models/widget.models'; +import * as ace from 'ace-builds'; +import { CancelAnimationFrame, RafService } from '@core/services/raf.service'; +import { css_beautify, html_beautify } from 'js-beautify'; +import { MatTab } from '@angular/material/tabs'; +import { BehaviorSubject } from 'rxjs'; + +@Component({ + selector: 'tb-custom-action-pretty-resources-tabs', + templateUrl: './custom-action-pretty-resources-tabs.component.html', + styleUrls: ['./custom-action-pretty-resources-tabs.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class CustomActionPrettyResourcesTabsComponent extends PageComponent implements OnInit, OnChanges, OnDestroy { + + @Input() + action: CustomActionDescriptor; + + @Input() + hasCustomFunction: boolean; + + @Output() + actionUpdated: EventEmitter = new EventEmitter(); + + @ViewChild('htmlInput', {static: true}) + htmlInputElmRef: ElementRef; + + @ViewChild('cssInput', {static: true}) + cssInputElmRef: ElementRef; + + htmlFullscreen = false; + cssFullscreen = false; + + aceEditors: ace.Ace.Editor[] = []; + editorsResizeCafs: {[editorId: string]: CancelAnimationFrame} = {}; + aceResizeListeners: { element: any, resizeListener: any }[] = []; + htmlEditor: ace.Ace.Editor; + cssEditor: ace.Ace.Editor; + setValuesPending = false; + + constructor(protected store: Store, + private translate: TranslateService, + private raf: RafService) { + super(store); + } + + ngOnInit(): void { + this.initAceEditors(); + if (this.setValuesPending) { + this.setAceEditorValues(); + this.setValuesPending = false; + } + } + + ngOnDestroy(): void { + this.aceResizeListeners.forEach((resizeListener) => { + // @ts-ignore + removeResizeListener(resizeListener.element, resizeListener.resizeListener); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (propName === 'action') { + if (this.aceEditors.length) { + this.setAceEditorValues(); + } else { + this.setValuesPending = true; + } + } + } + } + + public notifyActionUpdated() { + this.actionUpdated.emit(this.validate() ? this.action : null); + } + + private validate(): boolean { + for (const resource of this.action.customResources) { + if (!resource.url) { + return false; + } + } + return true; + } + + public addResource() { + this.action.customResources.push({url: ''}); + this.notifyActionUpdated(); + } + + public removeResource(index: number) { + if (index > -1) { + if (this.action.customResources.splice(index, 1).length > 0) { + this.notifyActionUpdated(); + } + } + } + + public beautifyCss(): void { + const res = css_beautify(this.action.customCss, {indent_size: 4}); + if (this.action.customCss !== res) { + this.action.customCss = res; + this.cssEditor.setValue(this.action.customCss ? this.action.customCss : '', -1); + this.notifyActionUpdated(); + } + } + + public beautifyHtml(): void { + const res = html_beautify(this.action.customHtml, {indent_size: 4, wrap_line_length: 60}); + if (this.action.customHtml !== res) { + this.action.customHtml = res; + this.htmlEditor.setValue(this.action.customHtml ? this.action.customHtml : '', -1); + this.notifyActionUpdated(); + } + } + + private initAceEditors() { + this.htmlEditor = this.createAceEditor(this.htmlInputElmRef, 'html'); + this.htmlEditor.on('input', () => { + const editorValue = this.htmlEditor.getValue(); + if (this.action.customHtml !== editorValue) { + this.action.customHtml = editorValue; + this.notifyActionUpdated(); + } + }); + this.cssEditor = this.createAceEditor(this.cssInputElmRef, 'css'); + this.cssEditor.on('input', () => { + const editorValue = this.cssEditor.getValue(); + if (this.action.customCss !== editorValue) { + this.action.customCss = editorValue; + this.notifyActionUpdated(); + } + }); + } + + private createAceEditor(editorElementRef: ElementRef, mode: string): ace.Ace.Editor { + const editorElement = editorElementRef.nativeElement; + let editorOptions: Partial = { + mode: `ace/mode/${mode}`, + showGutter: true, + showPrintMargin: true + }; + const advancedOptions = { + enableSnippets: true, + enableBasicAutocompletion: true, + enableLiveAutocompletion: true + }; + editorOptions = {...editorOptions, ...advancedOptions}; + const aceEditor = ace.edit(editorElement, editorOptions); + aceEditor.session.setUseWrapMode(true); + this.aceEditors.push(aceEditor); + + const resizeListener = this.onAceEditorResize.bind(this, aceEditor); + + // @ts-ignore + addResizeListener(editorElement, resizeListener); + this.aceResizeListeners.push({element: editorElement, resizeListener}); + return aceEditor; + } + + private setAceEditorValues() { + this.htmlEditor.setValue(this.action.customHtml ? this.action.customHtml : '', -1); + this.cssEditor.setValue(this.action.customCss ? this.action.customCss : '', -1); + } + + private onAceEditorResize(aceEditor: ace.Ace.Editor) { + if (this.editorsResizeCafs[aceEditor.id]) { + this.editorsResizeCafs[aceEditor.id](); + delete this.editorsResizeCafs[aceEditor.id]; + } + this.editorsResizeCafs[aceEditor.id] = this.raf.raf(() => { + aceEditor.resize(); + aceEditor.renderer.updateFull(); + }); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/action/custom-sample-css.raw b/ui-ngx/src/app/modules/home/components/widget/action/custom-sample-css.raw new file mode 100644 index 0000000000..9167f2c10e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/action/custom-sample-css.raw @@ -0,0 +1,118 @@ +/*=======================================================================*/ +/*========== There are two examples: for edit and add entity ==========*/ +/*=======================================================================*/ +/*======================== Edit entity example ========================*/ +/*=======================================================================*/ +/* +.edit-entity-form md-input-container { + padding-right: 10px; +} + +.edit-entity-form .boolean-value-input { + padding-left: 5px; +} + +.edit-entity-form .boolean-value-input .checkbox-label { + margin-bottom: 8px; + color: rgba(0,0,0,0.54); + font-size: 12px; +} + +.relations-list .header { + padding-right: 5px; + padding-bottom: 5px; + padding-left: 5px; +} + +.relations-list .header .cell { + padding-right: 5px; + padding-left: 5px; + font-size: 12px; + font-weight: 700; + color: rgba(0, 0, 0, .54); + white-space: nowrap; +} + +.relations-list .body { + padding-right: 5px; + padding-bottom: 15px; + padding-left: 5px; +} + +.relations-list .body .row { + padding-top: 5px; +} + +.relations-list .body .cell { + padding-right: 5px; + padding-left: 5px; +} + +.relations-list .body md-autocomplete-wrap md-input-container { + height: 30px; +} + +.relations-list .body .md-button { + margin: 0; +} + +.relations-list.old-relations tb-entity-select tb-entity-autocomplete button { + display: none; +} +*/ +/*========================================================================*/ +/*========================= Add entity example =========================*/ +/*========================================================================*/ +/* +.add-entity-form md-input-container { + padding-right: 10px; +} + +.add-entity-form .boolean-value-input { + padding-left: 5px; +} + +.add-entity-form .boolean-value-input .checkbox-label { + margin-bottom: 8px; + color: rgba(0,0,0,0.54); + font-size: 12px; +} + +.relations-list .header { + padding-right: 5px; + padding-bottom: 5px; + padding-left: 5px; +} + +.relations-list .header .cell { + padding-right: 5px; + padding-left: 5px; + font-size: 12px; + font-weight: 700; + color: rgba(0, 0, 0, .54); + white-space: nowrap; +} + +.relations-list .body { + padding-right: 5px; + padding-bottom: 15px; + padding-left: 5px; +} + +.relations-list .body .row { + padding-top: 5px; +} + +.relations-list .body .cell { + padding-right: 5px; + padding-left: 5px; +} + +.relations-list .body md-autocomplete-wrap md-input-container { + height: 30px; +} + +.relations-list .body .md-button { + margin: 0; +} +*/ diff --git a/ui-ngx/src/app/modules/home/components/widget/action/custom-sample-html.raw b/ui-ngx/src/app/modules/home/components/widget/action/custom-sample-html.raw new file mode 100644 index 0000000000..5de572fd4d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/action/custom-sample-html.raw @@ -0,0 +1,304 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/action/custom-sample-js.raw b/ui-ngx/src/app/modules/home/components/widget/action/custom-sample-js.raw new file mode 100644 index 0000000000..98ec6d044b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/action/custom-sample-js.raw @@ -0,0 +1,397 @@ +/*=======================================================================*/ +/*===== There are three examples: for delete, edit and add entity =====*/ +/*=======================================================================*/ +/*======================= Delete entity example =======================*/ +/*=======================================================================*/ +// +//var $injector = widgetContext.$scope.$injector; +//var $mdDialog = $injector.get('$mdDialog'), +// $document = $injector.get('$document'), +// types = $injector.get('types'), +// assetService = $injector.get('assetService'), +// deviceService = $injector.get('deviceService') +// $rootScope = $injector.get('$rootScope'), +// $q = $injector.get('$q'); +// +//openDeleteEntityDialog(); +// +//function openDeleteEntityDialog() { +// var title = 'Delete ' + entityId.entityType +// .toLowerCase() + ' ' + +// entityName; +// var content = 'Are you sure you want to delete the ' + +// entityId.entityType.toLowerCase() + ' ' + +// entityName + '?'; +// var confirm = $mdDialog.confirm() +// .targetEvent($event) +// .title(title) +// .htmlContent(content) +// .ariaLabel(title) +// .cancel('Cancel') +// .ok('Delete'); +// $mdDialog.show(confirm).then(function() { +// deleteEntity(); +// }) +//} +// +//function deleteEntity() { +// deleteEntityPromise(entityId).then( +// function success() { +// updateAliasData(); +// }, +// function fail() { +// showErrorDialog(); +// } +// ); +//} +// +//function deleteEntityPromise(entityId) { +// if (entityId.entityType == types.entityType.asset) { +// return assetService.deleteAsset(entityId.id); +// } else if (entityId.entityType == types.entityType.device) { +// return deviceService.deleteDevice(entityId.id); +// } +//} +// +//function updateAliasData() { +// var aliasIds = []; +// for (var id in widgetContext.aliasController.resolvedAliases) { +// aliasIds.push(id); +// } +// var tasks = []; +// aliasIds.forEach(function(aliasId) { +// widgetContext.aliasController.setAliasUnresolved(aliasId); +// tasks.push(widgetContext.aliasController.getAliasInfo(aliasId)); +// }); +// $q.all(tasks).then(function() { +// $rootScope.$broadcast('entityAliasesChanged', aliasIds); +// }); +//} +// +//function showErrorDialog() { +// var title = 'Error'; +// var content = 'An error occurred while deleting the entity. Please try again.'; +// var alert = $mdDialog.alert() +// .title(title) +// .htmlContent(content) +// .ariaLabel(title) +// .parent(angular.element($document[0].body)) +// .targetEvent($event) +// .multiple(true) +// .clickOutsideToClose(true) +// .ok('CLOSE'); +// $mdDialog.show(alert); +//} +// +/*=======================================================================*/ +/*======================== Edit entity example ========================*/ +/*=======================================================================*/ +// +//var $injector = widgetContext.$scope.$injector; +//var $mdDialog = $injector.get('$mdDialog'), +// $document = $injector.get('$document'), +// $q = $injector.get('$q'), +// types = $injector.get('types'), +// $rootScope = $injector.get('$rootScope'), +// entityService = $injector.get('entityService'), +// attributeService = $injector.get('attributeService'), +// entityRelationService = $injector.get('entityRelationService'); +// +//openEditEntityDialog(); +// +//function openEditEntityDialog() { +// $mdDialog.show({ +// controller: ['$scope','$mdDialog', EditEntityDialogController], +// controllerAs: 'vm', +// template: htmlTemplate, +// locals: { +// entityId: entityId +// }, +// parent: angular.element($document[0].body), +// targetEvent: $event, +// multiple: true, +// clickOutsideToClose: false +// }); +//} +// +//function EditEntityDialogController($scope,$mdDialog) { +// var vm = this; +// vm.entityId = entityId; +// vm.entityName = entityName; +// vm.entityType = entityId.entityType; +// vm.allowedEntityTypes = [types.entityType.asset, types.entityType.device]; +// vm.allowedRelatedEntityTypes = []; +// vm.entitySearchDirection = types.entitySearchDirection; +// vm.attributes = {}; +// vm.serverAttributes = {}; +// vm.relations = []; +// vm.newRelations = []; +// vm.relationsToDelete = []; +// getEntityInfo(); +// +// vm.addRelation = function() { +// var relation = { +// direction: null, +// relationType: null, +// relatedEntity: null +// }; +// vm.newRelations.push(relation); +// $scope.editEntityForm.$setDirty(); +// }; +// vm.removeRelation = function(index) { +// if (index > -1) { +// vm.newRelations.splice(index, 1); +// $scope.editEntityForm.$setDirty(); +// } +// }; +// vm.removeOldRelation = function(index, relation) { +// if (index > -1) { +// vm.relations.splice(index, 1); +// vm.relationsToDelete.push(relation); +// $scope.editEntityForm.$setDirty(); +// } +// }; +// vm.save = function() { +// saveAttributes(); +// saveRelations(); +// $scope.editEntityForm.$setPristine(); +// }; +// vm.cancel = function() { +// $mdDialog.hide(); +// }; +// +// function getEntityAttributes(attributes) { +// for (var i = 0; i < attributes.length; i++) { +// vm.attributes[attributes[i].key] = attributes[i].value; +// } +// vm.serverAttributes = angular.copy(vm.attributes); +// } +// +// function getEntityRelations(relations) { +// var relationsFrom = relations[0]; +// var relationsTo = relations[1]; +// for (var i=0; i < relationsFrom.length; i++) { +// var relation = { +// direction: types.entitySearchDirection.from, +// relationType: relationsFrom[i].type, +// relatedEntity: relationsFrom[i].to +// }; +// vm.relations.push(relation); +// } +// for (var i=0; i < relationsTo.length; i++) { +// var relation = { +// direction: types.entitySearchDirection.to, +// relationType: relationsTo[i].type, +// relatedEntity: relationsTo[i].from +// }; +// vm.relations.push(relation); +// } +// } +// +// function getEntityInfo() { +// entityService.getEntity(entityId.entityType, entityId.id).then( +// function(entity) { +// vm.entity = entity; +// vm.type = vm.entity.type; +// }); +// attributeService.getEntityAttributesValues(entityId.entityType, entityId.id, 'SERVER_SCOPE').then( +// function(data){ +// if (data.length) { +// getEntityAttributes(data); +// } +// }); +// $q.all([entityRelationService.findInfoByFrom(entityId.id, entityId.entityType), entityRelationService.findInfoByTo(entityId.id, entityId.entityType)]).then( +// function(relations){ +// getEntityRelations(relations); +// }); +// } +// +// function saveAttributes() { +// var attributesArray = []; +// for (var key in vm.attributes) { +// if (vm.attributes[key] !== vm.serverAttributes[key]) { +// attributesArray.push({key: key, value: vm.attributes[key]}); +// } +// } +// if (attributesArray.length > 0) { +// attributeService.saveEntityAttributes(entityId.entityType, entityId.id, \"SERVER_SCOPE\", attributesArray); +// } +// } +// +// function saveRelations() { +// var tasks = []; +// for (var i=0; i < vm.newRelations.length; i++) { +// var relation = { +// type: vm.newRelations[i].relationType +// }; +// if (vm.newRelations[i].direction == types.entitySearchDirection.from) { +// relation.to = vm.newRelations[i].relatedEntity; +// relation.from = entityId; +// } else { +// relation.to = entityId; +// relation.from = vm.newRelations[i].relatedEntity; +// } +// tasks.push(entityRelationService.saveRelation(relation)); +// } +// for (var i=0; i < vm.relationsToDelete.length; i++) { +// var relation = { +// type: vm.relationsToDelete[i].relationType +// }; +// if (vm.relationsToDelete[i].direction == types.entitySearchDirection.from) { +// relation.to = vm.relationsToDelete[i].relatedEntity; +// relation.from = entityId; +// } else { +// relation.to = entityId; +// relation.from = vm.relationsToDelete[i].relatedEntity; +// } +// tasks.push(entityRelationService.deleteRelation(relation.from.id, relation.from.entityType, relation.type, relation.to.id, relation.to.entityType)); +// } +// $q.all(tasks).then(function(){ +// vm.relations = vm.relations.concat(vm.newRelations); +// vm.newRelations = []; +// vm.relationsToDelete = []; +// updateAliasData(); +// }); +// } +// +// function updateAliasData() { +// var aliasIds = []; +// for (var id in widgetContext.aliasController.resolvedAliases) { +// aliasIds.push(id); +// } +// var tasks = []; +// aliasIds.forEach(function(aliasId) { +// widgetContext.aliasController.setAliasUnresolved(aliasId); +// tasks.push(widgetContext.aliasController.getAliasInfo(aliasId)); +// }); +// $q.all(tasks).then(function() { +// $rootScope.$broadcast('entityAliasesChanged', aliasIds); +// }); +// } +//} +// +/*========================================================================*/ +/*========================= Add entity example =========================*/ +/*========================================================================*/ +// +//var $injector = widgetContext.$scope.$injector; +//var $mdDialog = $injector.get('$mdDialog'), +// $document = $injector.get('$document'), +// $q = $injector.get('$q'), +// $rootScope = $injector.get('$rootScope'), +// types = $injector.get('types'), +// assetService = $injector.get('assetService'), +// deviceService = $injector.get('deviceService'), +// attributeService = $injector.get('attributeService'), +// entityRelationService = $injector.get('entityRelationService'); +// +//openAddEntityDialog(); +// +//function openAddEntityDialog() { +// $mdDialog.show({ +// controller: ['$scope','$mdDialog', AddEntityDialogController], +// controllerAs: 'vm', +// template: htmlTemplate, +// locals: { +// entityId: entityId +// }, +// parent: angular.element($document[0].body), +// targetEvent: $event, +// multiple: true, +// clickOutsideToClose: false +// }); +//} +// +//function AddEntityDialogController($scope, $mdDialog) { +// var vm = this; +// vm.allowedEntityTypes = [types.entityType.asset, types.entityType.device]; +// vm.allowedRelatedEntityTypes = []; +// vm.entitySearchDirection = types.entitySearchDirection; +// vm.attributes = {}; +// vm.relations = []; +// +// vm.addRelation = function() { +// var relation = { +// direction: null, +// relationType: null, +// relatedEntity: null +// }; +// vm.relations.push(relation); +// }; +// vm.removeRelation = function(index) { +// if (index > -1) { +// vm.relations.splice(index, 1); +// } +// }; +// vm.save = function() { +// $scope.addEntityForm.$setPristine(); +// saveEntityPromise().then( +// function (entity) { +// saveAttributes(entity.id); +// saveRelations(entity.id); +// $mdDialog.hide(); +// } +// ); +// }; +// vm.cancel = function() { +// $mdDialog.hide(); +// }; +// +// +// function saveEntityPromise() { +// var entity = { +// name: vm.entityName, +// type: vm.type +// }; +// if (vm.entityType == types.entityType.asset) { +// return assetService.saveAsset(entity); +// } else if (vm.entityType == types.entityType.device) { +// return deviceService.saveDevice(entity); +// } +// } +// +// function saveAttributes(entityId) { +// var attributesArray = []; +// for (var key in vm.attributes) { +// attributesArray.push({key: key, value: vm.attributes[key]}); +// } +// if (attributesArray.length > 0) { +// attributeService.saveEntityAttributes(entityId.entityType, entityId.id, \"SERVER_SCOPE\", attributesArray); +// } +// } +// +// function saveRelations(entityId) { +// var tasks = []; +// for (var i=0; i < vm.relations.length; i++) { +// var relation = { +// type: vm.relations[i].relationType +// }; +// if (vm.relations[i].direction == types.entitySearchDirection.from) { +// relation.to = vm.relations[i].relatedEntity; +// relation.from = entityId; +// } else { +// relation.to = entityId; +// relation.from = vm.relations[i].relatedEntity; +// } +// tasks.push(entityRelationService.saveRelation(relation)); +// } +// $q.all(tasks).then(function(){ +// updateAliasData(); +// }); +// } +// +// function updateAliasData() { +// var aliasIds = []; +// for (var id in widgetContext.aliasController.resolvedAliases) { +// aliasIds.push(id); +// } +// var tasks = []; +// aliasIds.forEach(function(aliasId) { +// widgetContext.aliasController.setAliasUnresolved(aliasId); +// tasks.push(widgetContext.aliasController.getAliasInfo(aliasId)); +// }); +// $q.all(tasks).then(function() { +// $rootScope.$broadcast('entityAliasesChanged', aliasIds); +// }); +// } +//} diff --git a/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.html b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.html new file mode 100644 index 0000000000..4c9cf6ead9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.html @@ -0,0 +1,118 @@ + +
+
+ +
+ widget-config.actions + + + +
+
+ +
+ + +   + + + +
+
+
+ + + {{ 'widget-config.action-source' | translate }} + + {{ action.actionSourceName }} + + + + {{ 'widget-config.action-name' | translate }} + + {{ action.name }} + + + + {{ 'widget-config.action-icon' | translate }} + + {{ action.icon }} + + + + {{ 'widget-config.action-type' | translate }} + + {{ action.typeName }} + + + + + + +
+ + +
+
+
+ + +
+ {{ 'widget-config.no-actions-text' }} +
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.models.ts b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.models.ts new file mode 100644 index 0000000000..bd790c6d7f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.models.ts @@ -0,0 +1,167 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { WidgetActionDescriptor, WidgetActionSource, + widgetActionTypeTranslationMap, CustomActionDescriptor } from '@app/shared/models/widget.models'; +import { CollectionViewer, DataSource } from '@angular/cdk/typings/collections'; +import { EntityRelationInfo, EntitySearchDirection } from '@shared/models/relation.models'; +import { BehaviorSubject, Observable, of, ReplaySubject } from 'rxjs'; +import { emptyPageData, PageData } from '@shared/models/page/page-data'; +import { SelectionModel } from '@angular/cdk/collections'; +import { EntityRelationService } from '@core/http/entity-relation.service'; +import { TranslateService } from '@ngx-translate/core'; +import { EntityId } from '@shared/models/id/entity-id'; +import { PageLink } from '@shared/models/page/page-link'; +import { catchError, map, publishReplay, refCount, share, take, tap } from 'rxjs/operators'; +import { entityTypeTranslations } from '@shared/models/entity-type.models'; +import { UtilsService } from '@core/services/utils.service'; +import { deepClone, isUndefined } from '@core/utils'; + +import * as customSampleJs from '!raw-loader!./custom-sample-js.raw'; +import * as customSampleCss from '!raw-loader!./custom-sample-css.raw'; +import * as customSampleHtml from '!raw-loader!./custom-sample-html.raw'; + +export interface WidgetActionCallbacks { + fetchDashboardStates: (query: string) => Array; +} + +export interface WidgetActionsData { + actionsMap: {[actionSourceId: string]: Array}; + actionSources: {[actionSourceId: string]: WidgetActionSource}; +} + +export interface WidgetActionDescriptorInfo extends WidgetActionDescriptor { + actionSourceId?: string; + actionSourceName?: string; + typeName?: string; +} + +export function toWidgetActionDescriptor(action: WidgetActionDescriptorInfo): WidgetActionDescriptor { + const copy = deepClone(action); + delete copy.actionSourceId; + delete copy.actionSourceName; + delete copy.typeName; + return copy; +} + +export function toCustomAction(action: WidgetActionDescriptorInfo): CustomActionDescriptor { + let result: CustomActionDescriptor; + if (!action || (isUndefined(action.customFunction) && isUndefined(action.customHtml) && isUndefined(action.customCss))) { + result = { + customHtml: customSampleHtml, + customCss: customSampleCss, + customFunction: customSampleJs + }; + } else { + result = { + customHtml: action.customHtml, + customCss: action.customCss, + customFunction: action.customFunction + }; + } + result.customResources = action ? deepClone(action.customResources) : []; + return result; +} + +export class WidgetActionsDatasource implements DataSource { + + private actionsSubject = new BehaviorSubject([]); + private pageDataSubject = new BehaviorSubject>(emptyPageData()); + + public pageData$ = this.pageDataSubject.asObservable(); + + private allActions: Observable>; + + private actionsMap: {[actionSourceId: string]: Array}; + private actionSources: {[actionSourceId: string]: WidgetActionSource}; + + constructor(private translate: TranslateService, + private utils: UtilsService) {} + + connect(collectionViewer: CollectionViewer): Observable> { + return this.actionsSubject.asObservable(); + } + + disconnect(collectionViewer: CollectionViewer): void { + this.actionsSubject.complete(); + this.pageDataSubject.complete(); + } + + setActions(actionsData: WidgetActionsData) { + this.actionsMap = actionsData.actionsMap; + this.actionSources = actionsData.actionSources; + } + + loadActions(pageLink: PageLink, reload: boolean = false): Observable> { + if (reload) { + this.allActions = null; + } + const result = new ReplaySubject>(); + this.fetchActions(pageLink).pipe( + catchError(() => of(emptyPageData())), + ).subscribe( + (pageData) => { + this.actionsSubject.next(pageData.data); + this.pageDataSubject.next(pageData); + result.next(pageData); + } + ); + return result; + } + + fetchActions(pageLink: PageLink): Observable> { + return this.getAllActions().pipe( + map((data) => pageLink.filterData(data)) + ); + } + + getAllActions(): Observable> { + if (!this.allActions) { + const actions: WidgetActionDescriptorInfo[] = []; + for (const actionSourceId of Object.keys(this.actionsMap)) { + const descriptors = this.actionsMap[actionSourceId]; + descriptors.forEach((descriptor) => { + actions.push(this.toWidgetActionDescriptorInfo(actionSourceId, descriptor)); + }); + } + this.allActions = of(actions).pipe( + publishReplay(1), + refCount() + ); + } + return this.allActions; + } + + private toWidgetActionDescriptorInfo(actionSourceId: string, action: WidgetActionDescriptor): WidgetActionDescriptorInfo { + const actionSource = this.actionSources[actionSourceId]; + const actionSourceName = actionSource ? this.utils.customTranslation(actionSource.name, actionSource.name) : actionSourceId; + const typeName = this.translate.instant(widgetActionTypeTranslationMap.get(action.type)); + return { actionSourceId, actionSourceName, typeName, ...action}; + } + + isEmpty(): Observable { + return this.actionsSubject.pipe( + map((actions) => !actions.length) + ); + } + + total(): Observable { + return this.pageDataSubject.pipe( + map((pageData) => pageData.totalElements) + ); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.scss b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.scss new file mode 100644 index 0000000000..0dd60c93b3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.scss @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2019 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + width: 100%; + height: 100%; + .tb-entity-table { + .tb-entity-table-content { + width: 100%; + height: 100%; + background: #fff; + + .tb-entity-table-title { + padding-right: 20px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .table-container { + overflow: auto; + } + } + } +} + +:host ::ng-deep { + .mat-sort-header-sorted .mat-sort-header-arrow { + opacity: 1 !important; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts new file mode 100644 index 0000000000..60bd8bb7eb --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts @@ -0,0 +1,316 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + forwardRef, + Input, + OnDestroy, + OnInit, + ViewChild +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { MatDialog } from '@angular/material/dialog'; +import { DialogService } from '@core/services/dialog.service'; +import { PageLink } from '@shared/models/page/page-link'; +import { Direction, SortOrder } from '@shared/models/page/sort-order'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort } from '@angular/material/sort'; +import { fromEvent, merge } from 'rxjs'; +import { debounceTime, distinctUntilChanged, tap } from 'rxjs/operators'; +import { + WidgetActionDescriptorInfo, + WidgetActionsData, + WidgetActionsDatasource, + WidgetActionCallbacks, toWidgetActionDescriptor +} from '@home/components/widget/action/manage-widget-actions.component.models'; +import { UtilsService } from '@core/services/utils.service'; +import { EntityRelation, EntitySearchDirection, RelationTypeGroup } from '@shared/models/relation.models'; +import { RelationDialogComponent, RelationDialogData } from '@home/components/relation/relation-dialog.component'; +import { WidgetActionDescriptor, WidgetActionSource } from '@shared/models/widget.models'; +import { + WidgetActionDialogComponent, + WidgetActionDialogData +} from '@home/components/widget/action/widget-action-dialog.component'; +import { deepClone } from '@core/utils'; + +@Component({ + selector: 'tb-manage-widget-actions', + templateUrl: './manage-widget-actions.component.html', + styleUrls: ['./manage-widget-actions.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ManageWidgetActionsComponent), + multi: true + } + ] +}) +export class ManageWidgetActionsComponent extends PageComponent implements OnInit, AfterViewInit, OnDestroy, ControlValueAccessor { + + @Input() disabled: boolean; + + @Input() callbacks: WidgetActionCallbacks; + + innerValue: WidgetActionsData; + + displayedColumns: string[]; + pageLink: PageLink; + textSearchMode = false; + dataSource: WidgetActionsDatasource; + + viewsInited = false; + dirtyValue = false; + + @ViewChild('searchInput', {static: false}) searchInputField: ElementRef; + + @ViewChild(MatPaginator, {static: false}) paginator: MatPaginator; + @ViewChild(MatSort, {static: false}) sort: MatSort; + + private propagateChange = (_: any) => {}; + + constructor(protected store: Store, + private translate: TranslateService, + private utils: UtilsService, + private dialog: MatDialog, + private dialogs: DialogService) { + super(store); + const sortOrder: SortOrder = { property: 'actionSourceName', direction: Direction.ASC }; + this.pageLink = new PageLink(10, 0, null, sortOrder); + this.dataSource = new WidgetActionsDatasource(this.translate, this.utils); + this.displayedColumns = ['actionSourceName', 'name', 'icon', 'typeName', 'actions']; + } + + ngOnInit(): void { + } + + ngOnDestroy(): void { + } + + ngAfterViewInit() { + + fromEvent(this.searchInputField.nativeElement, 'keyup') + .pipe( + debounceTime(150), + distinctUntilChanged(), + tap(() => { + this.paginator.pageIndex = 0; + this.updateData(); + }) + ) + .subscribe(); + + this.sort.sortChange.subscribe(() => this.paginator.pageIndex = 0); + + merge(this.sort.sortChange, this.paginator.page) + .pipe( + tap(() => this.updateData()) + ) + .subscribe(); + + this.viewsInited = true; + if (this.dirtyValue) { + this.dirtyValue = false; + this.updateData(true); + } + + } + + updateData(reload: boolean = false) { + this.pageLink.page = this.paginator.pageIndex; + this.pageLink.pageSize = this.paginator.pageSize; + this.pageLink.sortOrder.property = this.sort.active; + this.pageLink.sortOrder.direction = Direction[this.sort.direction.toUpperCase()]; + this.dataSource.loadActions(this.pageLink, reload); + } + + addAction($event: Event) { + this.openWidgetActionDialog($event); + } + + editAction($event: Event, action: WidgetActionDescriptorInfo) { + this.openWidgetActionDialog($event, action); + } + + openWidgetActionDialog($event: Event, action: WidgetActionDescriptorInfo = null) { + if ($event) { + $event.stopPropagation(); + } + const isAdd = action === null; + let prevActionSourceId = null; + if (!isAdd) { + prevActionSourceId = action.actionSourceId; + } + const availableActionSources: {[actionSourceId: string]: WidgetActionSource} = {}; + for (const id of Object.keys(this.innerValue.actionSources)) { + const actionSource = this.innerValue.actionSources[id]; + if (actionSource.multiple) { + availableActionSources[id] = actionSource; + } else { + if (!isAdd && action.actionSourceId === id) { + availableActionSources[id] = actionSource; + } else { + const existing = this.innerValue.actionsMap[id]; + if (!existing || !existing.length) { + availableActionSources[id] = actionSource; + } + } + } + } + + const actionsData: WidgetActionsData = { + actionsMap: this.innerValue.actionsMap, + actionSources: availableActionSources + }; + + this.dialog.open(WidgetActionDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + isAdd, + callbacks: this.callbacks, + actionsData, + action: deepClone(action) + } + }).afterClosed().subscribe( + (res) => { + if (res) { + this.saveAction(res, isAdd, prevActionSourceId); + } + } + ); + } + + private saveAction(actionInfo: WidgetActionDescriptorInfo, isAdd: boolean, prevActionSourceId: string) { + const actionSourceId = actionInfo.actionSourceId; + const action = toWidgetActionDescriptor(actionInfo); + if (isAdd) { + const targetActions = this.getOrCreateTargetActions(actionSourceId); + targetActions.push(action); + } else { + if (actionSourceId !== prevActionSourceId) { + let targetActions = this.getOrCreateTargetActions(prevActionSourceId); + const targetIndex = targetActions.findIndex((targetAction) => targetAction.id === action.id); + if (targetIndex > -1) { + targetActions.splice(targetIndex, 1); + } + targetActions = this.getOrCreateTargetActions(actionSourceId); + targetActions.push(action); + } else { + const targetActions = this.getOrCreateTargetActions(actionSourceId); + const targetIndex = targetActions.findIndex((targetAction) => targetAction.id === action.id); + if (targetIndex > -1) { + targetActions[targetIndex] = action; + } + } + } + this.onActionsUpdated(); + } + + private getOrCreateTargetActions(actionSourceId: string): Array { + const actionsMap = this.innerValue.actionsMap; + let targetActions = actionsMap[actionSourceId]; + if (!targetActions) { + targetActions = []; + actionsMap[actionSourceId] = targetActions; + } + return targetActions; + } + + deleteAction($event: Event, action: WidgetActionDescriptorInfo) { + if ($event) { + $event.stopPropagation(); + } + const title = this.translate.instant('widget-config.delete-action-title'); + const content = this.translate.instant('widget-config.delete-action-text', {actionName: action.name}); + this.dialogs.confirm(title, content, + this.translate.instant('action.no'), + this.translate.instant('action.yes'), true).subscribe( + (res) => { + if (res) { + const targetActions = this.getOrCreateTargetActions(action.actionSourceId); + const targetIndex = targetActions.findIndex((targetAction) => targetAction.id === action.id); + if (targetIndex > -1) { + targetActions.splice(targetIndex, 1); + this.onActionsUpdated(); + } + } + }); + } + + enterFilterMode() { + this.textSearchMode = true; + this.pageLink.textSearch = ''; + setTimeout(() => { + this.searchInputField.nativeElement.focus(); + this.searchInputField.nativeElement.setSelectionRange(0, 0); + }, 10); + } + + exitFilterMode() { + this.textSearchMode = false; + this.pageLink.textSearch = null; + this.paginator.pageIndex = 0; + this.updateData(); + } + + resetSortAndFilter(update: boolean = true) { + this.pageLink.textSearch = null; + this.paginator.pageIndex = 0; + const sortable = this.sort.sortables.get('actionSourceName'); + this.sort.active = sortable.id; + this.sort.direction = 'asc'; + if (update) { + this.updateData(true); + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(obj: WidgetActionsData): void { + this.innerValue = obj; + setTimeout(() => { + this.dataSource.setActions(this.innerValue); + if (this.viewsInited) { + this.resetSortAndFilter(true); + } else { + this.dirtyValue = true; + } + }, 0); + } + + private onActionsUpdated() { + this.updateData(true); + this.propagateChange(this.innerValue); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html new file mode 100644 index 0000000000..06dcb65a68 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html @@ -0,0 +1,163 @@ + +
+ +

{{ (isAdd ? 'widget-config.add-action' : 'widget-config.edit-action' ) | translate }}

+ + +
+ + +
+
+
+ + widget-config.action-source + + + {{ actionSourceName(actionSourceItem.value) }} + + + + {{ 'widget-config.action-source-required' | translate }} + + + + widget-config.action-name + + + {{ 'widget-config.action-name-required' | translate }} + + + + + + + + widget-config.action-type + + + {{ widgetActionTypeTranslations.get(actionType) | translate }} + + + + {{ 'widget-config.action-type-required' | translate }} + + +
+ +
+
widget-action.target-dashboard
+ +
+
+ + + + + + + + + + + {{ 'widget-action.target-dashboard-state-required' | translate }} + + + + + + {{ 'widget-action.open-right-layout' | translate }} + + + +
+ + {{ 'widget-action.set-entity-from-widget' | translate }} + + + alias.state-entity-parameter-name + + +
+
+ + + + + + + +
+
+
+
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts new file mode 100644 index 0000000000..a3c85401be --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts @@ -0,0 +1,295 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, ElementRef, Inject, OnInit, SkipSelf, ViewChild } from '@angular/core'; +import { ErrorStateMatcher, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + FormBuilder, + FormControl, + FormGroup, + FormGroupDirective, + NgForm, + ValidatorFn, + Validators +} from '@angular/forms'; +import { Observable, of } from 'rxjs'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@app/shared/components/dialog.component'; +import { + toCustomAction, + WidgetActionCallbacks, + WidgetActionDescriptorInfo, + WidgetActionsData +} from '@home/components/widget/action/manage-widget-actions.component.models'; +import { UtilsService } from '@core/services/utils.service'; +import { WidgetActionSource, WidgetActionType, widgetActionTypeTranslationMap } from '@shared/models/widget.models'; +import { map, mergeMap, startWith, tap } from 'rxjs/operators'; +import { DashboardService } from '@core/http/dashboard.service'; +import { Dashboard } from '@shared/models/dashboard.models'; +import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; + +export interface WidgetActionDialogData { + isAdd: boolean; + callbacks: WidgetActionCallbacks; + actionsData: WidgetActionsData; + action?: WidgetActionDescriptorInfo; +} + +@Component({ + selector: 'tb-widget-action-dialog', + templateUrl: './widget-action-dialog.component.html', + providers: [{provide: ErrorStateMatcher, useExisting: WidgetActionDialogComponent}], + styleUrls: [] +}) +export class WidgetActionDialogComponent extends DialogComponent implements OnInit, ErrorStateMatcher { + + @ViewChild('dashboardStateInput', {static: false}) dashboardStateInput: ElementRef; + + widgetActionFormGroup: FormGroup; + actionTypeFormGroup: FormGroup; + + isAdd: boolean; + action: WidgetActionDescriptorInfo; + + widgetActionTypes = Object.keys(WidgetActionType); + widgetActionTypeTranslations = widgetActionTypeTranslationMap; + widgetActionType = WidgetActionType; + + filteredDashboardStates: Observable>; + targetDashboardStateSearchText = ''; + selectedDashboardStateIds: Observable>; + + submitted = false; + + constructor(protected store: Store, + protected router: Router, + private utils: UtilsService, + private dashboardService: DashboardService, + private dashboardUtils: DashboardUtilsService, + @Inject(MAT_DIALOG_DATA) public data: WidgetActionDialogData, + @SkipSelf() private errorStateMatcher: ErrorStateMatcher, + public dialogRef: MatDialogRef, + public fb: FormBuilder) { + super(store, router, dialogRef); + this.isAdd = data.isAdd; + if (this.isAdd) { + this.action = { + id: this.utils.guid(), + name: '', + icon: 'more_horiz', + type: null + }; + } else { + this.action = this.data.action; + } + } + + ngOnInit(): void { + this.widgetActionFormGroup = this.fb.group({}); + this.widgetActionFormGroup.addControl('actionSourceId', + this.fb.control(this.action.actionSourceId, [Validators.required])); + this.widgetActionFormGroup.addControl('name', + this.fb.control(this.action.name, [this.validateActionName(), Validators.required])); + this.widgetActionFormGroup.addControl('icon', + this.fb.control(this.action.icon, [Validators.required])); + this.widgetActionFormGroup.addControl('type', + this.fb.control(this.action.type, [Validators.required])); + this.updateActionTypeFormGroup(this.action.type, this.action); + this.widgetActionFormGroup.get('type').valueChanges.subscribe((type: WidgetActionType) => { + this.updateActionTypeFormGroup(type); + }); + this.widgetActionFormGroup.get('actionSourceId').valueChanges.subscribe(() => { + this.widgetActionFormGroup.get('name').updateValueAndValidity(); + }); + } + + private updateActionTypeFormGroup(type?: WidgetActionType, action?: WidgetActionDescriptorInfo) { + this.actionTypeFormGroup = this.fb.group({}); + if (type) { + switch (type) { + case WidgetActionType.openDashboard: + case WidgetActionType.openDashboardState: + case WidgetActionType.updateDashboardState: + this.actionTypeFormGroup.addControl( + 'targetDashboardStateId', + this.fb.control(action ? action.targetDashboardStateId : null, + type === WidgetActionType.openDashboardState ? [Validators.required] : []) + ); + this.actionTypeFormGroup.addControl( + 'setEntityId', + this.fb.control(action ? action.setEntityId : true, []) + ); + this.actionTypeFormGroup.addControl( + 'stateEntityParamName', + this.fb.control(action ? action.stateEntityParamName : null, []) + ); + if (type === WidgetActionType.openDashboard) { + this.actionTypeFormGroup.addControl( + 'targetDashboardId', + this.fb.control(action ? action.targetDashboardId : null, + [Validators.required]) + ); + this.setupSelectedDashboardStateIds(action ? action.targetDashboardId : null); + } else { + this.actionTypeFormGroup.addControl( + 'openRightLayout', + this.fb.control(action ? action.openRightLayout : false, []) + ); + } + this.setupFilteredDashboardStates(); + break; + case WidgetActionType.custom: + this.actionTypeFormGroup.addControl( + 'customFunction', + this.fb.control(action ? action.customFunction : null, []) + ); + break; + case WidgetActionType.customPretty: + this.actionTypeFormGroup.addControl( + 'customAction', + this.fb.control(toCustomAction(action), [Validators.required]) + ); + break; + } + } + } + + private setupSelectedDashboardStateIds(targetDashboardId?: string) { + this.selectedDashboardStateIds = + this.actionTypeFormGroup.get('targetDashboardId').valueChanges.pipe( + // startWith(targetDashboardId), + tap(() => { + this.targetDashboardStateSearchText = ''; + }), + mergeMap((dashboardId) => { + if (dashboardId) { + return this.dashboardService.getDashboard(dashboardId); + } else { + return of(null); + } + }), + map((dashboard: Dashboard) => { + if (dashboard) { + dashboard = this.dashboardUtils.validateAndUpdateDashboard(dashboard); + const states = dashboard.configuration.states; + return Object.keys(states); + } else { + return []; + } + }) + ); + } + + private setupFilteredDashboardStates() { + this.targetDashboardStateSearchText = ''; + this.filteredDashboardStates = this.actionTypeFormGroup.get('targetDashboardStateId').valueChanges + .pipe( + startWith(''), + map(value => value ? value : ''), + mergeMap(name => this.fetchDashboardStates(name) ) + ); + } + + private fetchDashboardStates(searchText?: string): Observable> { + this.targetDashboardStateSearchText = searchText; + if (this.widgetActionFormGroup.get('type').value === WidgetActionType.openDashboard) { + return this.selectedDashboardStateIds.pipe( + map(stateIds => { + const result = searchText ? stateIds.filter(this.createFilterForDashboardState(searchText)) : stateIds; + if (result && result.length) { + return result; + } else { + return [searchText]; + } + }) + ); + } else { + return of(this.data.callbacks.fetchDashboardStates(searchText)); + } + } + + private createFilterForDashboardState(query: string): (stateId: string) => boolean { + const lowercaseQuery = query.toLowerCase(); + return stateId => stateId.toLowerCase().indexOf(lowercaseQuery) === 0; + } + + public clearTargetDashboardState(value: string = '') { + this.dashboardStateInput.nativeElement.value = value; + this.actionTypeFormGroup.get('targetDashboardStateId').patchValue(value, {emitEvent: true}); + setTimeout(() => { + this.dashboardStateInput.nativeElement.blur(); + this.dashboardStateInput.nativeElement.focus(); + }, 0); + } + + private validateActionName(): ValidatorFn { + return (c: FormControl) => { + const newName = c.value; + const valid = this.checkActionName(newName, this.widgetActionFormGroup.get('actionSourceId').value); + return !valid ? { + actionNameNotUnique: true + } : null; + }; + } + + private checkActionName(name: string, actionSourceId: string): boolean { + let actionNameIsUnique = true; + if (name && actionSourceId) { + const sourceActions = this.data.actionsData.actionsMap[actionSourceId]; + if (sourceActions) { + const result = sourceActions.filter((sourceAction) => sourceAction.name === name); + if (result && result.length && result[0].id !== this.action.id) { + actionNameIsUnique = false; + } + } + } + return actionNameIsUnique; + } + + 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; + } + + public actionSourceName(actionSource: WidgetActionSource): string { + if (actionSource) { + return this.utils.customTranslation(actionSource.name, actionSource.name); + } else { + return ''; + } + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + this.submitted = true; + const type: WidgetActionType = this.widgetActionFormGroup.get('type').value; + let result: WidgetActionDescriptorInfo; + if (type === WidgetActionType.customPretty) { + result = {...this.widgetActionFormGroup.value, ...this.actionTypeFormGroup.get('customAction').value}; + } else { + result = {...this.widgetActionFormGroup.value, ...this.actionTypeFormGroup.value}; + } + result.id = this.action.id; + this.dialogRef.close(result); + } +} 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 77be49186c..b15c9d053e 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 @@ -16,7 +16,7 @@ import { COMMA, ENTER, SEMICOLON } from '@angular/cdk/keycodes'; import { - AfterViewInit, + AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, forwardRef, @@ -413,7 +413,7 @@ export class DataKeysComponent implements ControlValueAccessor, OnInit, AfterVie let fetchObservable: Observable> = null; if (this.datasourceType === DatasourceType.function || this.widgetType === widgetType.alarm) { const dataKeyFilter = this.createDataKeyFilter(this.searchText); - const targetKeysList = this.datasourceType === DatasourceType.function ? this.functionTypeKeys : this.alarmKeys; + const targetKeysList = this.widgetType === widgetType.alarm ? this.alarmKeys : this.functionTypeKeys; fetchObservable = of(targetKeysList.filter(dataKeyFilter)); } else { if (this.entityAliasId) { diff --git a/ui-ngx/src/app/modules/home/components/widget/legend-config-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/legend-config-panel.component.html new file mode 100644 index 0000000000..ac6722d78f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/legend-config-panel.component.html @@ -0,0 +1,57 @@ + +
+
+
+
+
+ + legend.direction + + + {{ legendDirectionTranslations.get(direction) | translate }} + + + + + legend.position + + + {{ legendPositionTranslations.get(pos) | translate }} + + + + + {{ 'legend.show-min' | translate }} + + + {{ 'legend.show-max' | translate }} + + + {{ 'legend.show-avg' | translate }} + + + {{ 'legend.show-total' | translate }} + +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/legend-config-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/legend-config-panel.component.scss new file mode 100644 index 0000000000..94ab8d5d45 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/legend-config-panel.component.scss @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2019 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + width: 100%; + height: 100%; + form, + fieldset { + height: 100%; + } + + .mat-content { + overflow: hidden; + background-color: #fff; + } + + .mat-padding { + padding: 16px; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/legend-config-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/legend-config-panel.component.ts new file mode 100644 index 0000000000..b0d31efe44 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/legend-config-panel.component.ts @@ -0,0 +1,118 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject, InjectionToken, OnInit, ViewContainerRef } from '@angular/core'; +import { Overlay, OverlayRef } from '@angular/cdk/overlay'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { + LegendConfig, + LegendDirection, + legendDirectionTranslationMap, + LegendPosition, + legendPositionTranslationMap +} from '@shared/models/widget.models'; + +export const LEGEND_CONFIG_PANEL_DATA = new InjectionToken('LegendConfigPanelData'); + +export interface LegendConfigPanelData { + legendConfig: LegendConfig; + legendConfigUpdated: (legendConfig: LegendConfig) => void; +} + +@Component({ + selector: 'tb-legend-config-panel', + templateUrl: './legend-config-panel.component.html', + styleUrls: ['./legend-config-panel.component.scss'] +}) +export class LegendConfigPanelComponent extends PageComponent implements OnInit { + + legendConfigForm: FormGroup; + + legendDirection = LegendDirection; + + legendDirections = Object.keys(LegendDirection); + + legendDirectionTranslations = legendDirectionTranslationMap; + + legendPosition = LegendPosition; + + legendPositions = Object.keys(LegendPosition); + + legendPositionTranslations = legendPositionTranslationMap; + + constructor(@Inject(LEGEND_CONFIG_PANEL_DATA) public data: LegendConfigPanelData, + public overlayRef: OverlayRef, + protected store: Store, + public fb: FormBuilder, + private overlay: Overlay, + public viewContainerRef: ViewContainerRef) { + super(store); + } + + ngOnInit(): void { + this.legendConfigForm = this.fb.group({ + direction: [this.data.legendConfig.direction, []], + position: [this.data.legendConfig.position, []], + showMin: [this.data.legendConfig.showMin, []], + showMax: [this.data.legendConfig.showMax, []], + showAvg: [this.data.legendConfig.showAvg, []], + showTotal: [this.data.legendConfig.showTotal, []] + }); + this.legendConfigForm.get('direction').valueChanges.subscribe((direction: LegendDirection) => { + this.onDirectionChanged(direction); + }); + this.onDirectionChanged(this.data.legendConfig.direction); + this.legendConfigForm.valueChanges.subscribe(() => { + this.update(); + }); + } + + private onDirectionChanged(direction: LegendDirection) { + if (direction === LegendDirection.row) { + let position: LegendPosition = this.legendConfigForm.get('position').value; + if (position !== LegendPosition.bottom && position !== LegendPosition.top) { + position = LegendPosition.bottom; + } + this.legendConfigForm.patchValue( + { + position, + showMin: false, + showMax: false, + showAvg: false, + showTotal: false + }, {emitEvent: false} + ); + this.legendConfigForm.get('showMin').disable({emitEvent: false}); + this.legendConfigForm.get('showMax').disable({emitEvent: false}); + this.legendConfigForm.get('showAvg').disable({emitEvent: false}); + this.legendConfigForm.get('showTotal').disable({emitEvent: false}); + } else { + this.legendConfigForm.get('showMin').enable({emitEvent: false}); + this.legendConfigForm.get('showMax').enable({emitEvent: false}); + this.legendConfigForm.get('showAvg').enable({emitEvent: false}); + this.legendConfigForm.get('showTotal').enable({emitEvent: false}); + } + } + + update() { + const newLegendConfig: LegendConfig = this.legendConfigForm.value; + this.data.legendConfigUpdated(newLegendConfig); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/legend-config.component.html b/ui-ngx/src/app/modules/home/components/widget/legend-config.component.html new file mode 100644 index 0000000000..f143856ec3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/legend-config.component.html @@ -0,0 +1,22 @@ + + diff --git a/ui-ngx/src/app/modules/home/components/widget/legend-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/legend-config.component.ts new file mode 100644 index 0000000000..a61a1648f4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/legend-config.component.ts @@ -0,0 +1,198 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + Component, + forwardRef, Inject, + Input, + OnDestroy, + OnInit, + ViewChild, + ViewContainerRef +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { MillisecondsToTimeStringPipe } from '@shared/pipe/milliseconds-to-time-string.pipe'; +import { + HistoryWindowType, + Timewindow, + TimewindowType, + initModelFromDefaultTimewindow, cloneSelectedTimewindow +} from '@shared/models/time/time.models'; +import { DatePipe } from '@angular/common'; +import { + Overlay, + CdkOverlayOrigin, + OverlayConfig, + OverlayPositionBuilder, ConnectedPosition, PositionStrategy, OverlayRef +} from '@angular/cdk/overlay'; +import { + TIMEWINDOW_PANEL_DATA, + TimewindowPanelComponent, + TimewindowPanelData +} from '@shared/components/time/timewindow-panel.component'; +import { ComponentPortal, PortalInjector } from '@angular/cdk/portal'; +import { MediaBreakpoints } from '@shared/models/constants'; +import { BreakpointObserver } from '@angular/cdk/layout'; +import { DOCUMENT } from '@angular/common'; +import { WINDOW } from '@core/services/window.service'; +import { TimeService } from '@core/services/time.service'; +import { TooltipPosition } from '@angular/material/typings/tooltip'; +import { deepClone } from '@core/utils'; +import { LegendConfig } from '@shared/models/widget.models'; +import { + LEGEND_CONFIG_PANEL_DATA, + LegendConfigPanelComponent, + LegendConfigPanelData +} from '@home/components/widget/legend-config-panel.component'; + +@Component({ + selector: 'tb-legend-config', + templateUrl: './legend-config.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => LegendConfigComponent), + multi: true + } + ] +}) +export class LegendConfigComponent implements OnInit, OnDestroy, ControlValueAccessor { + + @Input() disabled: boolean; + + @ViewChild('legendConfigPanelOrigin', {static: false}) legendConfigPanelOrigin: CdkOverlayOrigin; + + innerValue: LegendConfig; + + private propagateChange = (_: any) => {}; + + constructor(private overlay: Overlay, + public viewContainerRef: ViewContainerRef, + public breakpointObserver: BreakpointObserver, + @Inject(DOCUMENT) private document: Document, + @Inject(WINDOW) private window: Window) { + } + + ngOnInit(): void { + } + + ngOnDestroy(): void { + } + + openEditMode() { + if (this.disabled) { + return; + } + const isGtSm = this.breakpointObserver.isMatched(MediaBreakpoints['gt-sm']); + const position = this.overlay.position(); + const config = new OverlayConfig({ + panelClass: 'tb-legend-config-panel', + backdropClass: 'cdk-overlay-transparent-backdrop', + hasBackdrop: isGtSm, + }); + if (isGtSm) { + config.minWidth = '220px'; + config.maxHeight = '300px'; + const panelHeight = 220; + const panelWidth = 220; + const el = this.legendConfigPanelOrigin.elementRef.nativeElement; + const offset = el.getBoundingClientRect(); + const scrollTop = this.window.pageYOffset || this.document.documentElement.scrollTop || this.document.body.scrollTop || 0; + const scrollLeft = this.window.pageXOffset || this.document.documentElement.scrollLeft || this.document.body.scrollLeft || 0; + const bottomY = offset.bottom - scrollTop; + const leftX = offset.left - scrollLeft; + let originX; + let originY; + let overlayX; + let overlayY; + const wHeight = this.document.documentElement.clientHeight; + const wWidth = this.document.documentElement.clientWidth; + if (bottomY + panelHeight > wHeight) { + originY = 'top'; + overlayY = 'bottom'; + } else { + originY = 'bottom'; + overlayY = 'top'; + } + if (leftX + panelWidth > wWidth) { + originX = 'end'; + overlayX = 'end'; + } else { + originX = 'start'; + overlayX = 'start'; + } + const connectedPosition: ConnectedPosition = { + originX, + originY, + overlayX, + overlayY + }; + config.positionStrategy = position.flexibleConnectedTo(this.legendConfigPanelOrigin.elementRef) + .withPositions([connectedPosition]); + } else { + config.minWidth = '100%'; + config.minHeight = '100%'; + config.positionStrategy = position.global().top('0%').left('0%') + .right('0%').bottom('0%'); + } + + const overlayRef = this.overlay.create(config); + + overlayRef.backdropClick().subscribe(() => { + overlayRef.dispose(); + }); + + const injector = this._createLegendConfigPanelInjector( + overlayRef, + { + legendConfig: deepClone(this.innerValue), + legendConfigUpdated: this.legendConfigUpdated.bind(this) + } + ); + + overlayRef.attach(new ComponentPortal(LegendConfigPanelComponent, this.viewContainerRef, injector)); + } + + private _createLegendConfigPanelInjector(overlayRef: OverlayRef, data: LegendConfigPanelData): PortalInjector { + const injectionTokens = new WeakMap([ + [LEGEND_CONFIG_PANEL_DATA, data], + [OverlayRef, overlayRef] + ]); + return new PortalInjector(this.viewContainerRef.injector, injectionTokens); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(obj: LegendConfig): void { + this.innerValue = obj; + } + + private legendConfigUpdated(legendConfig: LegendConfig) { + this.innerValue = legendConfig; + this.propagateChange(this.innerValue); + } +} 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 837d86f7fd..4f769b9051 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 @@ -181,6 +181,188 @@ + + + + {{ 'widget-config.alarm-source' | translate }} + + +
+
+ + + + {{ datasourceTypesTranslations.get(datasourceType) | translate }} + + + +
+ + + + + + + + + + +
+ + +
+
+
+ + + +
+
+ widget-config.general-settings +
+ + widget-config.title + + +
+ +
+
+
+
+ + {{ 'widget-config.display-icon' | translate }} + +
+
+ + +
+ + + + widget-config.icon-size + + +
+
+
+ + {{ 'widget-config.display-title' | translate }} + +
+
+ + {{ 'widget-config.drop-shadow' | translate }} + +
+
+ + {{ 'widget-config.enable-fullscreen' | translate }} + +
+
+ +
+
+
+ + + + + + widget-config.padding + + + + widget-config.margin + + +
+
+ + widget-config.units + + + + widget-config.decimals + + +
+
+ + {{ 'widget-config.display-legend' | translate }} + +
+ + +
+
+
+
+ widget-config.mobile-mode-settings +
+ + widget-config.order + + + + widget-config.height + + +
+
@@ -191,6 +373,10 @@ - + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.models.ts b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.models.ts index f5954f48ec..ce1ba0c4e3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.models.ts @@ -16,5 +16,6 @@ import { EntityAliasSelectCallbacks } from '../alias/entity-alias-select.component.models'; import { DataKeysCallbacks } from './data-keys.component.models'; +import { WidgetActionCallbacks } from './action/manage-widget-actions.component.models'; -export type WidgetConfigCallbacks = EntityAliasSelectCallbacks & DataKeysCallbacks; +export type WidgetConfigCallbacks = EntityAliasSelectCallbacks & DataKeysCallbacks & WidgetActionCallbacks; diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss index 85f82154f0..d3b160ef9e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss @@ -18,6 +18,9 @@ :host { .tb-widget-config { + .tb-advanced-widget-config { + height: 100%; + } .tb-advanced-widget-config { height: 100%; } @@ -45,6 +48,13 @@ :host ::ng-deep { .tb-widget-config { + .mat-tab-body-wrapper { + position: absolute; + top: 49px; + left: 0; + right: 0; + bottom: 0; + } .mat-tab-body.mat-tab-body-active { .mat-tab-body-content > div { height: 100%; diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts index 9eb7cbb926..2b221be827 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts @@ -14,19 +14,17 @@ /// limitations under the License. /// -import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, forwardRef, Input, OnInit } from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { DataKey, Datasource, - DatasourceType, datasourceTypeTranslationMap, - LegendConfig, + DatasourceType, + datasourceTypeTranslationMap, defaultLegendConfig, WidgetActionDescriptor, - WidgetActionSource, - widgetType, - WidgetTypeParameters + widgetType } from '@shared/models/widget.models'; import { AbstractControl, @@ -37,7 +35,7 @@ import { FormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, - Validator, ValidatorFn, + Validator, Validators } from '@angular/forms'; import { WidgetConfigComponentData } from '@home/models/widget-component.models'; @@ -55,10 +53,12 @@ import { EntityAliasDialogComponent, EntityAliasDialogData } from '@home/components/alias/entity-alias-dialog.component'; -import { tap, mergeMap, map, catchError } from 'rxjs/operators'; +import { catchError, map, mergeMap, tap } from 'rxjs/operators'; import { MatDialog } from '@angular/material/dialog'; import { EntityService } from '@core/http/entity.service'; import { JsonFormComponentData } from '@shared/components/json-form/json-form-component.models'; +import { WidgetActionsData } from './action/manage-widget-actions.component.models'; +import { Dashboard } from '@shared/models/dashboard.models'; const emptySettingsSchema = { type: 'object', @@ -106,6 +106,9 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont @Input() functionsOnly: boolean; + @Input() + dashboardStates: Array; + @Input() disabled: boolean; widgetType: widgetType; @@ -117,34 +120,13 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont widgetConfigCallbacks: WidgetConfigCallbacks = { createEntityAlias: this.createEntityAlias.bind(this), generateDataKey: this.generateDataKey.bind(this), - fetchEntityKeys: this.fetchEntityKeys.bind(this) + fetchEntityKeys: this.fetchEntityKeys.bind(this), + fetchDashboardStates: this.fetchDashboardStates.bind(this) }; widgetEditMode = this.utils.widgetEditMode; selectedTab: number; - title: string; - showTitleIcon: boolean; - titleIcon: string; - iconColor: string; - iconSize: string; - showTitle: boolean; - dropShadow: boolean; - enableFullscreen: boolean; - backgroundColor: string; - color: string; - padding: string; - margin: string; - widgetStyle: string; - titleStyle: string; - units: string; - decimals: number; - showLegend: boolean; - legendConfig: LegendConfig; - actions: {[actionSourceId: string]: Array}; - alarmSource: Datasource; - mobileOrder: number; - mobileHeight: number; private modelValue: WidgetConfigComponentData; @@ -152,11 +134,19 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont public dataSettings: FormGroup; public targetDeviceSettings: FormGroup; + public alarmSourceSettings: FormGroup; + public widgetSettings: FormGroup; + public layoutSettings: FormGroup; public advancedSettings: FormGroup; + public actionsSettings: FormGroup; private dataSettingsChangesSubscription: Subscription; private targetDeviceSettingsSubscription: Subscription; + private alarmSourceSettingsSubscription: Subscription; + private widgetSettingsSubscription: Subscription; + private layoutSettingsSubscription: Subscription; private advancedSettingsSubscription: Subscription; + private actionsSettingsSubscription: Subscription; constructor(protected store: Store, private utils: UtilsService, @@ -173,6 +163,47 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont } else { this.datasourceTypes = [DatasourceType.function, DatasourceType.entity]; } + this.widgetSettings = this.fb.group({ + title: [null, []], + showTitleIcon: [null, []], + titleIcon: [null, []], + iconColor: [null, []], + iconSize: [null, []], + showTitle: [null, []], + dropShadow: [null, []], + enableFullscreen: [null, []], + backgroundColor: [null, []], + color: [null, []], + padding: [null, []], + margin: [null, []], + widgetStyle: [null, []], + titleStyle: [null, []], + units: [null, []], + decimals: [null, [Validators.min(0), Validators.max(15), Validators.pattern(/^\d*$/)]], + showLegend: [null, []], + legendConfig: [null, []] + }); + this.widgetSettings.get('showTitleIcon').valueChanges.subscribe((value: boolean) => { + if (value) { + this.widgetSettings.get('titleIcon').enable({emitEvent: false}); + } else { + this.widgetSettings.get('titleIcon').disable({emitEvent: false}); + } + }); + this.widgetSettings.get('showLegend').valueChanges.subscribe((value: boolean) => { + if (value) { + this.widgetSettings.get('legendConfig').enable({emitEvent: false}); + } else { + this.widgetSettings.get('legendConfig').disable({emitEvent: false}); + } + }); + this.layoutSettings = this.fb.group({ + mobileOrder: [null, [Validators.pattern(/^-?[0-9]+$/)]], + mobileHeight: [null, [Validators.min(1), Validators.max(10), Validators.pattern(/^\d*$/)]] + }); + this.actionsSettings = this.fb.group({ + actionsData: [null, []] + }); } private removeChangeSubscriptions() { @@ -184,10 +215,26 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont this.targetDeviceSettingsSubscription.unsubscribe(); this.targetDeviceSettingsSubscription = null; } + if (this.alarmSourceSettingsSubscription) { + this.alarmSourceSettingsSubscription.unsubscribe(); + this.alarmSourceSettingsSubscription = null; + } + if (this.widgetSettingsSubscription) { + this.widgetSettingsSubscription.unsubscribe(); + this.widgetSettingsSubscription = null; + } + if (this.layoutSettingsSubscription) { + this.layoutSettingsSubscription.unsubscribe(); + this.layoutSettingsSubscription = null; + } if (this.advancedSettingsSubscription) { this.advancedSettingsSubscription.unsubscribe(); this.advancedSettingsSubscription = null; } + if (this.actionsSettingsSubscription) { + this.actionsSettingsSubscription.unsubscribe(); + this.actionsSettingsSubscription = null; + } } private createChangeSubscriptions() { @@ -197,14 +244,27 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont this.targetDeviceSettingsSubscription = this.targetDeviceSettings.valueChanges.subscribe( () => this.updateTargetDeviceSettings() ); + this.alarmSourceSettingsSubscription = this.alarmSourceSettings.valueChanges.subscribe( + () => this.updateAlarmSourceSettings() + ); + this.widgetSettingsSubscription = this.widgetSettings.valueChanges.subscribe( + () => this.updateWidgetSettings() + ); + this.layoutSettingsSubscription = this.layoutSettings.valueChanges.subscribe( + () => this.updateLayoutSettings() + ); this.advancedSettingsSubscription = this.advancedSettings.valueChanges.subscribe( () => this.updateAdvancedSettings() ); + this.actionsSettingsSubscription = this.actionsSettings.valueChanges.subscribe( + () => this.updateActionSettings() + ); } private buildForms() { this.dataSettings = this.fb.group({}); this.targetDeviceSettings = this.fb.group({}); + this.alarmSourceSettings = this.fb.group({}); this.advancedSettings = this.fb.group({}); if (this.widgetType === widgetType.timeseries || this.widgetType === widgetType.alarm) { this.dataSettings.addControl('useDashboardTimewindow', this.fb.control(null)); @@ -235,6 +295,8 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont this.targetDeviceSettings.addControl('targetDeviceAliasId', this.fb.control(null, this.widgetEditMode ? [] : [Validators.required])); + } else if (this.widgetType === widgetType.alarm) { + this.alarmSourceSettings = this.buildDatasourceForm(); } } this.advancedSettings.addControl('settings', @@ -264,31 +326,54 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont const layout = this.modelValue.layout; if (config) { this.selectedTab = 0; - this.title = config.title; - this.showTitleIcon = isDefined(config.showTitleIcon) ? config.showTitleIcon : false; - this.titleIcon = isDefined(config.titleIcon) ? config.titleIcon : ''; - this.iconColor = isDefined(config.iconColor) ? config.iconColor : 'rgba(0, 0, 0, 0.87)'; - this.iconSize = isDefined(config.iconSize) ? config.iconSize : '24px'; - this.showTitle = config.showTitle; - this.dropShadow = isDefined(config.dropShadow) ? config.dropShadow : true; - this.enableFullscreen = isDefined(config.enableFullscreen) ? config.enableFullscreen : true; - this.backgroundColor = config.backgroundColor; - this.color = config.color; - this.padding = config.padding; - this.margin = config.margin; - this.widgetStyle = - JSON.stringify(isDefined(config.widgetStyle) ? config.widgetStyle : {}, null, 2); - this.titleStyle = - JSON.stringify(isDefined(config.titleStyle) ? config.titleStyle : { - fontSize: '16px', - fontWeight: 400 - }, null, 2); - this.units = config.units; - this.decimals = config.decimals; - this.actions = config.actions; - if (!this.actions) { - this.actions = {}; + this.widgetSettings.patchValue({ + title: config.title, + showTitleIcon: isDefined(config.showTitleIcon) ? config.showTitleIcon : false, + titleIcon: isDefined(config.titleIcon) ? config.titleIcon : '', + iconColor: isDefined(config.iconColor) ? config.iconColor : 'rgba(0, 0, 0, 0.87)', + iconSize: isDefined(config.iconSize) ? config.iconSize : '24px', + showTitle: config.showTitle, + dropShadow: isDefined(config.dropShadow) ? config.dropShadow : true, + enableFullscreen: isDefined(config.enableFullscreen) ? config.enableFullscreen : true, + backgroundColor: config.backgroundColor, + color: config.color, + padding: config.padding, + margin: config.margin, + widgetStyle: isDefined(config.widgetStyle) ? config.widgetStyle : {}, + titleStyle: isDefined(config.titleStyle) ? config.titleStyle : { + fontSize: '16px', + fontWeight: 400 + }, + units: config.units, + decimals: config.decimals, + showLegend: isDefined(config.showLegend) ? config.showLegend : + this.widgetType === widgetType.timeseries, + legendConfig: config.legendConfig || defaultLegendConfig(this.widgetType) + }, + {emitEvent: false} + ); + const showTitleIcon: boolean = this.widgetSettings.get('showTitleIcon').value; + if (showTitleIcon) { + this.widgetSettings.get('titleIcon').enable({emitEvent: false}); + } else { + this.widgetSettings.get('titleIcon').disable({emitEvent: false}); + } + const showLegend: boolean = this.widgetSettings.get('showLegend').value; + if (showLegend) { + this.widgetSettings.get('legendConfig').enable({emitEvent: false}); + } else { + this.widgetSettings.get('legendConfig').disable({emitEvent: false}); } + const actionsData: WidgetActionsData = { + actionsMap: config.actions || {}, + actionSources: this.modelValue.actionSources || {} + }; + this.actionsSettings.patchValue( + { + actionsData + }, + {emitEvent: false} + ); if (this.widgetType === widgetType.timeseries || this.widgetType === widgetType.alarm) { const useDashboardTimewindow = isDefined(config.useDashboardTimewindow) ? config.useDashboardTimewindow : true; @@ -346,29 +431,42 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont { alarmsPollingInterval: isDefined(config.alarmsPollingInterval) ? config.alarmsPollingInterval : 5}, {emitEvent: false} ); - if (config.alarmSource) { - this.alarmSource = config.alarmSource; - } else { - this.alarmSource = null; - } + this.alarmSourceSettings.patchValue( + config.alarmSource, {emitEvent: false} + ); + const alarmSourceType: DatasourceType = this.alarmSourceSettings.get('type').value; + this.alarmSourceSettings.get('entityAliasId').setValidators( + alarmSourceType === DatasourceType.entity ? [Validators.required] : [] + ); + this.alarmSourceSettings.get('entityAliasId').updateValueAndValidity({emitEvent: false}); } } this.updateSchemaForm(config.settings); if (layout) { - this.mobileOrder = layout.mobileOrder; - this.mobileHeight = layout.mobileHeight; + this.layoutSettings.patchValue( + { + mobileOrder: layout.mobileOrder, + mobileHeight: layout.mobileHeight + }, + {emitEvent: false} + ); } else { - this.mobileOrder = undefined; - this.mobileHeight = undefined; + this.layoutSettings.patchValue( + { + mobileOrder: null, + mobileHeight: null + }, + {emitEvent: false} + ); } } this.createChangeSubscriptions(); } } - private buildDatasourceForm(datasource?: Datasource): AbstractControl { + private buildDatasourceForm(datasource?: Datasource): FormGroup { const dataKeysRequired = !this.modelValue.typeParameters || !this.modelValue.typeParameters.dataKeysOptional; const datasourceFormGroup = this.fb.group( { @@ -427,6 +525,34 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont } } + private updateAlarmSourceSettings() { + if (this.modelValue) { + if (this.modelValue.config) { + const alarmSource: Datasource = this.alarmSourceSettings.value; + this.modelValue.config.alarmSource = alarmSource; + } + this.propagateChange(this.modelValue); + } + } + + private updateWidgetSettings() { + if (this.modelValue) { + if (this.modelValue.config) { + Object.assign(this.modelValue.config, this.widgetSettings.value); + } + this.propagateChange(this.modelValue); + } + } + + private updateLayoutSettings() { + if (this.modelValue) { + if (this.modelValue.layout) { + Object.assign(this.modelValue.layout, this.layoutSettings.value); + } + this.propagateChange(this.modelValue); + } + } + private updateAdvancedSettings() { if (this.modelValue) { if (this.modelValue.config) { @@ -437,6 +563,16 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont } } + private updateActionSettings() { + if (this.modelValue) { + if (this.modelValue.config) { + const actions = (this.actionsSettings.get('actionsData').value as WidgetActionsData).actionsMap; + this.modelValue.config.actions = actions; + } + this.propagateChange(this.modelValue); + } + } + public displayAdvanced(): boolean { return this.modelValue.settingsSchema && this.modelValue.settingsSchema.schema; } @@ -596,6 +732,21 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont ); } + private fetchDashboardStates(query: string): Array { + const stateIds = Object.keys(this.dashboardStates); + const result = query ? stateIds.filter(this.createFilterForDashboardState(query)) : stateIds; + if (result && result.length) { + return result; + } else { + return [query]; + } + } + + private createFilterForDashboardState(query: string): (stateId: string) => boolean { + const lowercaseQuery = query.toLowerCase(); + return stateId => stateId.toLowerCase().indexOf(lowercaseQuery) === 0; + } + public validate(c: FormControl) { if (!this.dataSettings.valid) { return { @@ -603,6 +754,18 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont valid: false } }; + } else if (!this.widgetSettings.valid) { + return { + widgetSettings: { + valid: false + } + }; + } else if (!this.layoutSettings.valid) { + return { + widgetSettings: { + valid: false + } + }; } else if (!this.advancedSettings.valid) { return { advancedSettings: { @@ -636,24 +799,6 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont }; } } - try { - JSON.parse(this.widgetStyle); - } catch (e) { - return { - widgetStyle: { - valid: false - } - }; - } - try { - JSON.parse(this.titleStyle); - } catch (e) { - return { - titleStyle: { - valid: false - } - }; - } } return null; } diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts index 9f5d846a1e..90e7771bab 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 @@ -38,6 +38,7 @@ import { Datasource, LegendConfig, LegendData, + LegendDirection, LegendPosition, Widget, WidgetActionDescriptor, @@ -45,7 +46,8 @@ import { WidgetActionType, WidgetResource, widgetType, - WidgetTypeParameters + WidgetTypeParameters, + defaultLegendConfig } from '@shared/models/widget.models'; import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; @@ -165,6 +167,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI private ngZone: NgZone, private cd: ChangeDetectorRef) { super(store); + this.cssParser.testMode = false; } ngOnInit(): void { @@ -179,14 +182,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI this.legendContainerLayoutType = 'column'; if (this.displayLegend) { - this.legendConfig = this.widget.config.legendConfig || - { - position: LegendPosition.bottom, - showMin: false, - showMax: false, - showAvg: this.widget.type === widgetType.timeseries, - showTotal: false - }; + this.legendConfig = this.widget.config.legendConfig || defaultLegendConfig(this.widget.type); this.legendData = { keys: [], data: [] @@ -194,8 +190,10 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI if (this.legendConfig.position === LegendPosition.top || this.legendConfig.position === LegendPosition.bottom) { this.legendContainerLayoutType = 'column'; + this.isLegendFirst = this.legendConfig.position === LegendPosition.top; } else { this.legendContainerLayoutType = 'row'; + this.isLegendFirst = this.legendConfig.position === LegendPosition.left; } switch (this.legendConfig.position) { case LegendPosition.top: @@ -352,7 +350,9 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI this.loadFromWidgetInfo(); } ); - + setTimeout(() => { + this.dashboardWidget.updateWidgetParams(); + }, 0); } ngAfterViewInit(): void { @@ -764,6 +764,7 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI timeWindowUpdated: (subscription, timeWindowConfig) => { this.ngZone.run(() => { this.widget.config.timewindow = timeWindowConfig; + this.cd.detectChanges(); }); } }; @@ -924,24 +925,16 @@ export class WidgetComponent extends PageComponent implements OnInit, AfterViewI if (targetDashboardStateId) { stateObject.id = targetDashboardStateId; } - const stateParams = { - dashboardId: targetDashboardId, - state: objToBase64([ stateObject ]) - }; const state = objToBase64([ stateObject ]); - const currentUrl = this.route.snapshot.url; + const isSinglePage = this.route.snapshot.data.singlePageMode; let url; - if (currentUrl.length > 1) { - if (currentUrl[currentUrl.length - 2].path === 'dashboard') { - url = `/dashboard/${targetDashboardId}?state=${state}`; - } else { - url = `/dashboards/${targetDashboardId}?state=${state}`; - } - } - if (url) { - const urlTree = this.router.parseUrl(url); - this.router.navigateByUrl(url); + if (isSinglePage) { + url = `/dashboard/${targetDashboardId}?state=${state}`; + } else { + url = `/dashboards/${targetDashboardId}?state=${state}`; } + const urlTree = this.router.parseUrl(url); + this.router.navigateByUrl(url); break; case WidgetActionType.custom: const customFunction = descriptor.customFunction; diff --git a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts index a0e6e2c24a..5b76b48eae 100644 --- a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts @@ -78,6 +78,7 @@ export interface IDashboardComponent { selectWidget(index: number, delay?: number); getSelectedWidget(): Widget; getEventGridPosition(event: Event): WidgetPosition; + notifyGridsterOptionsChanged(); } declare type DashboardWidgetUpdateOperation = 'add' | 'remove' | 'update'; @@ -185,7 +186,7 @@ export class DashboardWidgets implements Iterable { highlightWidget(index: number): DashboardWidget { const widget = this.findWidgetAtIndex(index); - if (widget && (!this.highlightedMode || !widget.highlighted)) { + if (widget && (!this.highlightedMode || !widget.highlighted || this.highlightedMode && widget.highlighted)) { this.highlightedMode = true; widget.highlighted = true; this.dashboardWidgets.forEach((dashboardWidget) => { @@ -248,6 +249,7 @@ export class DashboardWidgets implements Iterable { }); this.sortWidgets(); this.dashboard.gridsterOpts.maxRows = maxRows; + this.dashboard.notifyGridsterOptionsChanged(); } sortWidgets() { diff --git a/ui-ngx/src/app/modules/home/models/widget-component.models.ts b/ui-ngx/src/app/modules/home/models/widget-component.models.ts index 34be6e1ee9..438b0110ae 100644 --- a/ui-ngx/src/app/modules/home/models/widget-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/widget-component.models.ts @@ -68,7 +68,6 @@ export interface WidgetContext { width?: number; height?: number; $scope?: IDynamicWidgetComponent; - hideTitlePanel?: boolean; isEdit?: boolean; isMobile?: boolean; dashboard?: IDashboardComponent; @@ -87,15 +86,18 @@ export interface WidgetContext { stateController?: IStateController; aliasController?: IAliasController; activeEntityInfo?: SubscriptionEntityInfo; - widgetTitleTemplate?: string; - widgetTitle?: string; - customHeaderActions?: Array; - widgetActions?: Array; datasources?: Array; data?: Array; hiddenData?: Array<{data: DataSet}>; timeWindow?: WidgetTimewindow; + + hideTitlePanel?: boolean; + widgetTitleTemplate?: string; + widgetTitle?: string; + customHeaderActions?: Array; + widgetActions?: Array; + } export interface IDynamicWidgetComponent { @@ -122,7 +124,7 @@ export interface WidgetConfigComponentData { layout: WidgetLayout; widgetType: widgetType; typeParameters: WidgetTypeParameters; - actionSources: {[key: string]: WidgetActionSource}; + actionSources: {[actionSourceId: string]: WidgetActionSource}; isDataEnabled: boolean; settingsSchema: any; dataKeySettingsSchema: any; @@ -178,7 +180,7 @@ export interface WidgetTypeInstance { getDataKeySettingsSchema?: () => string; typeParameters?: () => WidgetTypeParameters; useCustomDatasources?: () => boolean; - actionSources?: () => {[key: string]: WidgetActionSource}; + actionSources?: () => {[actionSourceId: string]: WidgetActionSource}; onInit?: () => void; onDataUpdated?: () => void; diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.component.ts b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.component.ts index 8ff27f9453..970e6548f6 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.component.ts +++ b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.component.ts @@ -14,7 +14,16 @@ /// limitations under the License. /// -import { Component, Inject, OnDestroy, OnInit, ViewEncapsulation, ViewChild, NgZone } from '@angular/core'; +import { + Component, + Inject, + OnDestroy, + OnInit, + ViewEncapsulation, + ViewChild, + NgZone, + ChangeDetectorRef, ChangeDetectionStrategy, ApplicationRef +} from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -77,7 +86,8 @@ import { EditWidgetComponent } from '@home/pages/dashboard/edit-widget.component selector: 'tb-dashboard-page', templateUrl: './dashboard-page.component.html', styleUrls: ['./dashboard-page.component.scss'], - encapsulation: ViewEncapsulation.None + encapsulation: ViewEncapsulation.None, + // changeDetection: ChangeDetectionStrategy.OnPush }) export class DashboardPageComponent extends PageComponent implements IDashboardController, OnDestroy { @@ -149,7 +159,8 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC dashboardTimewindow: null, state: null, stateController: null, - aliasController: null + aliasController: null, + runChangeDetection: this.runChangeDetection.bind(this) }; addWidgetFabButtons: FooterFabButtons = { @@ -204,12 +215,15 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC private dashboardService: DashboardService, private itembuffer: ItemBufferService, private fb: FormBuilder, - private dialog: MatDialog) { + private dialog: MatDialog, + private ngZone: NgZone, + private cd: ChangeDetectorRef) { super(store); this.rxSubscriptions.push(this.route.data.subscribe( (data) => { this.init(data); + this.runChangeDetection(); } )); @@ -294,6 +308,12 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC this.rxSubscriptions.length = 0; } + public runChangeDetection() { + /*setTimeout(() => { + this.cd.detectChanges(); + });*/ + } + public openToolbar() { this.isToolbarOpenedAnimate = true; this.isToolbarOpened = true; @@ -646,7 +666,9 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC this.editingWidgetLayoutOriginal = widgetLayout; this.editingLayoutCtx.widgets[index] = widget; this.editingLayoutCtx.widgetLayouts[widget.id] = widgetLayout; - this.editingLayoutCtx.ctrl.highlightWidget(index, 0); + setTimeout(() => { + this.editingLayoutCtx.ctrl.highlightWidget(index, 0); + }, 0); } onEditWidgetClosed() { diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.models.ts b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.models.ts index 60176173d8..3e829681ec 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.models.ts +++ b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.models.ts @@ -25,6 +25,7 @@ import { WidgetPosition } from '@home/models/dashboard-component.models'; import { Observable } from 'rxjs'; +import { ChangeDetectorRef } from '@angular/core'; export declare type DashboardPageScope = 'tenant' | 'customer'; @@ -34,6 +35,7 @@ export interface DashboardContext { dashboardTimewindow: Timewindow; aliasController: IAliasController; stateController: IStateController; + runChangeDetection: () => void; } export interface IDashboardController { diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/edit-widget.component.html b/ui-ngx/src/app/modules/home/pages/dashboard/edit-widget.component.html index 8ebde89527..db5b54e437 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/edit-widget.component.html +++ b/ui-ngx/src/app/modules/home/pages/dashboard/edit-widget.component.html @@ -21,6 +21,7 @@ [aliasController]="aliasController" [functionsOnly]="widgetEditMode" [entityAliases]="dashboard.configuration.entityAliases" + [dashboardStates]="dashboard.configuration.states" formControlName="widgetConfig"> diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/edit-widget.component.ts b/ui-ngx/src/app/modules/home/pages/dashboard/edit-widget.component.ts index 562a7339da..adb3fa2bfe 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/edit-widget.component.ts +++ b/ui-ngx/src/app/modules/home/pages/dashboard/edit-widget.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, OnInit, Input, OnChanges, SimpleChanges, ViewChild } from '@angular/core'; +import { Component, OnInit, Input, OnChanges, SimpleChanges, ViewChild, ChangeDetectionStrategy } from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/layout/dashboard-layout.component.ts b/ui-ngx/src/app/modules/home/pages/dashboard/layout/dashboard-layout.component.ts index f52e97ea7b..0421720692 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/layout/dashboard-layout.component.ts +++ b/ui-ngx/src/app/modules/home/pages/dashboard/layout/dashboard-layout.component.ts @@ -85,8 +85,8 @@ export class DashboardLayoutComponent extends PageComponent implements ILayoutCo this.rxSubscriptions.push(this.dashboard.dashboardTimewindowChanged.subscribe( (dashboardTimewindow) => { this.dashboardCtx.dashboardTimewindow = dashboardTimewindow; - } - ) + this.dashboardCtx.runChangeDetection(); + }) ); this.initHotKeys(); } diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/states/default-state-controller.component.ts b/ui-ngx/src/app/modules/home/pages/dashboard/states/default-state-controller.component.ts index 4a6b2bcdf7..acf7f851f8 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/states/default-state-controller.component.ts +++ b/ui-ngx/src/app/modules/home/pages/dashboard/states/default-state-controller.component.ts @@ -90,7 +90,7 @@ export class DefaultStateControllerComponent extends StateControllerComponent im protected onStateChanged() { this.stateObject = this.parseState(this.currentState); - this.gotoState(this.stateObject[0].id, true); + this.gotoState(this.stateObject[0].id, false); } protected stateControllerId(): string { diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/states/entity-state-controller.component.ts b/ui-ngx/src/app/modules/home/pages/dashboard/states/entity-state-controller.component.ts index 4c87aaf2df..12479b7c24 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/states/entity-state-controller.component.ts +++ b/ui-ngx/src/app/modules/home/pages/dashboard/states/entity-state-controller.component.ts @@ -95,7 +95,7 @@ export class EntityStateControllerComponent extends StateControllerComponent imp protected onStateChanged() { this.stateObject = this.parseState(this.currentState); this.selectedStateIndex = this.stateObject.length - 1; - this.gotoState(this.stateObject[this.stateObject.length - 1].id, true); + this.gotoState(this.stateObject[this.stateObject.length - 1].id, false); } protected stateControllerId(): string { diff --git a/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts b/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts index 6b280bc40e..f4743ab4c5 100644 --- a/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts @@ -120,7 +120,7 @@ export class DashboardAutocompleteComponent implements ControlValueAccessor, OnI } ngAfterViewInit(): void { - this.selectFirstDashboardIfNeeded(); + // this.selectFirstDashboardIfNeeded(); } selectFirstDashboardIfNeeded(): void { @@ -159,6 +159,7 @@ export class DashboardAutocompleteComponent implements ControlValueAccessor, OnI } else { this.modelValue = null; this.selectDashboardFormGroup.get('dashboard').patchValue(null, {emitEvent: true}); + this.selectFirstDashboardIfNeeded(); } } diff --git a/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.html b/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.html new file mode 100644 index 0000000000..dbbf9adc9d --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.html @@ -0,0 +1,78 @@ + +
+ +

{{ 'icon.select-icon' | translate }}

+ +
+ + + +
+ +
+ + +
+
+ +
+
+
+
+ + + + + + +
+
+
+
+ + +
+
diff --git a/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.scss b/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.scss new file mode 100644 index 0000000000..2a333c1a35 --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.scss @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2019 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + .tb-material-icons-dialog { + position: relative; + } + .tb-icons-load { + top: 64px; + z-index: 3; + background: rgba(255, 255, 255, .75); + } +} + +:host ::ng-deep { + .tb-material-icons-dialog { + button.mat-icon-button.tb-select-icon-button { + width: 56px; + height: 56px; + padding: 16px; + margin: 10px; + border: solid 1px #ffa500; + border-radius: 0%; + line-height: 0; + } + } +} diff --git a/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.ts new file mode 100644 index 0000000000..643fc11d4b --- /dev/null +++ b/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.ts @@ -0,0 +1,108 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject, OnInit, QueryList, ViewChildren, TemplateRef, AfterViewInit } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@app/shared/components/dialog.component'; +import { UtilsService } from '@core/services/utils.service'; +import { FormControl } from '@angular/forms'; +import { Observable, of, merge, noop } from 'rxjs'; +import { delay, map, mergeMap, share, startWith, tap, mapTo } from 'rxjs/operators'; +import { DashboardInfo } from '@shared/models/dashboard.models'; +import { MatTab } from '@angular/material/tabs'; + +export interface MaterialIconsDialogData { + icon: string; +} + +@Component({ + selector: 'tb-material-icons-dialog', + templateUrl: './material-icons-dialog.component.html', + providers: [], + styleUrls: ['./material-icons-dialog.component.scss'] +}) +export class MaterialIconsDialogComponent extends DialogComponent + implements OnInit, AfterViewInit { + + @ViewChildren('iconButtons') iconButtons: QueryList; + + selectedIcon: string; + icons$: Observable>; + loadingIcons$: Observable; + + showAllControl: FormControl; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: MaterialIconsDialogData, + private utils: UtilsService, + public dialogRef: MatDialogRef) { + super(store, router, dialogRef); + this.selectedIcon = data.icon; + this.showAllControl = new FormControl(false); + } + + ngOnInit(): void { + this.icons$ = this.showAllControl.valueChanges.pipe( + map((showAll) => { + return {firstTime: false, showAll}; + }), + startWith<{firstTime: boolean, showAll: boolean}>({firstTime: true, showAll: false}), + mergeMap((data) => { + if (data.showAll) { + return this.utils.getMaterialIcons().pipe(delay(100)); + } else { + const res = of(this.utils.getCommonMaterialIcons()); + return data.firstTime ? res : res.pipe(delay(50)); + } + }), + share() + ); + } + + ngAfterViewInit(): void { + this.loadingIcons$ = merge( + this.showAllControl.valueChanges.pipe( + mapTo(true), + ), + this.iconButtons.changes.pipe( + delay(100), + mapTo( false), + ) + ).pipe( + tap((loadingIcons) => { + if (loadingIcons) { + this.showAllControl.disable({emitEvent: false}); + } else { + this.showAllControl.enable({emitEvent: false}); + } + }), + share() + ); + } + + selectIcon(icon: string) { + this.dialogRef.close(icon); + } + + cancel(): void { + this.dialogRef.close(null); + } + +} diff --git a/ui-ngx/src/app/shared/components/footer-fab-buttons.component.ts b/ui-ngx/src/app/shared/components/footer-fab-buttons.component.ts index ba3b55d5bc..5d09ff806a 100644 --- a/ui-ngx/src/app/shared/components/footer-fab-buttons.component.ts +++ b/ui-ngx/src/app/shared/components/footer-fab-buttons.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, Input, HostListener } from '@angular/core'; +import { Component, HostListener, Input } from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; diff --git a/ui-ngx/src/app/shared/components/fullscreen.directive.ts b/ui-ngx/src/app/shared/components/fullscreen.directive.ts index f220c6cc2e..b94b105d99 100644 --- a/ui-ngx/src/app/shared/components/fullscreen.directive.ts +++ b/ui-ngx/src/app/shared/components/fullscreen.directive.ts @@ -18,7 +18,7 @@ import { Directive, ElementRef, EventEmitter, - Input, OnChanges, + Input, OnChanges, OnDestroy, Output, SimpleChanges, ViewContainerRef } from '@angular/core'; @@ -29,7 +29,7 @@ import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; @Directive({ selector: '[tb-fullscreen]' }) -export class FullscreenDirective implements OnChanges { +export class FullscreenDirective implements OnChanges, OnDestroy { fullscreenValue = false; @@ -69,6 +69,12 @@ export class FullscreenDirective implements OnChanges { } } + ngOnDestroy(): void { + if (this.fullscreen) { + this.exitFullscreen(); + } + } + enterFullscreen() { const targetElement: HTMLElement = this.fullscreenElement || this.elementRef.nativeElement; this.parentElement = targetElement.parentElement; diff --git a/ui-ngx/src/app/shared/components/js-func.component.html b/ui-ngx/src/app/shared/components/js-func.component.html index f79b61f0d2..182d560bfc 100644 --- a/ui-ngx/src/app/shared/components/js-func.component.html +++ b/ui-ngx/src/app/shared/components/js-func.component.html @@ -17,7 +17,7 @@ -->
+ [fullscreen]="fullscreen" fxLayout="column">
@@ -25,6 +25,7 @@ {{'js-func.tidy' | translate }}
-
+
diff --git a/ui-ngx/src/app/shared/components/json-object-edit.component.ts b/ui-ngx/src/app/shared/components/json-object-edit.component.ts index f0789711ab..0ee763ecc0 100644 --- a/ui-ngx/src/app/shared/components/json-object-edit.component.ts +++ b/ui-ngx/src/app/shared/components/json-object-edit.component.ts @@ -15,7 +15,7 @@ /// import { - Attribute, + Attribute, ChangeDetectionStrategy, Component, ElementRef, forwardRef, @@ -60,6 +60,8 @@ export class JsonObjectEditComponent implements OnInit, ControlValueAccessor, Va @Input() fillHeight: boolean; + @Input() editorStyle: {[klass: string]: any}; + private requiredValue: boolean; get required(): boolean { return this.requiredValue; diff --git a/ui-ngx/src/app/shared/components/material-icon-select.component.html b/ui-ngx/src/app/shared/components/material-icon-select.component.html new file mode 100644 index 0000000000..b78b7711da --- /dev/null +++ b/ui-ngx/src/app/shared/components/material-icon-select.component.html @@ -0,0 +1,24 @@ + +
+ {{materialIconFormGroup.get('icon').value}} + + icon.icon + + +
diff --git a/ui-ngx/src/app/shared/components/material-icon-select.component.scss b/ui-ngx/src/app/shared/components/material-icon-select.component.scss new file mode 100644 index 0000000000..1d4532cd00 --- /dev/null +++ b/ui-ngx/src/app/shared/components/material-icon-select.component.scss @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2019 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + .mat-icon { + padding: 4px; + margin: 8px 4px 4px; + cursor: pointer; + border: solid 1px rgba(0, 0, 0, .27); + } +} diff --git a/ui-ngx/src/app/shared/components/material-icon-select.component.ts b/ui-ngx/src/app/shared/components/material-icon-select.component.ts new file mode 100644 index 0000000000..ec994d3c04 --- /dev/null +++ b/ui-ngx/src/app/shared/components/material-icon-select.component.ts @@ -0,0 +1,125 @@ +/// +/// Copyright © 2016-2019 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { DataKey, DatasourceType } from '@shared/models/widget.models'; +import { + ControlValueAccessor, + FormBuilder, + FormControl, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + Validator, + Validators +} from '@angular/forms'; +import { UtilsService } from '@core/services/utils.service'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { EntityService } from '@core/http/entity.service'; +import { DataKeysCallbacks } from '@home/components/widget/data-keys.component.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { Observable, of } from 'rxjs'; +import { map, mergeMap, tap } from 'rxjs/operators'; +import { alarmFields } from '@shared/models/alarm.models'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { DialogService } from '@core/services/dialog.service'; + +@Component({ + selector: 'tb-material-icon-select', + templateUrl: './material-icon-select.component.html', + styleUrls: ['./material-icon-select.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MaterialIconSelectComponent), + multi: true + } + ] +}) +export class MaterialIconSelectComponent extends PageComponent implements OnInit, ControlValueAccessor { + + @Input() + disabled: boolean; + + private modelValue: string; + + private propagateChange = null; + + public materialIconFormGroup: FormGroup; + + constructor(protected store: Store, + private dialogs: DialogService, + private fb: FormBuilder) { + super(store); + } + + ngOnInit(): void { + this.materialIconFormGroup = this.fb.group({ + icon: [null, []] + }); + + this.materialIconFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.materialIconFormGroup.disable({emitEvent: false}); + } else { + this.materialIconFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: string): void { + this.modelValue = value; + this.materialIconFormGroup.patchValue( + { icon: this.modelValue }, {emitEvent: false} + ); + } + + private updateModel() { + const icon: string = this.materialIconFormGroup.get('icon').value; + if (this.modelValue !== icon) { + this.modelValue = icon; + this.propagateChange(this.modelValue); + } + } + + openIconDialog() { + this.dialogs.materialIconPicker(this.materialIconFormGroup.get('icon').value).subscribe( + (icon) => { + if (icon) { + this.materialIconFormGroup.patchValue( + {icon}, {emitEvent: true} + ); + } + } + ); + } +} diff --git a/ui-ngx/src/app/shared/components/time/timewindow.component.html b/ui-ngx/src/app/shared/components/time/timewindow.component.html index 38b65e46a6..b7d093706e 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow.component.html +++ b/ui-ngx/src/app/shared/components/time/timewindow.component.html @@ -18,7 +18,7 @@
@@ -32,7 +32,7 @@ (click)="openEditMode($event)" matTooltip="{{ 'timewindow.edit' | translate }}" [matTooltipPosition]="tooltipPosition"> - {{innerValue.displayValue}} + {{innerValue?.displayValue}}