Browse Source

Widget Configuration: Widget Actions.

pull/2150/head
Igor Kulikov 7 years ago
parent
commit
c3ebc2f20d
  1. 6
      ui-ngx/package-lock.json
  2. 43
      ui-ngx/src/app/core/api/widget-subscription.ts
  3. 15
      ui-ngx/src/app/core/services/dialog.service.ts
  4. 39
      ui-ngx/src/app/core/services/utils.service.ts
  5. 2
      ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html
  6. 1
      ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.scss
  7. 35
      ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts
  8. 2
      ui-ngx/src/app/modules/home/components/details-panel.component.ts
  9. 25
      ui-ngx/src/app/modules/home/components/home-components.module.ts
  10. 53
      ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.html
  11. 107
      ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.scss
  12. 138
      ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.ts
  13. 100
      ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.html
  14. 79
      ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.scss
  15. 211
      ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.ts
  16. 118
      ui-ngx/src/app/modules/home/components/widget/action/custom-sample-css.raw
  17. 304
      ui-ngx/src/app/modules/home/components/widget/action/custom-sample-html.raw
  18. 397
      ui-ngx/src/app/modules/home/components/widget/action/custom-sample-js.raw
  19. 118
      ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.html
  20. 167
      ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.models.ts
  21. 43
      ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.scss
  22. 316
      ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts
  23. 163
      ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html
  24. 295
      ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts
  25. 4
      ui-ngx/src/app/modules/home/components/widget/data-keys.component.ts
  26. 57
      ui-ngx/src/app/modules/home/components/widget/legend-config-panel.component.html
  27. 32
      ui-ngx/src/app/modules/home/components/widget/legend-config-panel.component.scss
  28. 118
      ui-ngx/src/app/modules/home/components/widget/legend-config-panel.component.ts
  29. 22
      ui-ngx/src/app/modules/home/components/widget/legend-config.component.html
  30. 198
      ui-ngx/src/app/modules/home/components/widget/legend-config.component.ts
  31. 188
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.html
  32. 3
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.models.ts
  33. 10
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss
  34. 311
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts
  35. 43
      ui-ngx/src/app/modules/home/components/widget/widget.component.ts
  36. 4
      ui-ngx/src/app/modules/home/models/dashboard-component.models.ts
  37. 16
      ui-ngx/src/app/modules/home/models/widget-component.models.ts
  38. 32
      ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.component.ts
  39. 2
      ui-ngx/src/app/modules/home/pages/dashboard/dashboard-page.models.ts
  40. 1
      ui-ngx/src/app/modules/home/pages/dashboard/edit-widget.component.html
  41. 2
      ui-ngx/src/app/modules/home/pages/dashboard/edit-widget.component.ts
  42. 4
      ui-ngx/src/app/modules/home/pages/dashboard/layout/dashboard-layout.component.ts
  43. 2
      ui-ngx/src/app/modules/home/pages/dashboard/states/default-state-controller.component.ts
  44. 2
      ui-ngx/src/app/modules/home/pages/dashboard/states/entity-state-controller.component.ts
  45. 3
      ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts
  46. 78
      ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.html
  47. 39
      ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.scss
  48. 108
      ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.ts
  49. 2
      ui-ngx/src/app/shared/components/footer-fab-buttons.component.ts
  50. 10
      ui-ngx/src/app/shared/components/fullscreen.directive.ts
  51. 3
      ui-ngx/src/app/shared/components/js-func.component.html
  52. 13
      ui-ngx/src/app/shared/components/js-func.component.scss
  53. 49
      ui-ngx/src/app/shared/components/js-func.component.ts
  54. 1
      ui-ngx/src/app/shared/components/json-form/json-form.component.ts
  55. 2
      ui-ngx/src/app/shared/components/json-object-edit.component.html
  56. 4
      ui-ngx/src/app/shared/components/json-object-edit.component.ts
  57. 24
      ui-ngx/src/app/shared/components/material-icon-select.component.html
  58. 23
      ui-ngx/src/app/shared/components/material-icon-select.component.scss
  59. 125
      ui-ngx/src/app/shared/components/material-icon-select.component.ts
  60. 4
      ui-ngx/src/app/shared/components/time/timewindow.component.html
  61. 1
      ui-ngx/src/app/shared/components/time/timewindow.component.ts
  62. 29
      ui-ngx/src/app/shared/models/widget.models.ts
  63. 9
      ui-ngx/src/app/shared/shared.module.ts
  64. 1
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  65. 9
      ui-ngx/src/styles.scss
  66. 20
      ui-ngx/src/typings/rawloader.typings.d.ts
  67. 1
      ui-ngx/tsconfig.json

6
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",

43
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<string>): boolean {

15
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<string> {
return this.dialog.open<MaterialIconsDialogComponent, MaterialIconsDialogData, string>(MaterialIconsDialogComponent,
{
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
icon
}
}).afterClosed();
}
private permissionDenied() {
this.alert(
this.translate.instant('access.permission-denied'),

39
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<string> = [
alarmFields.status.keyName
];
const commonMaterialIcons: Array<string> = [ '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<DataKey> = [];
materialIcons: Array<string> = [];
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<Array<string>> {
if (this.materialIcons.length) {
return of(this.materialIcons);
} else {
const materialIconsSubject = new ReplaySubject<Array<string>>();
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<string> {
return commonMaterialIcons;
}
public getMaterialColor(index: number) {
const colorIndex = index % materialColors.length;
return materialColors[colorIndex].value;

2
ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html

@ -88,7 +88,7 @@
<tb-timewindow *ngIf="widget.hasTimewindow"
#timewindowComponent
aggregation="{{widget.hasAggregation}}"
[ngModel]="widget.widget.config.timewindow"
[ngModel]="widgetComponent.widget.config.timewindow"
(ngModelChange)="widgetComponent.onTimewindowChanged($event)">
</tb-timewindow>
</div>

1
ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.scss

@ -39,6 +39,7 @@
gridster-item {
transition: none;
overflow: visible;
background: none;
}
}

35
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();
}

2
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';

25
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

53
ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-editor.component.html

@ -0,0 +1,53 @@
<!--
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.
-->
<div class="tb-custom-action-pretty mat-elevation-z1" tb-fullscreen [fullscreen]="fullscreen">
<div fxLayout="row" fxLayoutAlign="end center" class="tb-action-expand-button" [ngClass]="{'tb-fullscreen-editor': fullscreen}">
<button mat-button fxHide.xs fxHide.sm
matTooltip="{{ 'widget.toggle-fullscreen' | translate }}"
matTooltipPosition="above"
(click)="fullscreen = !fullscreen">
<mat-icon>{{ fullscreen ? 'fullscreen_exit' : 'fullscreen' }}</mat-icon>
<span *ngIf="fullscreen" translate>widget.toggle-fullscreen</span>
</button>
</div>
<div class="tb-custom-action-editor" [ngClass]="{'tb-fullscreen-editor': fullscreen}">
<div *ngIf="!fullscreen; else fullscreenEditor">
<tb-custom-action-pretty-resources-tabs [hasCustomFunction]="true"
[action]="action" (actionUpdated)="onActionUpdated($event ? true : false)">
</tb-custom-action-pretty-resources-tabs>
</div>
<ng-template #fullscreenEditor>
<div class="tb-fullscreen-panel tb-layout-fill" fxLayout="row">
<div #leftPanel class="tb-split tb-content">
<tb-custom-action-pretty-resources-tabs [hasCustomFunction]="false"
[action]="action" (actionUpdated)="onActionUpdated($event ? true : false)">
</tb-custom-action-pretty-resources-tabs>
</div>
<div #rightPanel class="tb-split tb-content right-panel">
<tb-js-func
[(ngModel)]="action.customFunction"
(ngModelChange)="onActionUpdated()"
[fillHeight]="true"
[functionArgs]="['$event', 'widgetContext', 'entityId', 'entityName', 'htmlTemplate', 'additionalParams']"
[validationArgs]="[]">
</tb-js-func>
</div>
</div>
</ng-template>
</div>
</div>

107
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%;
}
}
}

138
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<ElementRef<HTMLElement>>;
@ViewChildren('rightPanel')
rightPanelElmRef: QueryList<ElementRef<HTMLElement>>;
private propagateChange = (_: any) => {};
constructor(protected store: Store<AppState>) {
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);
}
}
}

100
ui-ngx/src/app/modules/home/components/widget/action/custom-action-pretty-resources-tabs.component.html

@ -0,0 +1,100 @@
<!--
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.
-->
<mat-tab-group selectedIndex="{{hasCustomFunction ? 3 : 2}}" dynamicHeight="true" style="width: 100%; height: 100%;">
<mat-tab label="{{ 'widget.resources' | translate }}" style="width: 100%; height: 100%;">
<div class="tb-custom-action-editor-container" style="background-color: #fff;">
<div class="mat-padding">
<div fxFlex fxLayout="row" style="max-height: 40px;"
fxLayoutAlign="start center"
*ngFor="let resource of action.customResources; let i = index" >
<mat-form-field fxFlex class="mat-block resource-field" floatLabel="never"
style="margin: 10px 0px 0px 0px; max-height: 40px;">
<input required matInput [(ngModel)]="resource.url"
(ngModelChange)="notifyActionUpdated()"
placeholder="{{ 'widget.resource-url' | translate }}"/>
</mat-form-field>
<button mat-button mat-icon-button color="primary"
[disabled]="isLoading$ | async"
type="button"
(click)="removeResource(i)"
matTooltip="{{'widget.remove-resource' | translate}}"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
</div>
<div style="margin-top: 6px;">
<button mat-button mat-raised-button color="primary"
[disabled]="isLoading$ | async"
type="button"
(click)="addResource()"
matTooltip="{{'widget.add-resource' | translate}}"
matTooltipPosition="above">
<span translate>action.add</span>
</button>
</div>
</div>
</div>
</mat-tab>
<mat-tab label="{{ 'widget.css' | translate }}" style="width: 100%; height: 100%;">
<div class="tb-custom-action-editor-container" tb-fullscreen [fullscreen]="cssFullscreen">
<div class="tb-editor-area-title-panel">
<button mat-button
type="button"
(click)="beautifyCss()">
{{ 'widget.tidy' | translate }}
</button>
<button mat-button
type="button"
mat-icon-button class="tb-mat-32"
(click)="cssFullscreen = !cssFullscreen"
matTooltip="{{(cssFullscreen ? 'fullscreen.exit' : 'fullscreen.expand') | translate}}"
matTooltipPosition="above">
<mat-icon>{{ cssFullscreen ? 'fullscreen_exit' : 'fullscreen' }}</mat-icon>
</button>
</div>
<div #cssInput class="css-panel"></div>
</div>
</mat-tab>
<mat-tab label="{{ 'widget.html' | translate }}" style="width: 100%; height: 100%;">
<div class="tb-custom-action-editor-container" tb-fullscreen [fullscreen]="htmlFullscreen">
<div class="tb-editor-area-title-panel">
<button mat-button
type="button" (click)="beautifyHtml()">
{{ 'widget.tidy' | translate }}
</button>
<button mat-button
type="button"
mat-icon-button class="tb-mat-32"
(click)="htmlFullscreen = !htmlFullscreen"
matTooltip="{{(htmlFullscreen ? 'fullscreen.exit' : 'fullscreen.expand') | translate}}"
matTooltipPosition="above">
<mat-icon>{{ htmlFullscreen ? 'fullscreen_exit' : 'fullscreen' }}</mat-icon>
</button>
</div>
<div #htmlInput class="html-panel"></div>
</div>
</mat-tab>
<mat-tab *ngIf="hasCustomFunction" label="{{ 'widget.js' | translate }}" style="width: 100%; height: 100%;">
<tb-js-func
[(ngModel)]="action.customFunction"
(ngModelChange)="notifyActionUpdated()"
[functionArgs]="['$event', 'widgetContext', 'entityId', 'entityName', 'htmlTemplate', 'additionalParams']"
[validationArgs]="[]">
</tb-js-func>
</mat-tab>
</mat-tab-group>

79
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;
}
}
}
}

211
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<CustomActionDescriptor> = new EventEmitter<CustomActionDescriptor>();
@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<AppState>,
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<ace.Ace.EditorOptions> = {
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();
});
}
}

118
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;
}
*/

304
ui-ngx/src/app/modules/home/components/widget/action/custom-sample-html.raw

@ -0,0 +1,304 @@
<!--=======================================================================-->
<!--===== There are two example templates: for edit and add entity =====-->
<!--=======================================================================-->
<!--======================== Edit entity example ========================-->
<!--=======================================================================-->
<!-- -->
<!--<md-dialog aria-label="Edit entity">-->
<!-- <form name="editEntityForm" class="edit-entity-form" ng-submit="vm.save()">-->
<!-- <md-toolbar>-->
<!-- <div class="md-toolbar-tools">-->
<!-- <h2>Edit {{vm.entityType.toLowerCase()}} {{vm.entityName}}</h2>-->
<!-- <span flex></span>-->
<!-- <md-button class="md-icon-button" ng-click="vm.cancel()">-->
<!-- <ng-md-icon icon="close" aria-label="Close"></ng-md-icon>-->
<!-- </md-button>-->
<!-- </div>-->
<!-- </md-toolbar>-->
<!-- <md-dialog-content>-->
<!-- <div class="md-dialog-content">-->
<!-- <div layout="row">-->
<!-- <md-input-container flex class="md-block">-->
<!-- <label>Entity Name</label>-->
<!-- <input ng-model="vm.entityName" readonly>-->
<!-- </md-input-container>-->
<!-- <md-input-container flex class="md-block">-->
<!-- <label>Entity Type</label>-->
<!-- <input ng-model="vm.entityType" readonly>-->
<!-- </md-input-container>-->
<!-- <md-input-container flex class="md-block">-->
<!-- <label>Type</label>-->
<!-- <input ng-model="vm.type" readonly>-->
<!-- </md-input-container>-->
<!-- </div>-->
<!-- <div layout="row">-->
<!-- <md-input-container flex class="md-block">-->
<!-- <label>Latitude</label>-->
<!-- <input name="latitude" type="number" step="any" ng-model="vm.attributes.latitude">-->
<!-- </md-input-container>-->
<!-- <md-input-container flex class="md-block">-->
<!-- <label>Longitude</label>-->
<!-- <input name="longitude" type="number" step="any" ng-model="vm.attributes.longitude">-->
<!-- </md-input-container>-->
<!-- </div>-->
<!-- <div layout="row">-->
<!-- <md-input-container flex class="md-block">-->
<!-- <label>Address</label>-->
<!-- <input ng-model="vm.attributes.address">-->
<!-- </md-input-container>-->
<!-- <md-input-container flex class="md-block">-->
<!-- <label>Owner</label>-->
<!-- <input ng-model="vm.attributes.owner">-->
<!-- </md-input-container>-->
<!-- </div>-->
<!-- <div layout="row">-->
<!-- <md-input-container flex class="md-block">-->
<!-- <label>Integer Value</label>-->
<!-- <input name="integerNumber" type="number" step="1" ng-pattern="/^-?[0-9]+$/" ng-model="vm.attributes.number">-->
<!-- <div ng-messages="editEntityForm.integerNumber.$error">-->
<!-- <div ng-message="pattern">Invalid integer value.</div>-->
<!-- </div>-->
<!-- </md-input-container>-->
<!-- <div class="boolean-value-input" layout="column" layout-align="center start" flex>-->
<!-- <label class="checkbox-label">Boolean Value</label>-->
<!-- <md-checkbox ng-model="vm.attributes.booleanValue" style="margin-bottom: 40px;">-->
<!-- {{ (vm.attributes.booleanValue ? "value.true" : "value.false") | translate }}-->
<!-- </md-checkbox>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div class="relations-list old-relations">-->
<!-- <div class="md-body-1" style="padding-bottom: 10px; color: rgba(0,0,0,0.57);">Relations</div>-->
<!-- <div class="body" ng-show="vm.relations.length">-->
<!-- <div class="row" layout="row" layout-align="start center" ng-repeat="relation in vm.relations track by $index">-->
<!-- <div class="md-whiteframe-1dp" flex layout="row" style="padding-left: 5px; margin-bottom: 3px;">-->
<!-- <div flex layout="column">-->
<!-- <div layout="row">-->
<!-- <md-input-container class="md-block" style="min-width: 100px;">-->
<!-- <label>Direction</label>-->
<!-- <md-select ng-disabled="true" required ng-model="relation.direction">-->
<!-- <md-option ng-repeat="direction in vm.entitySearchDirection" ng-value="direction">-->
<!-- {{ ("relation.search-direction." + direction) | translate}}-->
<!-- </md-option>-->
<!-- </md-select>-->
<!-- </md-input-container>-->
<!-- <tb-relation-type-autocomplete ng-disabled="true" flex class="md-block"-->
<!-- the-form="editEntityForm"-->
<!-- ng-model="relation.relationType"-->
<!-- tb-required="true">-->
<!-- </tb-relation-type-autocomplete>-->
<!-- </div>-->
<!-- <div layout="row">-->
<!-- <tb-entity-select flex class="md-block"-->
<!-- the-form="editEntityForm"-->
<!-- ng-disabled="true"-->
<!-- tb-required="true"-->
<!-- ng-model="relation.relatedEntity">-->
<!-- </tb-entity-select>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div layout="column" layout-align="center center">-->
<!-- <md-button class="md-icon-button md-primary" style="width: 40px; min-width: 40px;"-->
<!-- ng-click="vm.removeOldRelation($index,relation)" aria-label="Remove">-->
<!-- <md-tooltip md-direction="top">Remove relation</md-tooltip>-->
<!-- <md-icon aria-label="Remove" class="material-icons">-->
<!-- close-->
<!-- </md-icon>-->
<!-- </md-button>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div class="relations-list">-->
<!-- <div class="md-body-1" style="padding-bottom: 10px; color: rgba(0,0,0,0.57);">New Relations</div>-->
<!-- <div class="body" ng-show="vm.newRelations.length">-->
<!-- <div class="row" layout="row" layout-align="start center" ng-repeat="relation in vm.newRelations track by $index">-->
<!-- <div class="md-whiteframe-1dp" flex layout="row" style="padding-left: 5px; margin-bottom: 3px;">-->
<!-- <div flex layout="column">-->
<!-- <div layout="row">-->
<!-- <md-input-container class="md-block" style="min-width: 100px;">-->
<!-- <label>Direction</label>-->
<!-- <md-select name="direction" required ng-model="relation.direction">-->
<!-- <md-option ng-repeat="direction in vm.entitySearchDirection" ng-value="direction">-->
<!-- {{ ("relation.search-direction." + direction) | translate}}-->
<!-- </md-option>-->
<!-- </md-select>-->
<!-- <div ng-messages="editEntityForm.direction.$error">-->
<!-- <div ng-message="required">Relation direction is required.</div>-->
<!-- </div>-->
<!-- </md-input-container>-->
<!-- <tb-relation-type-autocomplete flex class="md-block"-->
<!-- the-form="editEntityForm"-->
<!-- ng-model="relation.relationType"-->
<!-- tb-required="true">-->
<!-- </tb-relation-type-autocomplete>-->
<!-- </div>-->
<!-- <div layout="row">-->
<!-- <tb-entity-select flex class="md-block"-->
<!-- the-form="editEntityForm"-->
<!-- tb-required="true"-->
<!-- ng-model="relation.relatedEntity">-->
<!-- </tb-entity-select>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div layout="column" layout-align="center center">-->
<!-- <md-button class="md-icon-button md-primary" style="width: 40px; min-width: 40px;"-->
<!-- ng-click="vm.removeRelation($index)" aria-label="Remove">-->
<!-- <md-tooltip md-direction="top">Remove relation</md-tooltip>-->
<!-- <md-icon aria-label="Remove" class="material-icons">-->
<!-- close-->
<!-- </md-icon>-->
<!-- </md-button>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div>-->
<!-- <md-button class="md-primary md-raised" ng-click="vm.addRelation()" aria-label="Add">-->
<!-- <md-tooltip md-direction="top">Add Relation</md-tooltip>-->
<!-- Add-->
<!-- </md-button>-->
<!-- </div> -->
<!-- </div>-->
<!-- </div>-->
<!-- </md-dialog-content>-->
<!-- <md-dialog-actions>-->
<!-- <md-button type="submit" ng-disabled="editEntityForm.$invalid || !editEntityForm.$dirty" class="md-raised md-primary">Save</md-button>-->
<!-- <md-button ng-click="vm.cancel()" class="md-primary">Cancel</md-button>-->
<!-- </md-dialog-actions>-->
<!-- </form>-->
<!--</md-dialog>-->
<!---->
<!--========================================================================-->
<!--========================= Add entity example =========================-->
<!--========================================================================-->
<!---->
<!--<md-dialog aria-label="Add entity">-->
<!-- <form name="addEntityForm" class="add-entity-form" ng-submit="vm.save()">-->
<!-- <md-toolbar>-->
<!-- <div class="md-toolbar-tools">-->
<!-- <h2>Add entity</h2>-->
<!-- <span flex></span>-->
<!-- <md-button class="md-icon-button" ng-click="vm.cancel()">-->
<!-- <ng-md-icon icon="close" aria-label="Close"></ng-md-icon>-->
<!-- </md-button>-->
<!-- </div>-->
<!-- </md-toolbar>-->
<!-- <md-dialog-content>-->
<!-- <div class="md-dialog-content">-->
<!-- <div layout="row">-->
<!-- <md-input-container flex class="md-block">-->
<!-- <label>Entity Name</label>-->
<!-- <input ng-model="vm.entityName" name=entityName required>-->
<!-- <div ng-messages="addEntityForm.entityName.$error">-->
<!-- <div ng-message="required">Entity name is required.</div>-->
<!-- </div>-->
<!-- </md-input-container>-->
<!-- <tb-entity-type-select class="md-block" style="min-width: 100px; width: 100px;"-->
<!-- the-form="addEntityForm"-->
<!-- tb-required="true"-->
<!-- allowed-entity-types="vm.allowedEntityTypes"-->
<!-- ng-model="vm.entityType">-->
<!-- </tb-entity-type-select>-->
<!-- <md-input-container flex class="md-block">-->
<!-- <label>Entity Subtype</label>-->
<!-- <input ng-model="vm.type" name=type required>-->
<!-- <div ng-messages="addEntityForm.type.$error">-->
<!-- <div ng-message="required">Entity subtype is required.</div>-->
<!-- </div>-->
<!-- </md-input-container>-->
<!-- </div>-->
<!-- <div layout="row">-->
<!-- <md-input-container flex class="md-block">-->
<!-- <label>Latitude</label>-->
<!-- <input name="latitude" type="number" step="any" ng-model="vm.attributes.latitude">-->
<!-- </md-input-container>-->
<!-- <md-input-container flex class="md-block">-->
<!-- <label>Longitude</label>-->
<!-- <input name="longitude" type="number" step="any" ng-model="vm.attributes.longitude">-->
<!-- </md-input-container>-->
<!-- </div>-->
<!-- <div layout="row">-->
<!-- <md-input-container flex class="md-block">-->
<!-- <label>Address</label>-->
<!-- <input ng-model="vm.attributes.address">-->
<!-- </md-input-container>-->
<!-- <md-input-container flex class="md-block">-->
<!-- <label>Owner</label>-->
<!-- <input ng-model="vm.attributes.owner">-->
<!-- </md-input-container>-->
<!-- </div>-->
<!-- <div layout="row">-->
<!-- <md-input-container flex class="md-block">-->
<!-- <label>Integer Value</label>-->
<!-- <input name="integerNumber" type="number" step="1" ng-pattern="/^-?[0-9]+$/" ng-model="vm.attributes.number">-->
<!-- <div ng-messages="addEntityForm.integerNumber.$error">-->
<!-- <div ng-message="pattern">Invalid integer value.</div>-->
<!-- </div>-->
<!-- </md-input-container>-->
<!-- <div class="boolean-value-input" layout="column" layout-align="center start" flex>-->
<!-- <label class="checkbox-label">Boolean Value</label>-->
<!-- <md-checkbox ng-model="vm.attributes.booleanValue" style="margin-bottom: 40px;">-->
<!-- {{ (vm.attributes.booleanValue ? "value.true" : "value.false") | translate }}-->
<!-- </md-checkbox>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div class="relations-list">-->
<!-- <div class="md-body-1" style="padding-bottom: 10px; color: rgba(0,0,0,0.57);">Relations</div>-->
<!-- <div class="body" ng-show="vm.relations.length">-->
<!-- <div class="row" layout="row" layout-align="start center" ng-repeat="relation in vm.relations track by $index">-->
<!-- <div class="md-whiteframe-1dp" flex layout="row" style="padding-left: 5px;">-->
<!-- <div flex layout="column">-->
<!-- <div layout="row">-->
<!-- <md-input-container class="md-block" style="min-width: 100px;">-->
<!-- <label>Direction</label>-->
<!-- <md-select name="direction" required ng-model="relation.direction">-->
<!-- <md-option ng-repeat="direction in vm.entitySearchDirection" ng-value="direction">-->
<!-- {{ ("relation.search-direction." + direction) | translate}}-->
<!-- </md-option>-->
<!-- </md-select>-->
<!-- <div ng-messages="addEntityForm.direction.$error">-->
<!-- <div ng-message="required">Relation direction is required.</div>-->
<!-- </div>-->
<!-- </md-input-container>-->
<!-- <tb-relation-type-autocomplete flex class="md-block"-->
<!-- the-form="addEntityForm"-->
<!-- ng-model="relation.relationType"-->
<!-- tb-required="true">-->
<!-- </tb-relation-type-autocomplete>-->
<!-- </div>-->
<!-- <div layout="row">-->
<!-- <tb-entity-select flex class="md-block"-->
<!-- the-form="addEntityForm"-->
<!-- tb-required="true"-->
<!-- ng-model="relation.relatedEntity">-->
<!-- </tb-entity-select>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div layout="column" layout-align="center center">-->
<!-- <md-button class="md-icon-button md-primary" style="width: 40px; min-width: 40px;"-->
<!-- ng-click="vm.removeRelation($index)" aria-label="Remove">-->
<!-- <md-tooltip md-direction="top">Remove relation</md-tooltip>-->
<!-- <md-icon aria-label="Remove" class="material-icons">-->
<!-- close-->
<!-- </md-icon>-->
<!-- </md-button>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div>-->
<!-- <md-button class="md-primary md-raised" ng-click="vm.addRelation()" aria-label="Add">-->
<!-- <md-tooltip md-direction="top">Add Relation</md-tooltip>-->
<!-- Add-->
<!-- </md-button>-->
<!-- </div> -->
<!-- </div>-->
<!-- </div>-->
<!-- </md-dialog-content>-->
<!-- <md-dialog-actions>-->
<!-- <md-button type="submit" ng-disabled="addEntityForm.$invalid || !addEntityForm.$dirty" class="md-raised md-primary">Create</md-button>-->
<!-- <md-button ng-click="vm.cancel()" class="md-primary">Cancel</md-button>-->
<!-- </md-dialog-actions>-->
<!-- </form>-->
<!--</md-dialog>-->

397
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);
// });
// }
//}

118
ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.html

@ -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.
-->
<div class="mat-padding tb-entity-table tb-absolute-fill">
<div fxFlex fxLayout="column" class="mat-elevation-z1 tb-entity-table-content">
<mat-toolbar class="mat-table-toolbar" [fxShow]="!textSearchMode">
<div class="mat-toolbar-tools">
<span class="tb-entity-table-title" translate>widget-config.actions</span>
<span fxFlex></span>
<button mat-button mat-icon-button [disabled]="isLoading$ | async"
(click)="addAction($event)"
matTooltip="{{ 'widget-config.add-action' | translate }}"
matTooltipPosition="above">
<mat-icon>add</mat-icon>
</button>
<button mat-button mat-icon-button [disabled]="isLoading$ | async" (click)="enterFilterMode()"
matTooltip="{{ 'action.search' | translate }}"
matTooltipPosition="above">
<mat-icon>search</mat-icon>
</button>
</div>
</mat-toolbar>
<mat-toolbar class="mat-table-toolbar" [fxShow]="textSearchMode">
<div class="mat-toolbar-tools">
<button mat-button mat-icon-button
matTooltip="{{ 'widget-config.search-actions' | translate }}"
matTooltipPosition="above">
<mat-icon>search</mat-icon>
</button>
<mat-form-field fxFlex>
<mat-label>&nbsp;</mat-label>
<input #searchInput matInput
[(ngModel)]="pageLink.textSearch"
placeholder="{{ 'widget-config.search-actions' | translate }}"/>
</mat-form-field>
<button mat-button mat-icon-button (click)="exitFilterMode()"
matTooltip="{{ 'action.close' | translate }}"
matTooltipPosition="above">
<mat-icon>close</mat-icon>
</button>
</div>
</mat-toolbar>
<div fxFlex class="table-container">
<mat-table [dataSource]="dataSource"
matSort [matSortActive]="pageLink.sortOrder.property" [matSortDirection]="(pageLink.sortOrder.direction + '').toLowerCase()" matSortDisableClear>
<ng-container matColumnDef="actionSourceName">
<mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'widget-config.action-source' | translate }} </mat-header-cell>
<mat-cell *matCellDef="let action">
{{ action.actionSourceName }}
</mat-cell>
</ng-container>
<ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'widget-config.action-name' | translate }} </mat-header-cell>
<mat-cell *matCellDef="let action">
{{ action.name }}
</mat-cell>
</ng-container>
<ng-container matColumnDef="icon">
<mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'widget-config.action-icon' | translate }} </mat-header-cell>
<mat-cell *matCellDef="let action">
<mat-icon>{{ action.icon }}</mat-icon>
</mat-cell>
</ng-container>
<ng-container matColumnDef="typeName">
<mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'widget-config.action-type' | translate }} </mat-header-cell>
<mat-cell *matCellDef="let action">
{{ action.typeName }}
</mat-cell>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<mat-header-cell *matHeaderCellDef [ngStyle]="{ minWidth: '80px', maxWidth: '80px' }">
</mat-header-cell>
<mat-cell *matCellDef="let action" [ngStyle]="{ minWidth: '80px', maxWidth: '80px' }">
<div fxFlex fxLayout="row" fxLayoutAlign="end">
<button mat-button mat-icon-button [disabled]="isLoading$ | async"
matTooltip="{{ 'widget-config.edit-action' | translate }}"
matTooltipPosition="above"
(click)="editAction($event, action)">
<mat-icon>edit</mat-icon>
</button>
<button mat-button mat-icon-button [disabled]="isLoading$ | async"
matTooltip="{{ 'widget-config.delete-action' | translate }}"
matTooltipPosition="above"
(click)="deleteAction($event, action)">
<mat-icon>delete</mat-icon>
</button>
</div>
</mat-cell>
</ng-container>
<mat-header-row [ngClass]="{'mat-row-select': true}" *matHeaderRowDef="displayedColumns; sticky: true"></mat-header-row>
<mat-row [ngClass]="{'mat-row-select': true}"
*matRowDef="let action; columns: displayedColumns;"></mat-row>
</mat-table>
<span [fxShow]="dataSource.isEmpty() | async"
fxLayoutAlign="center center"
class="no-data-found" translate>{{ 'widget-config.no-actions-text' }}</span>
</div>
<mat-divider></mat-divider>
<mat-paginator [length]="dataSource.total() | async"
[pageIndex]="pageLink.page"
[pageSize]="pageLink.pageSize"
[pageSizeOptions]="[10, 20, 30]"></mat-paginator>
</div>
</div>

167
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<string>;
}
export interface WidgetActionsData {
actionsMap: {[actionSourceId: string]: Array<WidgetActionDescriptor>};
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<WidgetActionDescriptorInfo> {
private actionsSubject = new BehaviorSubject<WidgetActionDescriptorInfo[]>([]);
private pageDataSubject = new BehaviorSubject<PageData<WidgetActionDescriptorInfo>>(emptyPageData<WidgetActionDescriptorInfo>());
public pageData$ = this.pageDataSubject.asObservable();
private allActions: Observable<Array<WidgetActionDescriptorInfo>>;
private actionsMap: {[actionSourceId: string]: Array<WidgetActionDescriptor>};
private actionSources: {[actionSourceId: string]: WidgetActionSource};
constructor(private translate: TranslateService,
private utils: UtilsService) {}
connect(collectionViewer: CollectionViewer): Observable<WidgetActionDescriptorInfo[] | ReadonlyArray<WidgetActionDescriptorInfo>> {
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<PageData<WidgetActionDescriptorInfo>> {
if (reload) {
this.allActions = null;
}
const result = new ReplaySubject<PageData<WidgetActionDescriptorInfo>>();
this.fetchActions(pageLink).pipe(
catchError(() => of(emptyPageData<WidgetActionDescriptorInfo>())),
).subscribe(
(pageData) => {
this.actionsSubject.next(pageData.data);
this.pageDataSubject.next(pageData);
result.next(pageData);
}
);
return result;
}
fetchActions(pageLink: PageLink): Observable<PageData<WidgetActionDescriptorInfo>> {
return this.getAllActions().pipe(
map((data) => pageLink.filterData(data))
);
}
getAllActions(): Observable<Array<WidgetActionDescriptorInfo>> {
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<boolean> {
return this.actionsSubject.pipe(
map((actions) => !actions.length)
);
}
total(): Observable<number> {
return this.pageDataSubject.pipe(
map((pageData) => pageData.totalElements)
);
}
}

43
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;
}
}

316
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<AppState>,
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, WidgetActionDialogData,
WidgetActionDescriptorInfo>(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<WidgetActionDescriptor> {
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);
}
}

163
ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html

@ -0,0 +1,163 @@
<!--
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.
-->
<form #widgetActionForm="ngForm" [formGroup]="widgetActionFormGroup" (ngSubmit)="save()" style="min-width: 600px;">
<mat-toolbar fxLayout="row" color="primary">
<h2>{{ (isAdd ? 'widget-config.add-action' : 'widget-config.edit-action' ) | translate }}</h2>
<span fxFlex></span>
<button mat-button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<div mat-dialog-content>
<fieldset [disabled]="isLoading$ | async">
<mat-form-field class="mat-block">
<mat-label translate>widget-config.action-source</mat-label>
<mat-select required matInput formControlName="actionSourceId">
<mat-option *ngFor="let actionSourceItem of data.actionsData.actionSources | keyvalue" [value]="actionSourceItem.key">
{{ actionSourceName(actionSourceItem.value) }}
</mat-option>
</mat-select>
<mat-error *ngIf="widgetActionFormGroup.get('actionSourceId').hasError('required')">
{{ 'widget-config.action-source-required' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block" style="padding-bottom: 20px;">
<mat-label translate>widget-config.action-name</mat-label>
<input required matInput formControlName="name">
<mat-error *ngIf="widgetActionFormGroup.get('name').hasError('required')">
{{ 'widget-config.action-name-required' | translate }}
</mat-error>
<mat-error *ngIf="widgetActionFormGroup.get('name').hasError('actionNameNotUnique')"
[innerHTML]="'widget-config.action-name-not-unique' | translate">
</mat-error>
</mat-form-field>
<tb-material-icon-select
formControlName="icon">
</tb-material-icon-select>
<mat-form-field class="mat-block">
<mat-label translate>widget-config.action-type</mat-label>
<mat-select required matInput formControlName="type">
<mat-option *ngFor="let actionType of widgetActionTypes" [value]="actionType">
{{ widgetActionTypeTranslations.get(actionType) | translate }}
</mat-option>
</mat-select>
<mat-error *ngIf="widgetActionFormGroup.get('type').hasError('required')">
{{ 'widget-config.action-type-required' | translate }}
</mat-error>
</mat-form-field>
<section fxLayout="column" [formGroup]="actionTypeFormGroup" [ngSwitch]="widgetActionFormGroup.get('type').value">
<ng-template [ngSwitchCase]="widgetActionType.openDashboard">
<div fxLayout="column" style="padding-bottom: 20px;">
<div class="mat-caption tb-required"
style="padding-left: 3px; padding-bottom: 10px; color: rgba(0,0,0,0.57);" translate>widget-action.target-dashboard</div>
<tb-dashboard-autocomplete
formControlName="targetDashboardId"
required
[selectFirstDashboard]="true"
></tb-dashboard-autocomplete>
</div>
</ng-template>
<ng-template [ngSwitchCase]="widgetActionFormGroup.get('type').value === widgetActionType.openDashboardState ||
widgetActionFormGroup.get('type').value === widgetActionType.updateDashboardState ||
widgetActionFormGroup.get('type').value === widgetActionType.openDashboard ?
widgetActionFormGroup.get('type').value : ''">
<mat-form-field class="mat-block">
<input matInput type="text" placeholder="{{ 'widget-action.target-dashboard-state' | translate }}"
#dashboardStateInput
formControlName="targetDashboardStateId"
[required]="widgetActionFormGroup.get('type').value === widgetActionType.openDashboardState"
[matAutocomplete]="targetDashboardStateAutocomplete">
<button *ngIf="actionTypeFormGroup.get('targetDashboardStateId').value"
type="button"
matSuffix mat-button mat-icon-button aria-label="Clear"
(click)="clearTargetDashboardState()">
<mat-icon class="material-icons">close</mat-icon>
</button>
<mat-autocomplete
class="tb-autocomplete"
#targetDashboardStateAutocomplete="matAutocomplete">
<mat-option *ngFor="let state of filteredDashboardStates | async" [value]="state">
<span [innerHTML]="state | highlight:targetDashboardStateSearchText"></span>
</mat-option>
</mat-autocomplete>
<mat-error *ngIf="actionTypeFormGroup.get('targetDashboardStateId').hasError('required')">
{{ 'widget-action.target-dashboard-state-required' | translate }}
</mat-error>
</mat-form-field>
</ng-template>
<ng-template [ngSwitchCase]="widgetActionFormGroup.get('type').value === widgetActionType.openDashboardState ||
widgetActionFormGroup.get('type').value === widgetActionType.updateDashboardState ?
widgetActionFormGroup.get('type').value : ''">
<mat-checkbox formControlName="openRightLayout">
{{ 'widget-action.open-right-layout' | translate }}
</mat-checkbox>
</ng-template>
<ng-template [ngSwitchCase]="widgetActionFormGroup.get('type').value === widgetActionType.openDashboardState ||
widgetActionFormGroup.get('type').value === widgetActionType.updateDashboardState ||
widgetActionFormGroup.get('type').value === widgetActionType.openDashboard ?
widgetActionFormGroup.get('type').value : ''">
<div fxFlex fxLayout="column">
<mat-checkbox formControlName="setEntityId">
{{ 'widget-action.set-entity-from-widget' | translate }}
</mat-checkbox>
<mat-form-field *ngIf="actionTypeFormGroup.get('setEntityId').value"
floatLabel="always"
class="mat-block">
<mat-label translate>alias.state-entity-parameter-name</mat-label>
<input matInput
placeholder="{{ 'alias.default-entity-parameter-name' | translate }}"
formControlName="stateEntityParamName">
</mat-form-field>
</div>
</ng-template>
<ng-template [ngSwitchCase]="widgetActionType.custom">
<tb-js-func
formControlName="customFunction"
[functionArgs]="['$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams']"
[validationArgs]="[]"
></tb-js-func>
</ng-template>
<ng-template [ngSwitchCase]="widgetActionType.customPretty">
<tb-custom-action-pretty-editor
formControlName="customAction">
</tb-custom-action-pretty-editor>
</ng-template>
</section>
</fieldset>
</div>
<div mat-dialog-actions fxLayout="row">
<span fxFlex></span>
<button mat-button mat-raised-button color="primary"
type="submit"
[disabled]="(isLoading$ | async) || widgetActionFormGroup.invalid || actionTypeFormGroup.invalid || (!widgetActionFormGroup.dirty && !actionTypeFormGroup.dirty)">
{{ (isAdd ? 'action.add' : 'action.save') | translate }}
</button>
<button mat-button color="primary"
style="margin-right: 20px;"
type="button"
[disabled]="(isLoading$ | async)"
(click)="cancel()" cdkFocusInitial>
{{ 'action.cancel' | translate }}
</button>
</div>
</form>

295
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<WidgetActionDialogComponent,
WidgetActionDescriptorInfo> 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<Array<string>>;
targetDashboardStateSearchText = '';
selectedDashboardStateIds: Observable<Array<string>>;
submitted = false;
constructor(protected store: Store<AppState>,
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<WidgetActionDialogComponent, WidgetActionDescriptorInfo>,
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<string>(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<Array<string>> {
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);
}
}

4
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<Array<DataKey>> = 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) {

57
ui-ngx/src/app/modules/home/components/widget/legend-config-panel.component.html

@ -0,0 +1,57 @@
<!--
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.
-->
<form [formGroup]="legendConfigForm">
<fieldset [disabled]="(isLoading$ | async)">
<div class="mat-content" style="height: 100%;">
<div class="mat-padding">
<section fxLayout="column">
<mat-form-field>
<mat-label translate>legend.direction</mat-label>
<mat-select matInput formControlName="direction" style="min-width: 150px;">
<mat-option *ngFor="let direction of legendDirections" [value]="direction">
{{ legendDirectionTranslations.get(direction) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label translate>legend.position</mat-label>
<mat-select matInput formControlName="position" style="min-width: 150px;">
<mat-option *ngFor="let pos of legendPositions" [value]="pos"
[disabled]="legendConfigForm.get('direction').value === legendDirection.row &&
(pos === legendPosition.left || pos === legendPosition.right)">
{{ legendPositionTranslations.get(pos) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-checkbox fxFlex formControlName="showMin">
{{ 'legend.show-min' | translate }}
</mat-checkbox>
<mat-checkbox fxFlex formControlName="showMax">
{{ 'legend.show-max' | translate }}
</mat-checkbox>
<mat-checkbox fxFlex formControlName="showAvg">
{{ 'legend.show-avg' | translate }}
</mat-checkbox>
<mat-checkbox fxFlex formControlName="showTotal">
{{ 'legend.show-total' | translate }}
</mat-checkbox>
</section>
</div>
</div>
</fieldset>
</form>

32
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;
}
}

118
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<any>('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<AppState>,
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);
}
}

22
ui-ngx/src/app/modules/home/components/widget/legend-config.component.html

@ -0,0 +1,22 @@
<!--
Copyright © 2016-2019 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<button cdkOverlayOrigin #legendConfigPanelOrigin="cdkOverlayOrigin" [disabled]="disabled"
mat-button mat-raised-button color="primary" (click)="openEditMode($event)">
<mat-icon class="material-icons">toc</mat-icon>
<span translate>legend.settings</span>
</button>

198
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<any, any>([
[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);
}
}

188
ui-ngx/src/app/modules/home/components/widget/widget-config.component.html

@ -181,6 +181,188 @@
</tb-entity-alias-select>
</div>
</mat-expansion-panel>
<mat-expansion-panel class="tb-datasources" *ngIf="widgetType === widgetTypes.alarm &&
modelValue?.isDataEnabled" [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
{{ 'widget-config.alarm-source' | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<div [formGroup]="alarmSourceSettings" style="padding: 0 5px;">
<section fxFlex
fxLayout="column"
fxLayoutAlign="center"
fxLayout.gt-sm="row"
fxLayoutAlign.gt-sm="start center">
<mat-form-field class="tb-datasource-type">
<mat-select matInput formControlName="type">
<mat-option *ngFor="let datasourceType of datasourceTypes" [value]="datasourceType">
{{ datasourceTypesTranslations.get(datasourceType) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<section class="tb-datasource" [ngSwitch]="alarmSourceSettings.get('type').value">
<ng-template [ngSwitchCase]="datasourceType.function">
<mat-form-field floatLabel="always" [fxShow]="widgetType !== widgetTypes.alarm"
class="tb-datasource-name" style="min-width: 200px;">
<mat-label></mat-label>
<input matInput
placeholder="{{ 'datasource.name' | translate }}"
formControlName="name">
</mat-form-field>
</ng-template>
<ng-template [ngSwitchCase]="datasourceType.entity">
<tb-entity-alias-select
[tbRequired]="alarmSourceSettings.get('type').value === datasourceType.entity"
[aliasController]="aliasController"
formControlName="entityAliasId"
[callbacks]="widgetConfigCallbacks">
</tb-entity-alias-select>
</ng-template>
</section>
<tb-data-keys class="tb-data-keys" fxFlex
[widgetType]="widgetType"
[datasourceType]="alarmSourceSettings.get('type').value"
[aliasController]="aliasController"
[datakeySettingsSchema]="modelValue?.dataKeySettingsSchema"
[callbacks]="widgetConfigCallbacks"
[entityAliasId]="alarmSourceSettings.get('entityAliasId').value"
formControlName="dataKeys">
</tb-data-keys>
</section>
</div>
</mat-expansion-panel>
</div>
</mat-tab>
<mat-tab label="{{ 'widget-config.settings' | translate }}">
<div class="mat-content mat-padding"
fxLayout="column" fxLayoutGap="8px">
<div [formGroup]="widgetSettings" fxLayout="column" fxLayoutGap="8px">
<span translate>widget-config.general-settings</span>
<div fxLayout="column" fxLayoutAlign="center" fxLayout.gt-sm="row" fxLayoutAlign.gt-sm="start center">
<mat-form-field fxFlex class="mat-block">
<mat-label translate>widget-config.title</mat-label>
<input matInput formControlName="title">
</mat-form-field>
<div fxFlex [fxShow]="widgetSettings.get('showTitle').value">
<tb-json-object-edit
[editorStyle]="{minHeight: '100px'}"
required
label="{{ 'widget-config.title-style' | translate }}"
formControlName="titleStyle"
></tb-json-object-edit>
</div>
</div>
<div fxLayout="column" fxLayoutAlign="center" fxLayout.gt-sm="row" fxLayoutAlign.gt-sm="start center"
fxLayoutGap="8px">
<div fxLayout="row" fxFlex>
<mat-checkbox formControlName="showTitleIcon">
{{ 'widget-config.display-icon' | translate }}
</mat-checkbox>
</div>
<div fxFlex>
<tb-material-icon-select
formControlName="titleIcon">
</tb-material-icon-select>
</div>
<tb-color-input fxFlex
label="{{'widget-config.icon-color' | translate}}"
icon="format_color_fill"
openOnInput
formControlName="iconColor">
</tb-color-input>
<mat-form-field fxFlex>
<mat-label translate>widget-config.icon-size</mat-label>
<input matInput formControlName="iconSize">
</mat-form-field>
</div>
<div fxLayout="column" fxLayoutAlign="center" fxLayout.gt-sm="row" fxLayoutAlign.gt-sm="start center"
fxLayoutGap="8px">
<div fxLayout="row">
<mat-checkbox formControlName="showTitle">
{{ 'widget-config.display-title' | translate }}
</mat-checkbox>
</div>
<div fxLayout="row">
<mat-checkbox formControlName="dropShadow">
{{ 'widget-config.drop-shadow' | translate }}
</mat-checkbox>
</div>
<div fxLayout="row">
<mat-checkbox formControlName="enableFullscreen">
{{ 'widget-config.enable-fullscreen' | translate }}
</mat-checkbox>
</div>
<div fxFlex>
<tb-json-object-edit
[editorStyle]="{minHeight: '100px'}"
required
label="{{ 'widget-config.widget-style' | translate }}"
formControlName="widgetStyle"
></tb-json-object-edit>
</div>
</div>
<div fxLayout="column" fxLayoutAlign="center" fxLayout.gt-sm="row" fxLayoutAlign.gt-sm="start center"
fxLayoutGap="8px">
<tb-color-input fxFlex
label="{{'widget-config.background-color' | translate}}"
icon="format_color_fill"
openOnInput
formControlName="backgroundColor">
</tb-color-input>
<tb-color-input fxFlex
label="{{'widget-config.text-color' | translate}}"
icon="format_color_fill"
openOnInput
formControlName="color">
</tb-color-input>
<mat-form-field fxFlex>
<mat-label translate>widget-config.padding</mat-label>
<input matInput formControlName="padding">
</mat-form-field>
<mat-form-field fxFlex>
<mat-label translate>widget-config.margin</mat-label>
<input matInput formControlName="margin">
</mat-form-field>
</div>
<div fxLayout="column" fxLayoutAlign="center" fxLayout.gt-sm="row" fxLayoutAlign.gt-sm="start center"
fxLayoutGap="8px">
<mat-form-field fxFlex>
<mat-label translate>widget-config.units</mat-label>
<input matInput formControlName="units">
</mat-form-field>
<mat-form-field fxFlex>
<mat-label translate>widget-config.decimals</mat-label>
<input matInput formControlName="decimals" type="number" min="0" max="15" step="1">
</mat-form-field>
</div>
<div [fxShow]="widgetType === widgetTypes.timeseries ||
widgetType === widgetTypes.latest"
fxLayout="column" fxLayoutAlign="center" fxLayout.gt-sm="row" fxLayoutAlign.gt-sm="start center"
fxLayoutGap="8px">
<mat-checkbox fxFlex formControlName="showLegend">
{{ 'widget-config.display-legend' | translate }}
</mat-checkbox>
<section fxFlex fxLayout="row" fxLayoutAlign="start center" style="margin-bottom: 16px;">
<tb-legend-config formControlName="legendConfig">
</tb-legend-config>
</section>
</div>
</div>
<div [formGroup]="layoutSettings" fxLayout="column" fxLayoutGap="8px">
<span translate>widget-config.mobile-mode-settings</span>
<div fxLayout="column" fxLayoutAlign="center" fxLayout.gt-sm="row" fxLayoutAlign.gt-sm="start center"
fxLayoutGap="8px">
<mat-form-field fxFlex>
<mat-label translate>widget-config.order</mat-label>
<input matInput formControlName="mobileOrder" type="number" step="1">
</mat-form-field>
<mat-form-field fxFlex>
<mat-label translate>widget-config.height</mat-label>
<input matInput formControlName="mobileHeight" type="number" min="1" max="10" step="1">
</mat-form-field>
</div>
</div>
</div>
</mat-tab>
<mat-tab *ngIf="displayAdvanced()" label="{{ 'widget-config.advanced' | translate }}">
@ -191,6 +373,10 @@
</tb-json-form>
</div>
</mat-tab>
<mat-tab label="{{ 'widget-config.actions' | translate }}">
<mat-tab label="{{ 'widget-config.actions' | translate }}" [formGroup]="actionsSettings">
<tb-manage-widget-actions
[callbacks]="widgetConfigCallbacks"
formControlName="actionsData">
</tb-manage-widget-actions>
</mat-tab>
</mat-tab-group>

3
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;

10
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%;

311
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<string>;
@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<WidgetActionDescriptor>};
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<AppState>,
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<string> {
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;
}

43
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;

4
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<DashboardWidget> {
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<DashboardWidget> {
});
this.sortWidgets();
this.dashboard.gridsterOpts.maxRows = maxRows;
this.dashboard.notifyGridsterOptionsChanged();
}
sortWidgets() {

16
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<WidgetHeaderAction>;
widgetActions?: Array<WidgetAction>;
datasources?: Array<Datasource>;
data?: Array<DatasourceData>;
hiddenData?: Array<{data: DataSet}>;
timeWindow?: WidgetTimewindow;
hideTitlePanel?: boolean;
widgetTitleTemplate?: string;
widgetTitle?: string;
customHeaderActions?: Array<WidgetHeaderAction>;
widgetActions?: Array<WidgetAction>;
}
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;

32
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() {

2
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 {

1
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">
</tb-widget-config>
</fieldset>

2
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';

4
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();
}

2
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 {

2
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 {

3
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();
}
}

78
ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.html

@ -0,0 +1,78 @@
<!--
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.
-->
<form class="tb-material-icons-dialog" style="min-width: 600px;">
<mat-toolbar fxLayout="row" color="primary">
<h2>{{ 'icon.select-icon' | translate }}</h2>
<span fxFlex></span>
<section fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-slide-toggle [formControl]="showAllControl">
</mat-slide-toggle>
<label translate>icon.show-all</label>
</section>
<button mat-button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
</mat-progress-bar>
<div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
<div class="tb-absolute-fill tb-icons-load" *ngIf="loadingIcons$ | async" fxLayout="column" fxLayoutAlign="center center">
<mat-spinner color="accent" mode="indeterminate" diameter="40"></mat-spinner>
</div>
<div mat-dialog-content>
<div class="mat-content mat-padding" fxLayout="column">
<fieldset [disabled]="(isLoading$ | async)">
<ng-template ngFor let-icon [ngForOf]="icons$ | async" let-last="last">
<ng-container #iconButtons>
<button *ngIf="icon === selectedIcon"
class="tb-select-icon-button"
mat-button mat-icon-button
mat-raised-button
color="primary"
(click)="selectIcon(icon)"
matTooltip="{{ icon }}"
matTooltipPosition="above"
type="button">
<mat-icon>{{icon}}</mat-icon>
</button>
<button *ngIf="icon !== selectedIcon"
class="tb-select-icon-button"
mat-button mat-icon-button
(click)="selectIcon(icon)"
matTooltip="{{ icon }}"
matTooltipPosition="above"
type="button">
<mat-icon>{{icon}}</mat-icon>
</button>
</ng-container>
</ng-template>
</fieldset>
</div>
</div>
<div mat-dialog-actions fxLayout="row">
<span fxFlex></span>
<button mat-button
type="button"
[disabled]="(isLoading$ | async)"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
</div>
</form>

39
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;
}
}
}

108
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<MaterialIconsDialogComponent, string>
implements OnInit, AfterViewInit {
@ViewChildren('iconButtons') iconButtons: QueryList<HTMLElement>;
selectedIcon: string;
icons$: Observable<Array<string>>;
loadingIcons$: Observable<boolean>;
showAllControl: FormControl;
constructor(protected store: Store<AppState>,
protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: MaterialIconsDialogData,
private utils: UtilsService,
public dialogRef: MatDialogRef<MaterialIconsDialogComponent, string>) {
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);
}
}

2
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';

10
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;

3
ui-ngx/src/app/shared/components/js-func.component.html

@ -17,7 +17,7 @@
-->
<div class="tb-js-func" style="background: #fff;" [ngClass]="{'tb-disabled': disabled, 'fill-height': fillHeight}"
tb-fullscreen
[fullscreen]="fullscreen" (fullscreenChanged)="onFullscreen()" fxLayout="column">
[fullscreen]="fullscreen" fxLayout="column">
<div fxLayout="row" fxLayoutAlign="start center" style="height: 40px;" class="tb-js-func-toolbar">
<label class="tb-title no-padding">{{'function ' + (functionName ? functionName : '') + '(' + functionArgsString + ') {'}}</label>
<span fxFlex></span>
@ -25,6 +25,7 @@
{{'js-func.tidy' | translate }}
</button>
<button type='button' mat-button mat-icon-button (click)="fullscreen = !fullscreen"
class="tb-mat-32"
matTooltip="{{(fullscreen ? 'fullscreen.exit' : 'fullscreen.expand') | translate}}"
matTooltipPosition="above">
<mat-icon class="material-icons">{{ fullscreen ? 'fullscreen_exit' : 'fullscreen' }}</mat-icon>

13
ui-ngx/src/app/shared/components/js-func.component.scss

@ -16,11 +16,11 @@
.tb-js-func {
position: relative;
.tb-disabled {
&.tb-disabled {
color: rgba(0, 0, 0, .38);
}
.fill-height {
&.fill-height {
height: 100%;
}
@ -41,15 +41,20 @@
}
.tb-js-func-toolbar {
.mat-button.tidy {
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 5px 0 0;
margin: 0;
font-size: .8rem;
line-height: 15px;
color: #7b7b7b;
background: rgba(220, 220, 220, .35);
&:not(:last-child) {
margin-right: 4px;
}
}
}
}

49
ui-ngx/src/app/shared/components/js-func.component.ts

@ -14,7 +14,16 @@
/// limitations under the License.
///
import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
ElementRef,
forwardRef,
Input, OnDestroy,
OnInit,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import { ControlValueAccessor, FormControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validator } from '@angular/forms';
import * as ace from 'ace-builds';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
@ -24,6 +33,7 @@ import { AppState } from '@core/core.state';
import { UtilsService } from '@core/services/utils.service';
import { isUndefined } from '@app/core/utils';
import { TranslateService } from '@ngx-translate/core';
import { CancelAnimationFrame, RafService } from '@core/services/raf.service';
@Component({
selector: 'tb-js-func',
@ -43,12 +53,14 @@ import { TranslateService } from '@ngx-translate/core';
],
encapsulation: ViewEncapsulation.None
})
export class JsFuncComponent implements OnInit, ControlValueAccessor, Validator {
export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor, Validator {
@ViewChild('javascriptEditor', {static: true})
javascriptEditorElmRef: ElementRef;
private jsEditor: ace.Ace.Editor;
private editorsResizeCaf: CancelAnimationFrame;
private editorResizeListener: any;
@Input() functionName: string;
@ -100,7 +112,8 @@ export class JsFuncComponent implements OnInit, ControlValueAccessor, Validator
constructor(public elementRef: ElementRef,
private utils: UtilsService,
private translate: TranslateService,
protected store: Store<AppState>) {
protected store: Store<AppState>,
private raf: RafService) {
}
ngOnInit(): void {
@ -137,6 +150,28 @@ export class JsFuncComponent implements OnInit, ControlValueAccessor, Validator
this.cleanupJsErrors();
this.updateView();
});
this.editorResizeListener = this.onAceEditorResize.bind(this);
// @ts-ignore
addResizeListener(editorElement, this.editorResizeListener);
}
ngOnDestroy(): void {
if (this.editorResizeListener) {
const editorElement = this.javascriptEditorElmRef.nativeElement;
// @ts-ignore
removeResizeListener(editorElement, this.editorResizeListener);
}
}
private onAceEditorResize() {
if (this.editorsResizeCaf) {
this.editorsResizeCaf();
this.editorsResizeCaf = null;
}
this.editorsResizeCaf = this.raf.raf(() => {
this.jsEditor.resize();
this.jsEditor.renderer.updateFull();
});
}
registerOnChange(fn: any): void {
@ -298,12 +333,4 @@ export class JsFuncComponent implements OnInit, ControlValueAccessor, Validator
}
}
onFullscreen() {
if (this.jsEditor) {
setTimeout(() => {
this.jsEditor.resize();
}, 0);
}
}
}

1
ui-ngx/src/app/shared/components/json-form/json-form.component.ts

@ -15,6 +15,7 @@
///
import {
ChangeDetectionStrategy,
Component,
ElementRef,
forwardRef,

2
ui-ngx/src/app/shared/components/json-object-edit.component.html

@ -32,6 +32,6 @@
</div>
<div fxFlex="0%" id="tb-json-panel" tb-toast toastTarget="jsonObjectEditor"
class="tb-json-object-panel" fxLayout="column">
<div fxFlex #jsonEditor id="tb-json-input" [ngClass]="{'fill-height': fillHeight}"></div>
<div #jsonEditor id="tb-json-input" [ngStyle]="editorStyle" [ngClass]="{'fill-height': fillHeight}"></div>
</div>
</div>

4
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;

24
ui-ngx/src/app/shared/components/material-icon-select.component.html

@ -0,0 +1,24 @@
<!--
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.
-->
<div fxLayout="row" [formGroup]="materialIconFormGroup">
<mat-icon (click)="openIconDialog()">{{materialIconFormGroup.get('icon').value}}</mat-icon>
<mat-form-field>
<mat-label translate>icon.icon</mat-label>
<input matInput formControlName="icon" (mousedown)="openIconDialog()">
</mat-form-field>
</div>

23
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);
}
}

125
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<AppState>,
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}
);
}
}
);
}
}

4
ui-ngx/src/app/shared/components/time/timewindow.component.html

@ -18,7 +18,7 @@
<button *ngIf="asButton" cdkOverlayOrigin #timewindowPanelOrigin="cdkOverlayOrigin" [disabled]="disabled"
mat-raised-button color="primary" (click)="openEditMode($event)">
<mat-icon class="material-icons">query_builder</mat-icon>
<span>{{innerValue.displayValue}}</span>
<span>{{innerValue?.displayValue}}</span>
</button>
<section *ngIf="!asButton" cdkOverlayOrigin #timewindowPanelOrigin="cdkOverlayOrigin"
class="tb-timewindow" fxLayout="row" fxLayoutAlign="start center">
@ -32,7 +32,7 @@
(click)="openEditMode($event)"
matTooltip="{{ 'timewindow.edit' | translate }}"
[matTooltipPosition]="tooltipPosition">
{{innerValue.displayValue}}
{{innerValue?.displayValue}}
</span>
<button *ngIf="direction === 'right'" [disabled]="disabled" mat-button mat-icon-button class="tb-mat-32"
(click)="openEditMode($event)"

1
ui-ngx/src/app/shared/components/time/timewindow.component.ts

@ -15,6 +15,7 @@
///
import {
ChangeDetectionStrategy,
Component,
forwardRef, Inject,
Input,

29
ui-ngx/src/app/shared/models/widget.models.ts

@ -121,7 +121,7 @@ export interface WidgetActionSource {
multiple: boolean;
}
export const widgetActionSources: {[key: string]: WidgetActionSource} = {
export const widgetActionSources: {[acionSourceId: string]: WidgetActionSource} = {
headerButton:
{
name: 'widget-action.header-button',
@ -156,7 +156,7 @@ export interface WidgetControllerDescriptor {
settingsSchema?: string | any;
dataKeySettingsSchema?: string | any;
typeParameters?: WidgetTypeParameters;
actionSources?: {[key: string]: WidgetActionSource};
actionSources?: {[actionSourceId: string]: WidgetActionSource};
}
export interface WidgetType extends BaseData<WidgetTypeId> {
@ -204,6 +204,17 @@ export interface LegendConfig {
showTotal: boolean;
}
export function defaultLegendConfig(wType: widgetType): LegendConfig {
return {
direction: LegendDirection.column,
position: LegendPosition.bottom,
showMin: false,
showMax: false,
showAvg: wType === widgetType.timeseries,
showTotal: false
};
}
export interface KeyInfo {
name: string;
label?: string;
@ -301,7 +312,15 @@ export const widgetActionTypeTranslationMap = new Map<WidgetActionType, string>(
]
);
export interface WidgetActionDescriptor {
export interface CustomActionDescriptor {
customFunction?: string;
customResources?: Array<WidgetResource>;
customHtml?: string;
customCss?: string;
}
export interface WidgetActionDescriptor extends CustomActionDescriptor {
id: string;
name: string;
icon: string;
displayName?: string;
@ -311,10 +330,6 @@ export interface WidgetActionDescriptor {
openRightLayout?: boolean;
setEntityId?: boolean;
stateEntityParamName?: string;
customFunction?: string;
customResources?: Array<WidgetResource>;
customHtml?: string;
customCss?: string;
}
export interface WidgetConfig {

9
ui-ngx/src/app/shared/shared.module.ts

@ -106,6 +106,8 @@ import { MatChipDraggableDirective } from './components/mat-chip-draggable.direc
import { ColorInputComponent } from './components/color-input.component';
import { JsFuncComponent } from './components/js-func.component';
import { JsonFormComponent } from './components/json-form/json-form.component';
import { MaterialIconsDialogComponent } from '@shared/components/dialog/material-icons-dialog.component';
import { MaterialIconSelectComponent } from '@shared/components/material-icon-select.component';
@NgModule({
providers: [
@ -121,7 +123,8 @@ import { JsonFormComponent } from './components/json-form/json-form.component';
TimewindowPanelComponent,
DashboardSelectPanelComponent,
MatSpinner,
ColorPickerDialogComponent
ColorPickerDialogComponent,
MaterialIconsDialogComponent
],
declarations: [
FooterComponent,
@ -166,7 +169,9 @@ import { JsonFormComponent } from './components/json-form/json-form.component';
FabToolbarComponent,
WidgetsBundleSelectComponent,
ColorPickerDialogComponent,
MaterialIconsDialogComponent,
ColorInputComponent,
MaterialIconSelectComponent,
JsonFormComponent,
NospacePipe,
MillisecondsToTimeStringPipe,
@ -299,7 +304,9 @@ import { JsonFormComponent } from './components/json-form/json-form.component';
HotkeyModule,
ColorPickerModule,
ColorPickerDialogComponent,
MaterialIconsDialogComponent,
ColorInputComponent,
MaterialIconSelectComponent,
JsonFormComponent,
NospacePipe,
MillisecondsToTimeStringPipe,

1
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -1641,6 +1641,7 @@
"action": "Action",
"add-action": "Add action",
"search-actions": "Search actions",
"no-actions-text": "No actions found",
"action-source": "Action source",
"action-source-required": "Action source is required.",
"action-name": "Name",

9
ui-ngx/src/styles.scss

@ -286,7 +286,7 @@ pre.tb-highlight {
font-size: 16px !important;
}
.tb-timewindow-panel {
.tb-timewindow-panel, .tb-legend-config-panel {
overflow: hidden;
background: #fff;
border-radius: 4px;
@ -764,6 +764,13 @@ mat-label {
right: 0;
}
.tb-layout-fill {
margin: 0;
width: 100%;
min-height: 100%;
height: 100%;
}
.tb-progress-cover {
position: absolute;
top: 0;

20
ui-ngx/src/typings/rawloader.typings.d.ts

@ -0,0 +1,20 @@
///
/// 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.
///
declare module '!raw-loader!*' {
const contents: string;
export = contents;
}

1
ui-ngx/tsconfig.json

@ -15,6 +15,7 @@
"jsx": "react",
"typeRoots": [
"node_modules/@types",
"src/typings/rawloader.typings.d.ts",
"src/typings/jquery.typings.d.ts",
"src/typings/jquery.flot.typings.d.ts",
"src/typings/split.js.typings.d.ts"

Loading…
Cancel
Save