Browse Source

DataKey configuration. React Schema Form initial implementation.

pull/2150/head
Igor Kulikov 7 years ago
parent
commit
79d58035f4
  1. 13
      ui-ngx/angular.json
  2. 18312
      ui-ngx/package-lock.json
  3. 42
      ui-ngx/package.json
  4. 10
      ui-ngx/src/app/app.component.ts
  5. 15
      ui-ngx/src/app/core/services/dialog.service.ts
  6. 3
      ui-ngx/src/app/core/services/dynamic-component-factory.service.ts
  7. 16
      ui-ngx/src/app/modules/home/components/home-components.module.ts
  8. 54
      ui-ngx/src/app/modules/home/components/widget/data-key-config-dialog.component.html
  9. 21
      ui-ngx/src/app/modules/home/components/widget/data-key-config-dialog.component.scss
  10. 98
      ui-ngx/src/app/modules/home/components/widget/data-key-config-dialog.component.ts
  11. 108
      ui-ngx/src/app/modules/home/components/widget/data-key-config.component.html
  12. 32
      ui-ngx/src/app/modules/home/components/widget/data-key-config.component.scss
  13. 227
      ui-ngx/src/app/modules/home/components/widget/data-key-config.component.ts
  14. 135
      ui-ngx/src/app/modules/home/components/widget/data-keys.component.html
  15. 2
      ui-ngx/src/app/modules/home/components/widget/data-keys.component.models.ts
  16. 91
      ui-ngx/src/app/modules/home/components/widget/data-keys.component.scss
  17. 454
      ui-ngx/src/app/modules/home/components/widget/data-keys.component.ts
  18. 71
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.html
  19. 12
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss
  20. 64
      ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts
  21. 36
      ui-ngx/src/app/shared/components/color-input.component.html
  22. 49
      ui-ngx/src/app/shared/components/color-input.component.scss
  23. 183
      ui-ngx/src/app/shared/components/color-input.component.ts
  24. 42
      ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.html
  25. 89
      ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.ts
  26. 39
      ui-ngx/src/app/shared/components/js-func.component.html
  27. 55
      ui-ngx/src/app/shared/components/js-func.component.scss
  28. 309
      ui-ngx/src/app/shared/components/js-func.component.ts
  29. 22
      ui-ngx/src/app/shared/components/json-form/json-form.component.html
  30. 20
      ui-ngx/src/app/shared/components/json-form/json-form.component.scss
  31. 216
      ui-ngx/src/app/shared/components/json-form/json-form.component.ts
  32. 118
      ui-ngx/src/app/shared/components/json-form/react/json-form-base-component.tsx
  33. 46
      ui-ngx/src/app/shared/components/json-form/react/json-form-checkbox.tsx
  34. 12
      ui-ngx/src/app/shared/components/json-form/react/json-form-help.tsx
  35. 97
      ui-ngx/src/app/shared/components/json-form/react/json-form-number.tsx
  36. 30
      ui-ngx/src/app/shared/components/json-form/react/json-form-radios.tsx
  37. 186
      ui-ngx/src/app/shared/components/json-form/react/json-form-rc-select.tsx
  38. 55
      ui-ngx/src/app/shared/components/json-form/react/json-form-react.tsx
  39. 183
      ui-ngx/src/app/shared/components/json-form/react/json-form-schema-form.tsx
  40. 75
      ui-ngx/src/app/shared/components/json-form/react/json-form-select.tsx
  41. 84
      ui-ngx/src/app/shared/components/json-form/react/json-form-text.tsx
  42. 542
      ui-ngx/src/app/shared/components/json-form/react/json-form-utils.ts
  43. 119
      ui-ngx/src/app/shared/components/json-form/react/json-form.models.ts
  44. 157
      ui-ngx/src/app/shared/components/json-form/react/json-form.scss
  45. 61
      ui-ngx/src/app/shared/components/json-form/react/styles/thingsboardTheme.ts
  46. 15
      ui-ngx/src/app/shared/components/json-object-edit.component.scss
  47. 265
      ui-ngx/src/app/shared/components/mat-chip-draggable.directive.ts
  48. 23
      ui-ngx/src/app/shared/shared.module.ts
  49. 1
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  50. 41
      ui-ngx/src/scss/mixins.scss
  51. 452
      ui-ngx/src/styles.scss
  52. 429
      ui-ngx/src/theme.scss
  53. 2
      ui-ngx/src/tsconfig.app.json
  54. 2
      ui-ngx/tsconfig.json

13
ui-ngx/angular.json

@ -32,8 +32,15 @@
],
"styles": [
"src/styles.scss",
"node_modules/jquery.terminal/css/jquery.terminal.min.css"
"node_modules/jquery.terminal/css/jquery.terminal.min.css",
"src/app/shared/components/json-form/react/json-form.scss",
"node_modules/rc-select/assets/index.css"
],
"stylePreprocessorOptions": {
"includePaths": [
"src/scss"
]
},
"scripts": [
"node_modules/javascript-detect-element-resize/detect-element-resize.js",
"node_modules/jquery/dist/jquery.min.js",
@ -95,8 +102,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
"maximumWarning": "5mb",
"maximumError": "10mb"
}
]
}

18312
ui-ngx/package-lock.json

File diff suppressed because it is too large

42
ui-ngx/package.json

@ -12,19 +12,20 @@
},
"private": true,
"dependencies": {
"@angular/animations": "~8.2.0",
"@angular/cdk": "~8.1.2",
"@angular/common": "~8.2.0",
"@angular/compiler": "~8.2.0",
"@angular/core": "~8.2.0",
"@angular/animations": "~8.2.11",
"@angular/cdk": "~8.2.3",
"@angular/common": "~8.2.11",
"@angular/compiler": "~8.2.11",
"@angular/core": "~8.2.11",
"@angular/flex-layout": "^8.0.0-beta.26",
"@angular/forms": "~8.2.0",
"@angular/material": "^8.1.2",
"@angular/platform-browser": "~8.2.0",
"@angular/platform-browser-dynamic": "~8.2.0",
"@angular/router": "~8.2.0",
"@angular/forms": "~8.2.11",
"@angular/material": "^8.2.3",
"@angular/platform-browser": "~8.2.11",
"@angular/platform-browser-dynamic": "~8.2.11",
"@angular/router": "~8.2.11",
"@auth0/angular-jwt": "^3.0.0",
"@mat-datetimepicker/core": "^2.0.1",
"@material-ui/core": "^4.5.1",
"@ngrx/effects": "^8.2.0",
"@ngrx/store": "^8.2.0",
"@ngrx/store-devtools": "^8.2.0",
@ -51,27 +52,40 @@
"messageformat": "^2.3.0",
"moment": "^2.24.0",
"ngx-clipboard": "^12.2.0",
"ngx-color-picker": "^8.2.0",
"ngx-translate-messageformat-compiler": "^4.5.0",
"objectpath": "^1.2.2",
"prop-types": "^15.7.2",
"rc-select": "^9.2.1",
"react": "^16.10.2",
"react-ace": "^8.0.0",
"react-dom": "^16.10.2",
"react-dropzone": "^10.1.10",
"reactcss": "^1.2.3",
"rxjs": "~6.5.2",
"schema-inspector": "^1.6.8",
"screenfull": "^4.2.1",
"split.js": "^1.5.11",
"tinycolor2": "^1.4.1",
"tslib": "^1.10.0",
"tv4": "^1.3.0",
"typeface-roboto": "^0.0.75",
"zone.js": "~0.9.1"
},
"devDependencies": {
"@angular-builders/custom-webpack": "^8.1.0",
"@angular-builders/custom-webpack": "^8.2.0",
"@angular-devkit/build-angular": "^0.802.0",
"@angular/cli": "~8.2.0",
"@angular/compiler-cli": "~8.2.0",
"@angular/language-service": "~8.2.0",
"@angular/cli": "~8.2.2",
"@angular/compiler-cli": "~8.2.11",
"@angular/language-service": "~8.2.11",
"@types/flot": "0.0.31",
"@types/jasmine": "~3.4.0",
"@types/jasminewd2": "~2.0.6",
"@types/jquery": "^3.3.31",
"@types/js-beautify": "^1.8.1",
"@types/node": "~10.14.15",
"@types/react": "^16.9.9",
"@types/react-dom": "^16.9.2",
"@types/tinycolor2": "^1.4.2",
"codelyzer": "~5.1.0",
"compression-webpack-plugin": "^3.0.0",

10
ui-ngx/src/app/app.component.ts

@ -43,6 +43,16 @@ export class AppComponent implements OnInit {
this.matIconRegistry.addSvgIconSetInNamespace('mdi',
this.domSanitizer.bypassSecurityTrustResourceUrl('./assets/mdi.svg'));
this.matIconRegistry.addSvgIconLiteral(
'alpha-a-circle-outline',
this.domSanitizer.bypassSecurityTrustHtml(
'<svg><path d="M11,7H13A2,2 0 0,1 15,9V17H13V13H11V17H9V9A2,2 0 0,' +
'1 11,7M11,9V11H13V9H11M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 ' +
'0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A1' +
'0,10 0 0,1 2,12A10,10 0 0,1 12,2Z" /></svg>'
)
);
this.storageService.testLocalStorage();
this.setupTranslate();

15
ui-ngx/src/app/core/services/dialog.service.ts

@ -22,6 +22,10 @@ import { TranslateService } from '@ngx-translate/core';
import { AlertDialogComponent } from '@core/services/dialog/alert-dialog.component';
import { TodoDialogComponent } from '@core/services/dialog/todo-dialog.component';
import { AuthService } from '@core/auth/auth.service';
import {
ColorPickerDialogComponent,
ColorPickerDialogData
} from '@shared/components/dialog/color-picker-dialog.component';
@Injectable(
{
@ -70,6 +74,17 @@ export class DialogService {
return dialogRef.afterClosed();
}
colorPicker(color: string): Observable<string> {
return this.dialog.open<ColorPickerDialogComponent, ColorPickerDialogData, string>(ColorPickerDialogComponent,
{
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
color
}
}).afterClosed();
}
private permissionDenied() {
this.alert(
this.translate.instant('access.permission-denied'),

3
ui-ngx/src/app/core/services/dynamic-component-factory.service.ts

@ -106,7 +106,8 @@ export class DynamicComponentFactoryService {
private createDynamicComponent<T>(componentType: Type<T>, template: string): Type<T> {
// noinspection AngularMissingOrInvalidDeclarationInModule
return Component({
template
template,
jit: true
})(componentType);
}

16
ui-ngx/src/app/modules/home/components/home-components.module.ts

@ -47,6 +47,9 @@ import { EntityAliasDialogComponent } from './alias/entity-alias-dialog.componen
import { EntityFilterComponent } from './entity/entity-filter.component';
import { RelationFiltersComponent } from './relation/relation-filters.component';
import { EntityAliasSelectComponent } from './alias/entity-alias-select.component';
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';
@NgModule({
entryComponents: [
@ -60,7 +63,8 @@ import { EntityAliasSelectComponent } from './alias/entity-alias-select.componen
EditAttributeValuePanelComponent,
AliasesEntitySelectPanelComponent,
EntityAliasesDialogComponent,
EntityAliasDialogComponent
EntityAliasDialogComponent,
DataKeyConfigDialogComponent
],
declarations:
[
@ -92,7 +96,10 @@ import { EntityAliasSelectComponent } from './alias/entity-alias-select.componen
WidgetConfigComponent,
EntityFilterViewComponent,
EntityFilterComponent,
EntityAliasSelectComponent
EntityAliasSelectComponent,
DataKeysComponent,
DataKeyConfigComponent,
DataKeyConfigDialogComponent
],
imports: [
CommonModule,
@ -120,7 +127,10 @@ import { EntityAliasSelectComponent } from './alias/entity-alias-select.componen
WidgetConfigComponent,
EntityFilterViewComponent,
EntityFilterComponent,
EntityAliasSelectComponent
EntityAliasSelectComponent,
DataKeysComponent,
DataKeyConfigComponent,
DataKeyConfigDialogComponent
],
providers: [
WidgetComponentService

54
ui-ngx/src/app/modules/home/components/widget/data-key-config-dialog.component.html

@ -0,0 +1,54 @@
<!--
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 #dataKeyForm="ngForm" [formGroup]="dataKeyFormGroup" (ngSubmit)="save()" style="width: 600px;">
<mat-toolbar fxLayout="row" color="primary">
<h2>{{ 'datakey.configuration' | 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>
<tb-data-key-config #dataKeyConfig
[dataKeySettingsSchema]="data.dataKeySettingsSchema"
[entityAliasId]="data.entityAliasId"
[callbacks]="data.callbacks"
formControlName="dataKey">
</tb-data-key-config>
</div>
<div mat-dialog-actions fxLayout="row">
<span fxFlex></span>
<button mat-button mat-raised-button color="primary"
type="submit"
[disabled]="(isLoading$ | async) || dataKeyForm.invalid || !dataKeyForm.dirty">
{{ '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>

21
ui-ngx/src/app/modules/home/components/widget/data-key-config-dialog.component.scss

@ -0,0 +1,21 @@
/**
* 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-dialog-content {
padding: 0 !important;
margin: 0 !important;
}
}

98
ui-ngx/src/app/modules/home/components/widget/data-key-config-dialog.component.ts

@ -0,0 +1,98 @@
///
/// Copyright © 2016-2019 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, Inject, OnInit, SkipSelf, 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, Validators } from '@angular/forms';
import {
CONTAINS_TYPE,
EntityRelation,
EntitySearchDirection,
RelationTypeGroup
} from '@shared/models/relation.models';
import { EntityRelationService } from '@core/http/entity-relation.service';
import { EntityId } from '@shared/models/id/entity-id';
import { forkJoin, Observable } from 'rxjs';
import { JsonObjectEditComponent } from '@app/shared/components/json-object-edit.component';
import { Router } from '@angular/router';
import { DialogComponent } from '@app/shared/components/dialog.component';
import { AttributeData, AttributeScope } from '@shared/models/telemetry/telemetry.models';
import { AttributeService } from '@core/http/attribute.service';
import { DataKey } from '@app/shared/models/widget.models';
import { EntityAlias } from '@shared/models/alias.models';
import { IAliasController } from '@core/api/widget-api.models';
import { DataKeysCallbacks } from './data-keys.component.models';
import { JsFuncComponent } from '@shared/components/js-func.component';
import { DataKeyConfigComponent } from '@home/components/widget/data-key-config.component';
export interface DataKeyConfigDialogData {
dataKey: DataKey;
dataKeySettingsSchema: any;
entityAliasId?: string;
callbacks?: DataKeysCallbacks;
}
@Component({
selector: 'tb-data-key-config-dialog',
templateUrl: './data-key-config-dialog.component.html',
providers: [{provide: ErrorStateMatcher, useExisting: DataKeyConfigDialogComponent}],
styleUrls: ['./data-key-config-dialog.component.scss']
})
export class DataKeyConfigDialogComponent extends DialogComponent<DataKeyConfigDialogComponent, DataKey>
implements OnInit, ErrorStateMatcher {
@ViewChild('dataKeyConfig', {static: true}) dataKeyConfig: DataKeyConfigComponent;
dataKeyFormGroup: FormGroup;
submitted = false;
constructor(protected store: Store<AppState>,
protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: DataKeyConfigDialogData,
@SkipSelf() private errorStateMatcher: ErrorStateMatcher,
public dialogRef: MatDialogRef<DataKeyConfigDialogComponent, DataKey>,
public fb: FormBuilder) {
super(store, router, dialogRef);
}
ngOnInit(): void {
this.dataKeyFormGroup = this.fb.group({
dataKey: [this.data.dataKey, [Validators.required]]
});
}
isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean {
const originalErrorState = this.errorStateMatcher.isErrorState(control, form);
const customErrorState = !!(control && control.invalid && this.submitted);
return originalErrorState || customErrorState;
}
cancel(): void {
this.dialogRef.close(null);
}
save(): void {
this.submitted = true;
this.dataKeyConfig.validateOnSubmit();
if (this.dataKeyFormGroup.valid) {
const dataKey: DataKey = this.dataKeyFormGroup.get('dataKey').value;
this.dialogRef.close(dataKey);
}
}
}

108
ui-ngx/src/app/modules/home/components/widget/data-key-config.component.html

@ -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.
-->
<mat-tab-group class="tb-datakey-config" [ngClass]="{'tb-headless': !displayAdvanced}"
[formGroup]="dataKeyFormGroup"
class="tb-datakey-config">
<mat-tab label="{{ 'datakey.settings' | translate }}">
<div class="mat-content mat-padding" fxLayout="column">
<mat-form-field class="mat-block" *ngIf="modelValue.type !== dataKeyTypes.function">
<mat-label>{{ 'entity.key' | translate }}</mat-label>
<input matInput type="text" placeholder="{{ 'entity.key-name' | translate }}"
#keyInput
formControlName="name"
required
[matAutocomplete]="keyAutocomplete">
<button *ngIf="dataKeyFormGroup.get('name').value"
type="button"
matSuffix mat-button mat-icon-button aria-label="Clear"
(click)="clearKey()">
<mat-icon class="material-icons">close</mat-icon>
</button>
<mat-autocomplete
class="tb-autocomplete"
#keyAutocomplete="matAutocomplete">
<mat-option *ngFor="let key of filteredKeys | async" [value]="key">
<span [innerHTML]="key | highlight:keySearchText"></span>
</mat-option>
</mat-autocomplete>
</mat-form-field>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field fxFlex class="mat-block">
<mat-label translate>datakey.label</mat-label>
<input matInput formControlName="label" required>
</mat-form-field>
<tb-color-input fxFlex
required
label="{{'datakey.color' | translate}}"
icon="format_color_fill"
openOnInput
formControlName="color">
</tb-color-input>
</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px" *ngIf="modelValue.type !== dataKeyTypes.alarm">
<mat-form-field fxFlex>
<mat-label translate>datakey.units</mat-label>
<input matInput formControlName="units">
</mat-form-field>
<mat-form-field fxFlex>
<mat-label translate>datakey.decimals</mat-label>
<input matInput formControlName="decimals" type="number" min="0" max="15" step="1">
</mat-form-field>
</div>
<section fxLayout="column" *ngIf="modelValue.type === dataKeyTypes.function">
<span translate>datakey.data-generation-func</span>
<br/>
<tb-js-func #funcBodyEdit
[functionArgs]="['time', 'prevValue']"
[validationArgs]="[[1, 1],[1, '1']]"
resultType="any"
formControlName="funcBody">
</tb-js-func>
</section>
<section fxLayout="column" *ngIf="modelValue.type === dataKeyTypes.timeseries || modelValue.type === dataKeyTypes.attribute">
<mat-checkbox formControlName="usePostProcessing">
{{ 'datakey.use-data-post-processing-func' | translate }}
</mat-checkbox>
<tb-js-func *ngIf="dataKeyFormGroup.get('usePostProcessing').value" #postFuncBodyEdit
[functionArgs]="['time', 'value', 'prevValue', 'timePrev', 'prevOrigValue']"
[validationArgs]="[[1, 1, 1, 1, 1],[1, '1', '1', 1, '1']]"
resultType="any"
formControlName="postFuncBody">
</tb-js-func>
<label *ngIf="dataKeyFormGroup.get('usePostProcessing').value" class="tb-title" style="margin-left: 15px;">
time - {{ 'datakey.time-description' | translate }}<br/>
value - {{ 'datakey.value-description' | translate }}<br/>
prevValue - {{ 'datakey.prev-value-description' | translate }}<br/>
timePrev - {{ 'datakey.time-prev-description' | translate }}<br/>
prevOrigValue - {{ 'datakey.prev-orig-value-description' | translate }}
</label>
</section>
</div>
</mat-tab>
<mat-tab label="{{ 'datakey.advanced' | translate }}" *ngIf="displayAdvanced">
<div class="mat-content mat-padding" fxLayout="column">
<div style="overflow: auto;">
<tb-json-form
[schema]="dataKeySchema"
[form]="dataKeyForm"
formControlName="settings">
</tb-json-form>
</div>
</div>
</mat-tab>
</mat-tab-group>

32
ui-ngx/src/app/modules/home/components/widget/data-key-config.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 {
.tb-datakey-config {
min-width: 500px !important;
min-height: 500px !important;
height: 500px !important;
}
}
:host ::ng-deep {
.tb-datakey-config {
.mat-tab-body.mat-tab-body-active {
.mat-tab-body-content > div {
height: 100%;
}
}
}
}

227
ui-ngx/src/app/modules/home/components/widget/data-key-config.component.ts

@ -0,0 +1,227 @@
///
/// 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 } 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 { JsFuncComponent } from '@shared/components/js-func.component';
@Component({
selector: 'tb-data-key-config',
templateUrl: './data-key-config.component.html',
styleUrls: ['./data-key-config.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DataKeyConfigComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => DataKeyConfigComponent),
multi: true,
}
]
})
export class DataKeyConfigComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator {
dataKeyTypes = DataKeyType;
@Input()
entityAliasId: string;
@Input()
callbacks: DataKeysCallbacks;
@Input()
dataKeySettingsSchema: any;
@ViewChild('keyInput', {static: false}) keyInput: ElementRef;
@ViewChild('funcBodyEdit', {static: false}) funcBodyEdit: JsFuncComponent;
@ViewChild('postFuncBodyEdit', {static: false}) postFuncBodyEdit: JsFuncComponent;
displayAdvanced = false;
dataKeySchema: any;
dataKeyForm: any;
private modelValue: DataKey;
private propagateChange = null;
public dataKeyFormGroup: FormGroup;
private alarmKeys: Array<DataKey>;
filteredKeys: Observable<Array<string>>;
private latestKeySearchResult: Array<string> = null;
keySearchText = '';
constructor(protected store: Store<AppState>,
private utils: UtilsService,
private entityService: EntityService,
private dialog: MatDialog,
private translate: TranslateService,
private fb: FormBuilder) {
super(store);
}
ngOnInit(): void {
this.alarmKeys = [];
for (const name of Object.keys(alarmFields)) {
this.alarmKeys.push({
name,
type: DataKeyType.alarm
});
}
if (this.dataKeySettingsSchema && this.dataKeySettingsSchema.schema) {
this.displayAdvanced = true;
this.dataKeySchema = this.dataKeySettingsSchema.schema;
this.dataKeyForm = this.dataKeySettingsSchema.form || ['*'];
}
this.dataKeyFormGroup = this.fb.group({
name: [null, []],
label: [null, [Validators.required]],
color: [null, [Validators.required]],
units: [null, []],
decimals: [null, [Validators.min(0), Validators.max(15), Validators.pattern(/^\d*$/)]],
funcBody: [null, []],
usePostProcessing: [null, []],
postFuncBody: [null, []],
settings: [null, []]
});
this.dataKeyFormGroup.valueChanges.subscribe(() => {
this.updateModel();
});
this.dataKeyFormGroup.get('usePostProcessing').valueChanges.subscribe((usePostProcessing: boolean) => {
const postFuncBody: string = this.dataKeyFormGroup.get('postFuncBody').value;
if (usePostProcessing && (!postFuncBody || !postFuncBody.length)) {
this.dataKeyFormGroup.get('postFuncBody').patchValue('return value;');
} else if (!usePostProcessing && postFuncBody && postFuncBody.length) {
this.dataKeyFormGroup.get('postFuncBody').patchValue(null);
}
});
this.filteredKeys = this.dataKeyFormGroup.get('name').valueChanges
.pipe(
map(value => value ? value : ''),
mergeMap(name => this.fetchKeys(name) )
);
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
}
writeValue(value: DataKey): void {
this.modelValue = value;
this.dataKeyFormGroup.patchValue(this.modelValue, {emitEvent: false});
this.dataKeyFormGroup.get('name').setValidators(this.modelValue.type !== DataKeyType.function ? [Validators.required] : []);
this.dataKeyFormGroup.get('name').updateValueAndValidity({emitEvent: false});
}
private updateModel() {
this.modelValue = {...this.modelValue, ...this.dataKeyFormGroup.value};
this.propagateChange(this.modelValue);
}
clearKey() {
this.dataKeyFormGroup.get('name').patchValue(null, {emitEvent: true});
setTimeout(() => {
this.keyInput.nativeElement.blur();
this.keyInput.nativeElement.focus();
}, 0);
}
private fetchKeys(searchText?: string): Observable<Array<string>> {
if (this.latestKeySearchResult === null || this.keySearchText !== searchText) {
this.keySearchText = searchText;
let fetchObservable: Observable<Array<DataKey>> = null;
if (this.modelValue.type === DataKeyType.alarm) {
const dataKeyFilter = this.createDataKeyFilter(this.keySearchText);
fetchObservable = of(this.alarmKeys.filter(dataKeyFilter));
} else {
if (this.entityAliasId) {
const dataKeyTypes = [this.modelValue.type];
fetchObservable = this.callbacks.fetchEntityKeys(this.entityAliasId, this.keySearchText, dataKeyTypes);
} else {
fetchObservable = of([]);
}
}
return fetchObservable.pipe(
map((dataKeys) => dataKeys.map((dataKey) => dataKey.name)),
tap(res => this.latestKeySearchResult = res)
);
}
return of(this.latestKeySearchResult);
}
private createDataKeyFilter(query: string): (key: DataKey) => boolean {
const lowercaseQuery = query.toLowerCase();
return key => key.name.toLowerCase().indexOf(lowercaseQuery) === 0;
}
public validateOnSubmit() {
if (this.modelValue.type === DataKeyType.function) {
this.funcBodyEdit.validateOnSubmit();
} else if ((this.modelValue.type === DataKeyType.timeseries ||
this.modelValue.type === DataKeyType.attribute) && this.dataKeyFormGroup.get('usePostProcessing').value) {
this.postFuncBodyEdit.validateOnSubmit();
}
}
public validate(c: FormControl) {
if (!this.dataKeyFormGroup.valid) {
return {
dataKey: {
valid: false
}
};
}
return null;
}
}

135
ui-ngx/src/app/modules/home/components/widget/data-keys.component.html

@ -0,0 +1,135 @@
<!--
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-form-field appearance="standard" [formGroup]="keysListFormGroup" class="mat-block">
<mat-chip-list #chipList formControlName="keys" tb-chip-draggable (chipDrop)="onChipDrop($event)">
<mat-chip
*ngFor="let key of keys; let $index = index"
[selectable]="!disabled"
[removable]="!disabled"
(removed)="remove(key)">
<div fxLayout="row" fxLayoutAlign="start center" class="tb-attribute-chip">
<div *ngIf="!disabled" class="tb-chip-drag-handle" style="margin-right: 5px;">
<mat-icon class="tb-mat-20">drag_handle</mat-icon>
</div>
<div class="tb-color-preview" (click)="showColorPicker(key)" style="margin-right: 5px;">
<div class="tb-color-result" [ngStyle]="{background: key.color}"></div>
</div>
<div fxLayout="row">
<div class="tb-chip-label">
<span *ngIf="datasourceType !== datasourceTypes.function && widgetType !== widgetTypes.alarm">
<span *ngIf="key.type === dataKeyTypes.attribute"
matTooltip="{{'datakey.attributes' | translate }}"
matTooltipPosition="above">
<mat-icon class="tb-mat-20"
svgIcon="alpha-a-circle-outline"></mat-icon>
</span>
<span *ngIf="key.type === dataKeyTypes.timeseries"
matTooltip="{{'datakey.timeseries' | translate }}"
matTooltipPosition="above">
<mat-icon class="tb-mat-20">timeline</mat-icon>
</span>
</span>
{{key.label}}
</div>
<div class="tb-chip-separator">: </div>
<div class="tb-chip-label">
<strong *ngIf="datasourceType !== datasourceTypes.function && key.postFuncBody; else simpleChipLabel">f({{key.name}})</strong>
<ng-template #simpleChipLabel>
<strong>{{key.name}}</strong>
</ng-template>
</div>
</div>
<button *ngIf="!disabled" (click)="editDataKey(key, $index)" mat-button mat-icon-button class="tb-mat-32">
<mat-icon class="tb-mat-20">edit</mat-icon>
</button>
<mat-icon matChipRemove *ngIf="!disabled">close</mat-icon>
</div>
</mat-chip>
<input matInput type="text" placeholder="{{ !disabled ? placeholder : '' }}"
style="max-width: 200px;"
#keyInput
formControlName="key"
matAutocompleteOrigin
#origin="matAutocompleteOrigin"
[matAutocompleteConnectedTo]="origin"
(focusin)="onFocus()"
[matAutocomplete]="keyAutocomplete"
[matChipInputFor]="chipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
(matChipInputTokenEnd)="add($event)">
</mat-chip-list>
<mat-autocomplete #keyAutocomplete="matAutocomplete"
class="tb-autocomplete"
[displayWith]="displayKeyFn">
<mat-option *ngFor="let key of filteredKeys | async" [value]="key">
<span style="white-space: nowrap;">
<span *ngIf="datasourceType !== datasourceTypes.function && widgetType !== widgetTypes.alarm">
<span *ngIf="key.type === dataKeyTypes.attribute"
matTooltip="{{'datakey.attributes' | translate }}"
matTooltipPosition="above">
<mat-icon class="tb-mat-16"
svgIcon="alpha-a-circle-outline"></mat-icon>
</span>
<span *ngIf="key.type === dataKeyTypes.timeseries"
matTooltip="{{'datakey.timeseries' | translate }}"
matTooltipPosition="above">
<mat-icon class="tb-mat-16">timeline</mat-icon>
</span>
</span>
<span [innerHTML]="key.name | highlight:searchText"></span>
</span>
</mat-option>
<mat-option *ngIf="!(filteredKeys | async)?.length" [value]="null" class="tb-not-found">
<div class="tb-not-found-content" (click)="$event.stopPropagation()">
<div *ngIf="!textIsNotEmpty(searchText); else searchNotEmpty">
<span translate>entity.no-keys-found</span>
</div>
<ng-template #searchNotEmpty>
<span>
{{ translate.get('entity.no-key-matching',
{key: truncate.transform(searchText, true, 6, &apos;...&apos;)}) | async }}
</span>
<span *ngIf="datasourceType === datasourceTypes.function || widgetType === widgetTypes.alarm; else createEntityKey">
<a translate (click)="createKey(searchText)">entity.create-new-key</a>
</span>
<ng-template #createEntityKey>
<span>{{'entity.create-new-key' | translate }} </span>
<span *ngIf="widgetType == widgetTypes.latest"
matTooltip="{{'datakey.attributes' | translate }}"
matTooltipPosition="above">
<mat-icon (click)="createKey(searchText, dataKeyTypes.attribute)"
class="tb-mat-16"
svgIcon="alpha-a-circle-outline"></mat-icon>
</span>
<span matTooltip="{{'datakey.timeseries' | translate }}"
matTooltipPosition="above">
<mat-icon (click)="createKey(searchText, dataKeyTypes.timeseries)"
class="tb-mat-16">timeline</mat-icon>
</span>
</ng-template>
</ng-template>
</div>
</mat-option>
</mat-autocomplete>
<mat-error *ngIf="keysListFormGroup.get('keys').hasError('required')">
{{ requiredText }}
</mat-error>
</mat-form-field>
<div class="mat-caption" style="color: rgba(0,0,0,0.57);" *ngIf="maxDataKeys !== null && maxDataKeys > -1">
{{ maxDataKeysText() }}
</div>

2
ui-ngx/src/app/modules/home/components/widget/data-keys.component.models.ts

@ -16,7 +16,9 @@
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { DataKey } from '@shared/models/widget.models';
import { Observable } from 'rxjs';
export interface DataKeysCallbacks {
generateDataKey: (chip: any, type: DataKeyType) => DataKey;
fetchEntityKeys: (entityAliasId: string, query: string, types: Array<DataKeyType>) => Observable<Array<DataKey>>;
}

91
ui-ngx/src/app/modules/home/components/widget/data-keys.component.scss

@ -0,0 +1,91 @@
/**
* 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.
*/
@mixin tb-checkered-bg() {
background-color: #fff;
background-image:
linear-gradient(45deg, #ddd 25%, transparent 25%, transparent 75%, #ddd 75%, #ddd),
linear-gradient(45deg, #ddd 25%, transparent 25%, transparent 75%, #ddd 75%, #ddd);
background-position: 0 0, 4px 4px;
background-size: 8px 8px;
}
:host {
.mat-chip.mat-standard-chip {
.tb-attribute-chip {
color: rgb(66, 66, 66);
font-weight: normal;
font-size: 16px;
.tb-chip-drag-handle {
cursor: move;
mat-icon {
pointer-events: none;
}
}
.tb-chip-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tb-chip-separator {
white-space: pre;
}
.mat-chip-remove.mat-icon {
width: 24px;
height: 24px;
font-size: 24px;
margin-left: 0px;
color: inherit;
opacity: inherit;
}
.tb-color-preview {
cursor: pointer;
box-sizing: border-box;
position: relative;
width: 24px;
min-width: 24px;
height: 24px;
overflow: hidden;
content: "";
border: 2px solid #fff;
border-radius: 50%;
box-shadow: 0 3px 1px -2px rgba(0, 0, 0, .14), 0 2px 2px 0 rgba(0, 0, 0, .098), 0 1px 5px 0 rgba(0, 0, 0, .084);
@include tb-checkered-bg();
.tb-color-result {
width: 100%;
height: 100%;
}
}
}
}
}
:host ::ng-deep {
.mat-form-field-flex {
padding-top: 0;
.mat-form-field-infix {
border-top: 0;
}
}
}

454
ui-ngx/src/app/modules/home/components/widget/data-keys.component.ts

@ -0,0 +1,454 @@
///
/// 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 { COMMA, ENTER, SEMICOLON } from '@angular/cdk/keycodes';
import {
AfterViewInit,
Component,
ElementRef,
forwardRef,
Input,
OnChanges,
OnInit,
SimpleChanges, SkipSelf,
ViewChild
} from '@angular/core';
import {
ControlValueAccessor,
FormBuilder,
FormControl,
FormGroup, FormGroupDirective,
NG_VALUE_ACCESSOR, NgForm,
Validators
} from '@angular/forms';
import { Observable, of } from 'rxjs';
import { filter, map, mergeMap, share, tap } from 'rxjs/operators';
import { Store } from '@ngrx/store';
import { AppState } from '@app/core/core.state';
import { TranslateService } from '@ngx-translate/core';
import { MatAutocomplete, MatChipInputEvent, MatChipList } from '@angular/material';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { DataKey, DatasourceType, widgetType } from '@shared/models/widget.models';
import { IAliasController } from '@core/api/widget-api.models';
import { DataKeysCallbacks } from './data-keys.component.models';
import { alarmFields } from '@shared/models/alarm.models';
import { UtilsService } from '@core/services/utils.service';
import { ErrorStateMatcher } from '@angular/material/core';
import { TruncatePipe } from '@shared/pipe/truncate.pipe';
import { DialogService } from '@core/services/dialog.service';
import {
ColorPickerDialogComponent,
ColorPickerDialogData
} from '@shared/components/dialog/color-picker-dialog.component';
import { MatDialog } from '@angular/material/dialog';
import {
DataKeyConfigDialogComponent,
DataKeyConfigDialogData
} from '@home/components/widget/data-key-config-dialog.component';
import { deepClone } from '@core/utils';
import { MatChipDropEvent } from '@app/shared/components/mat-chip-draggable.directive';
@Component({
selector: 'tb-data-keys',
templateUrl: './data-keys.component.html',
styleUrls: ['./data-keys.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DataKeysComponent),
multi: true
},
{
provide: ErrorStateMatcher,
useExisting: DataKeysComponent
}
]
})
export class DataKeysComponent implements ControlValueAccessor, OnInit, AfterViewInit, OnChanges, ErrorStateMatcher {
datasourceTypes = DatasourceType;
widgetTypes = widgetType;
dataKeyTypes = DataKeyType;
keysListFormGroup: FormGroup;
modelValue: Array<DataKey> | null;
@Input()
widgetType: widgetType;
@Input()
datasourceType: DatasourceType;
@Input()
maxDataKeys: number;
@Input()
optDataKeys: boolean;
@Input()
aliasController: IAliasController;
@Input()
datakeySettingsSchema: any;
@Input()
callbacks: DataKeysCallbacks;
@Input()
entityAliasId: string;
private requiredValue: boolean;
get required(): boolean {
return this.requiredValue || !this.optDataKeys;
}
@Input()
set required(value: boolean) {
this.requiredValue = coerceBooleanProperty(value);
}
@Input()
disabled: boolean;
@ViewChild('keyInput', {static: false}) keyInput: ElementRef<HTMLInputElement>;
@ViewChild('keyAutocomplete', {static: false}) matAutocomplete: MatAutocomplete;
@ViewChild('chipList', {static: false}) chipList: MatChipList;
keys: Array<DataKey> = [];
filteredKeys: Observable<Array<DataKey>>;
separatorKeysCodes: number[] = [ENTER, COMMA, SEMICOLON];
alarmKeys: Array<DataKey>;
functionTypeKeys: Array<DataKey>;
dataKeyType: DataKeyType;
placeholder: string;
requiredText: string;
private searchText = '';
private latestSearchTextResult: Array<DataKey> = null;
private dirty = false;
private propagateChange = (v: any) => { };
constructor(private store: Store<AppState>,
@SkipSelf() private errorStateMatcher: ErrorStateMatcher,
public translate: TranslateService,
private utils: UtilsService,
private dialogs: DialogService,
private dialog: MatDialog,
private fb: FormBuilder,
public truncate: TruncatePipe) {
this.keysListFormGroup = this.fb.group({
keys: [null, this.required ? [Validators.required] : []],
key: [null]
});
}
updateValidators() {
this.keysListFormGroup.get('keys').setValidators(this.required ? [Validators.required] : []);
this.keysListFormGroup.get('keys').updateValueAndValidity();
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
ngOnInit() {
this.alarmKeys = [];
for (const name of Object.keys(alarmFields)) {
this.alarmKeys.push({
name,
type: DataKeyType.alarm
});
}
this.functionTypeKeys = [];
for (const type of this.utils.getPredefinedFunctionsList()) {
this.functionTypeKeys.push({
name: type,
type: DataKeyType.function
});
}
this.updateParams();
this.filteredKeys = this.keysListFormGroup.get('key').valueChanges
.pipe(
tap((value: string | DataKey) => {
if (value && typeof value !== 'string') {
this.addFromChipValue(value);
} else if (value === null) {
this.clear(this.keyInput.nativeElement.value);
}
}),
filter((value) => typeof value === 'string'),
map((value) => value ? (typeof value === 'string' ? value : value.name) : ''),
mergeMap(name => this.fetchKeys(name) ),
share()
);
}
public maxDataKeysText(): string {
if (this.maxDataKeys !== null && this.maxDataKeys > -1) {
if (this.datasourceType === DatasourceType.function) {
return this.translate.instant('datakey.maximum-function-types', {count: this.maxDataKeys});
} else {
return this.translate.instant('datakey.maximum-timeseries-or-attributes', {count: this.maxDataKeys});
}
} else {
return '';
}
}
private updateParams() {
if (this.widgetType === widgetType.alarm) {
this.dataKeyType = DataKeyType.alarm;
this.placeholder = this.translate.instant('datakey.alarm');
this.requiredText = this.translate.instant('datakey.alarm-fields-required');
} else {
if (this.datasourceType === DatasourceType.function) {
this.dataKeyType = DataKeyType.function;
this.placeholder = this.translate.instant('datakey.function-types');
this.requiredText = this.translate.instant('datakey.function-types-required');
} else {
if (this.widgetType === widgetType.latest) {
this.dataKeyType = null;
this.requiredText = this.translate.instant('datakey.timeseries-or-attributes-required');
} else {
this.dataKeyType = DataKeyType.timeseries;
this.requiredText = this.translate.instant('datakey.timeseries-required');
}
this.placeholder = '';
}
}
}
private reset() {
if (this.widgetType === widgetType.alarm) {
this.keys = this.utils.getDefaultAlarmDataKeys();
} else {
this.keys = [];
}
this.keysListFormGroup.get('keys').setValue(this.keys);
this.modelValue = this.keys.length ? [...this.keys] : null;
if (this.keyInput) {
this.keyInput.nativeElement.value = '';
}
this.keysListFormGroup.get('key').patchValue('', {emitEvent: false});
this.propagateChange(this.modelValue);
this.dirty = true;
this.latestSearchTextResult = null;
}
ngOnChanges(changes: SimpleChanges): void {
for (const propName of Object.keys(changes)) {
const change = changes[propName];
if (!change.firstChange && change.currentValue !== change.previousValue) {
if (propName === 'entityAliasId') {
this.searchText = '';
this.latestSearchTextResult = null;
this.dirty = true;
} else if (['widgetType', 'datasourceType'].includes(propName)) {
this.updateParams();
setTimeout(() => {
this.reset();
}, 1);
} else if (['required', 'optDataKeys'].includes('propName')) {
this.updateValidators();
}
}
}
}
isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean {
const originalErrorState = this.errorStateMatcher.isErrorState(control, form);
const customErrorState = this.required && !this.modelValue;
return originalErrorState || customErrorState;
}
ngAfterViewInit(): void {}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (this.disabled) {
this.keysListFormGroup.disable({emitEvent: false});
} else {
this.keysListFormGroup.enable({emitEvent: false});
}
}
writeValue(value: Array<DataKey> | null): void {
this.searchText = '';
if (value != null && value.length) {
this.modelValue = [...value];
this.keys = [...value];
this.keysListFormGroup.get('keys').setValue(this.keys);
} else {
this.keys = [];
this.keysListFormGroup.get('keys').setValue(this.keys);
this.modelValue = null;
}
this.dirty = true;
}
onFocus() {
if (this.dirty) {
this.keysListFormGroup.get('key').updateValueAndValidity({onlySelf: true, emitEvent: true});
this.dirty = false;
}
}
private addFromChipValue(chip: DataKey) {
const key = this.callbacks.generateDataKey(chip.name, chip.type);
this.addKey(key);
}
addKey(key: DataKey): void {
if (!this.maxDataKeys || this.maxDataKeys < 0 ||
!this.modelValue || this.modelValue.length < this.maxDataKeys) {
if (!this.modelValue) {
this.modelValue = [];
}
this.modelValue.push(key);
this.keys.push(key);
this.keysListFormGroup.get('keys').setValue(this.keys);
}
this.propagateChange(this.modelValue);
this.clear();
}
add(event: MatChipInputEvent): void {
const value = event.value;
if ((value || '').trim()) {
if (this.dataKeyType) {
this.addFromChipValue({name: value.trim(), type: this.dataKeyType});
}
}
this.clear();
}
remove(key: DataKey) {
const index = this.keys.indexOf(key);
if (index >= 0) {
this.keys.splice(index, 1);
this.keysListFormGroup.get('keys').setValue(this.keys);
this.modelValue.splice(index, 1);
if (!this.modelValue.length) {
this.modelValue = null;
}
this.propagateChange(this.modelValue);
this.clear();
}
}
onChipDrop(event: MatChipDropEvent) {
const from = event.from;
const to = event.to;
this.keys.splice(to, 0, this.keys.splice(from, 1)[0]);
this.keysListFormGroup.get('keys').setValue(this.keys);
this.modelValue.splice(to, 0, this.modelValue.splice(from, 1)[0]);
this.propagateChange(this.modelValue);
}
showColorPicker(key: DataKey) {
this.dialogs.colorPicker(key.color).subscribe(
(color) => {
if (color && key.color !== color) {
key.color = color;
this.propagateChange(this.modelValue);
}
}
);
}
editDataKey(key: DataKey, index: number) {
this.dialog.open<DataKeyConfigDialogComponent, DataKeyConfigDialogData, DataKey>(DataKeyConfigDialogComponent,
{
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
dataKey: deepClone(key),
dataKeySettingsSchema: this.datakeySettingsSchema,
entityAliasId: this.entityAliasId,
callbacks: this.callbacks
}
}).afterClosed().subscribe((updatedDataKey) => {
if (updatedDataKey) {
this.keys[index] = updatedDataKey;
this.keysListFormGroup.get('keys').setValue(this.keys);
this.modelValue[index] = updatedDataKey;
this.propagateChange(this.modelValue);
}
});
}
createKey(name: string, dataKeyType: DataKeyType = this.dataKeyType) {
this.addFromChipValue({name, type: dataKeyType});
}
displayKeyFn(key?: DataKey): string | undefined {
return key ? key.name : undefined;
}
fetchKeys(searchText?: string): Observable<Array<DataKey>> {
if (this.latestSearchTextResult === null || this.searchText !== searchText) {
this.searchText = searchText;
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;
fetchObservable = of(targetKeysList.filter(dataKeyFilter));
} else {
if (this.entityAliasId) {
const dataKeyTypes = [DataKeyType.timeseries];
if (this.widgetType === widgetType.latest) {
dataKeyTypes.push(DataKeyType.attribute);
}
fetchObservable = this.callbacks.fetchEntityKeys(this.entityAliasId, this.searchText, dataKeyTypes);
} else {
fetchObservable = of([]);
}
}
return fetchObservable.pipe(
tap(res => this.latestSearchTextResult = res)
);
}
return of(this.latestSearchTextResult);
}
private createDataKeyFilter(query: string): (key: DataKey) => boolean {
const lowercaseQuery = query.toLowerCase();
return key => key.name.toLowerCase().indexOf(lowercaseQuery) === 0;
}
textIsNotEmpty(text: string): boolean {
return (text && text != null && text.length > 0) ? true : false;
}
clear(value: string = '') {
this.keyInput.nativeElement.value = value;
this.keysListFormGroup.get('key').patchValue(value, {emitEvent: true});
setTimeout(() => {
this.keyInput.nativeElement.blur();
this.keyInput.nativeElement.focus();
}, 0);
}
}

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

@ -92,33 +92,52 @@
formArrayName="datasources"
*ngFor="let datasourceControl of dataSettings.get('datasources').controls; let $index = index">
<span fxFlex="5">{{$index + 1}}.</span>
<div [formGroupName]="$index" class="mat-elevation-z4" fxFlex fxLayout="row" fxLayoutAlign="start center"
<div [formGroupName]="$index" class="mat-elevation-z4" fxFlex
fxLayout="row"
fxLayoutAlign="start center"
style="padding: 0 0 0 10px; margin: 5px;">
<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 fxFlex class="tb-datasource" [ngSwitch]="datasourceControl.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
[required]="datasourceControl.get('type').value === datasourceType.entity"
[aliasController]="aliasController"
formControlName="entityAliasId"
[callbacks]="widgetConfigCallbacks">
</tb-entity-alias-select>
</ng-template>
<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]="datasourceControl.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
[required]="datasourceControl.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]="datasourceControl.get('type').value"
[maxDataKeys]="typeParameters?.maxDataKeys"
[optDataKeys]="typeParameters?.dataKeysOptional"
[aliasController]="aliasController"
[datakeySettingsSchema]="dataKeySettingsSchema"
[callbacks]="widgetConfigCallbacks"
[entityAliasId]="datasourceControl.get('entityAliasId').value"
formControlName="dataKeys">
</tb-data-keys>
</section>
<!--tb-datasource fxFlex
[widgetType]="widgetType"

12
ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss

@ -35,6 +35,11 @@
max-width: 200px;
}
}
.tb-data-keys {
@media #{$mat-gt-sm} {
padding-left: 8px;
}
}
}
}
@ -74,5 +79,12 @@
}
}
}
.tb-data-keys {
@media #{$mat-gt-sm} {
.mat-input-element {
min-height: 32px;
}
}
}
}
}

64
ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts

@ -50,14 +50,15 @@ import { UtilsService } from '@core/services/utils.service';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { TranslateService } from '@ngx-translate/core';
import { EntityType } from '@shared/models/entity-type.models';
import { Observable, of, Subscription } from 'rxjs';
import { forkJoin, Observable, of, Subscription } from 'rxjs';
import { WidgetConfigCallbacks } from '@home/components/widget/widget-config.component.models';
import {
EntityAliasDialogComponent,
EntityAliasDialogData
} from '@home/components/alias/entity-alias-dialog.component';
import { tap } from 'rxjs/operators';
import { tap, mergeMap, map, catchError } from 'rxjs/operators';
import { MatDialog } from '@angular/material/dialog';
import { EntityService } from '@core/http/entity.service';
@Component({
selector: 'tb-widget-config',
@ -121,7 +122,8 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont
widgetConfigCallbacks: WidgetConfigCallbacks = {
createEntityAlias: this.createEntityAlias.bind(this),
generateDataKey: this.generateDataKey.bind(this)
generateDataKey: this.generateDataKey.bind(this),
fetchEntityKeys: this.fetchEntityKeys.bind(this)
};
widgetEditMode = this.utils.widgetEditMode;
@ -181,6 +183,7 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont
constructor(protected store: Store<AppState>,
private utils: UtilsService,
private entityService: EntityService,
private dialog: MatDialog,
private translate: TranslateService,
private fb: FormBuilder) {
@ -380,25 +383,17 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont
}
private buildDatasourceForm(datasource?: Datasource): AbstractControl {
const dataKeysRequired = !this.typeParameters || !this.typeParameters.dataKeysOptional;
const datasourceFormGroup = this.fb.group(
{
type: [datasource ? datasource.type : null, [Validators.required]],
name: [datasource ? datasource.name : null, []],
entityAliasId: [datasource ? datasource.entityAliasId : null,
datasource && datasource.type === DatasourceType.entity ? [Validators.required] : []],
dataKeys: [datasource ? datasource.name : null, []]
dataKeys: [datasource ? datasource.dataKeys : null, dataKeysRequired ? [Validators.required] : []]
}
);
datasourceFormGroup.get('type').valueChanges.subscribe((type: DatasourceType) => {
let dataKeys;
if (this.widgetType === widgetType.alarm) {
dataKeys = this.utils.getDefaultAlarmDataKeys();
} else {
dataKeys = [];
}
datasourceFormGroup.patchValue({
dataKeys
});
datasourceFormGroup.get('entityAliasId').setValidators(
type === DatasourceType.entity ? [Validators.required] : []
);
@ -560,6 +555,49 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, Cont
);
}
private fetchEntityKeys(entityAliasId: string, query: string, dataKeyTypes: Array<DataKeyType>): Observable<Array<DataKey>> {
return this.aliasController.getAliasInfo(entityAliasId).pipe(
mergeMap((aliasInfo) => {
const entity = aliasInfo.currentEntity;
if (entity) {
const fetchEntityTasks: Array<Observable<Array<DataKey>>> = [];
for (const dataKeyType of dataKeyTypes) {
fetchEntityTasks.push(
this.entityService.getEntityKeys(
{entityType: entity.entityType, id: entity.id},
query,
dataKeyType,
true,
true
).pipe(
map((keys) => {
const dataKeys: Array<DataKey> = [];
for (const key of keys) {
dataKeys.push({name: key, type: dataKeyType});
}
return dataKeys;
}
),
catchError(val => of([]))
));
}
return forkJoin(fetchEntityTasks).pipe(
map(arrayOfDataKeys => {
const result = new Array<DataKey>();
arrayOfDataKeys.forEach((dataKeyArray) => {
result.push(...dataKeyArray);
});
return result;
}
));
} else {
return of([]);
}
}),
catchError(val => of([] as Array<DataKey>))
);
}
public validate(c: FormControl) {
if (!this.dataSettings.valid) {
return {

36
ui-ngx/src/app/shared/components/color-input.component.html

@ -0,0 +1,36 @@
<!--
Copyright © 2016-2019 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<mat-form-field [formGroup]="colorFormGroup">
<mat-label>
<mat-icon *ngIf="icon">{{icon}}</mat-icon>
<span *ngIf="label">{{label}}</span>
</mat-label>
<div matPrefix class="tb-color-preview" (click)="showColorPicker()" style="margin-right: 5px;">
<div class="tb-color-result" [ngStyle]="{background: colorFormGroup.get('color').value}"></div>
</div>
<input matInput formControlName="color" (mousedown)="openOnInput && showColorPicker()" [required]="required">
<button *ngIf="colorClearButton"
type="button"
matSuffix mat-button mat-icon-button aria-label="Clear"
(click)="clear()">
<mat-icon class="material-icons">close</mat-icon>
</button>
<mat-error *ngIf="colorFormGroup.get('color').hasError('required')">
{{ requiredText }}
</mat-error>
</mat-form-field>

49
ui-ngx/src/app/shared/components/color-input.component.scss

@ -0,0 +1,49 @@
/**
* Copyright © 2016-2019 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@mixin tb-checkered-bg() {
background-color: #fff;
background-image:
linear-gradient(45deg, #ddd 25%, transparent 25%, transparent 75%, #ddd 75%, #ddd),
linear-gradient(45deg, #ddd 25%, transparent 25%, transparent 75%, #ddd 75%, #ddd);
background-position: 0 0, 4px 4px;
background-size: 8px 8px;
}
:host {
.mat-form-field {
width: 100%;
}
.tb-color-preview {
cursor: pointer;
box-sizing: border-box;
position: relative;
width: 24px;
min-width: 24px;
height: 24px;
overflow: hidden;
content: "";
border: 2px solid #fff;
border-radius: 50%;
box-shadow: 0 3px 1px -2px rgba(0, 0, 0, .14), 0 2px 2px 0 rgba(0, 0, 0, .098), 0 1px 5px 0 rgba(0, 0, 0, .084);
@include tb-checkered-bg();
.tb-color-result {
width: 100%;
height: 100%;
}
}
}

183
ui-ngx/src/app/shared/components/color-input.component.ts

@ -0,0 +1,183 @@
///
/// 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-color-input',
templateUrl: './color-input.component.html',
styleUrls: ['./color-input.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ColorInputComponent),
multi: true
}
]
})
export class ColorInputComponent extends PageComponent implements OnInit, ControlValueAccessor {
@Input()
icon: string;
@Input()
label: string;
@Input()
requiredText: string;
private colorClearButtonValue: boolean;
get colorClearButton(): boolean {
return this.colorClearButtonValue;
}
@Input()
set colorClearButton(value: boolean) {
const newVal = coerceBooleanProperty(value);
if (this.colorClearButtonValue !== newVal) {
this.colorClearButtonValue = newVal;
}
}
private openOnInputValue: boolean;
get openOnInput(): boolean {
return this.openOnInputValue;
}
@Input()
set openOnInput(value: boolean) {
const newVal = coerceBooleanProperty(value);
if (this.openOnInputValue !== newVal) {
this.openOnInputValue = newVal;
}
}
private requiredValue: boolean;
get required(): boolean {
return this.requiredValue;
}
@Input()
set required(value: boolean) {
const newVal = coerceBooleanProperty(value);
if (this.requiredValue !== newVal) {
this.requiredValue = newVal;
this.updateValidators();
}
}
@Input()
disabled: boolean;
private modelValue: string;
private propagateChange = null;
public colorFormGroup: FormGroup;
constructor(protected store: Store<AppState>,
private dialogs: DialogService,
private translate: TranslateService,
private fb: FormBuilder) {
super(store);
}
ngOnInit(): void {
this.colorFormGroup = this.fb.group({
color: [null, this.required ? [Validators.required] : []]
});
this.colorFormGroup.valueChanges.subscribe(() => {
this.updateModel();
});
}
updateValidators() {
if (this.colorFormGroup) {
this.colorFormGroup.get('color').setValidators(this.required ? [Validators.required] : []);
this.colorFormGroup.get('color').updateValueAndValidity();
}
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (isDisabled) {
this.colorFormGroup.disable({emitEvent: false});
} else {
this.colorFormGroup.enable({emitEvent: false});
}
}
writeValue(value: string): void {
this.modelValue = value;
this.colorFormGroup.patchValue(
{ color: this.modelValue }, {emitEvent: false}
);
}
private updateModel() {
const color: string = this.colorFormGroup.get('color').value;
if (this.modelValue !== color) {
this.modelValue = color;
this.propagateChange(this.modelValue);
}
}
showColorPicker() {
this.dialogs.colorPicker(this.colorFormGroup.get('color').value).subscribe(
(color) => {
if (color) {
this.colorFormGroup.patchValue(
{color}, {emitEvent: true}
);
}
}
);
}
clear() {
this.colorFormGroup.get('color').patchValue(null, {emitEvent: true});
}
}

42
ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.html

@ -0,0 +1,42 @@
<!--
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]="colorPickerFormGroup" (ngSubmit)="select()">
<div mat-dialog-content fxLayout="row" fxLayoutAlign="center">
<span [style.background]="colorPickerFormGroup.get('color').value"
[cpToggle]="true"
[cpDialogDisplay]="'inline'"
[colorPicker]="colorPickerFormGroup.get('color').value"
(colorPickerChange)="onColorChange($event)">
</span>
</div>
<div mat-dialog-actions fxLayout="row">
<span fxFlex></span>
<button mat-button
type="button"
[disabled]="(isLoading$ | async)"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-button
type="submit"
style="margin-right: 20px;"
[disabled]="(isLoading$ | async) || colorPickerFormGroup.invalid || !colorPickerFormGroup.dirty">
{{ 'action.select' | translate }}
</button>
</div>
</form>

89
ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.ts

@ -0,0 +1,89 @@
///
/// Copyright © 2016-2019 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, Inject, OnInit, SkipSelf, 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, Validators } from '@angular/forms';
import {
CONTAINS_TYPE,
EntityRelation,
EntitySearchDirection,
RelationTypeGroup
} from '@shared/models/relation.models';
import { EntityRelationService } from '@core/http/entity-relation.service';
import { EntityId } from '@shared/models/id/entity-id';
import { forkJoin, Observable } from 'rxjs';
import { JsonObjectEditComponent } from '@app/shared/components/json-object-edit.component';
import { Router } from '@angular/router';
import { DialogComponent } from '@app/shared/components/dialog.component';
import { AttributeData, AttributeScope } from '@shared/models/telemetry/telemetry.models';
import { AttributeService } from '@core/http/attribute.service';
export interface ColorPickerDialogData {
color: string;
}
@Component({
selector: 'tb-color-picker-dialog',
templateUrl: './color-picker-dialog.component.html',
providers: [{provide: ErrorStateMatcher, useExisting: ColorPickerDialogComponent}],
styleUrls: []
})
export class ColorPickerDialogComponent extends DialogComponent<ColorPickerDialogComponent, string>
implements OnInit, ErrorStateMatcher {
colorPickerFormGroup: FormGroup;
submitted = false;
constructor(protected store: Store<AppState>,
protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: ColorPickerDialogData,
@SkipSelf() private errorStateMatcher: ErrorStateMatcher,
public dialogRef: MatDialogRef<ColorPickerDialogComponent, string>,
public fb: FormBuilder) {
super(store, router, dialogRef);
}
ngOnInit(): void {
this.colorPickerFormGroup = this.fb.group({
color: [this.data.color, [Validators.required]]
});
}
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;
}
onColorChange(color: string) {
this.colorPickerFormGroup.get('color').setValue(color);
this.colorPickerFormGroup.markAsDirty();
}
cancel(): void {
this.dialogRef.close(null);
}
select(): void {
this.submitted = true;
const color: string = this.colorPickerFormGroup.get('color').value;
this.dialogRef.close(color);
}
}

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

@ -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.
-->
<div class="tb-js-func" style="background: #fff;" [ngClass]="{'tb-disabled': disabled, 'fill-height': fillHeight}"
tb-fullscreen
[fullscreen]="fullscreen" (fullscreenChanged)="onFullscreen()" 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>
<button type='button' *ngIf="!disabled" mat-button class="tidy" (click)="beautifyJs()">
{{'js-func.tidy' | translate }}
</button>
<button type='button' mat-button mat-icon-button (click)="fullscreen = !fullscreen"
matTooltip="{{(fullscreen ? 'fullscreen.exit' : 'fullscreen.expand') | translate}}"
matTooltipPosition="above">
<mat-icon class="material-icons">{{ fullscreen ? 'fullscreen_exit' : 'fullscreen' }}</mat-icon>
</button>
</div>
<div fxFlex="0%" id="tb-javascript-panel" class="tb-js-func-panel" fxLayout="column" tb-toast toastTarget="jsFuncEditor">
<div fxFlex #javascriptEditor id="tb-javascript-input" [ngClass]="{'fill-height': fillHeight}"></div>
</div>
<div fxLayout="row" fxLayoutAlign="start center" style="height: 40px;">
<label class="tb-title no-padding">}</label>
</div>
</div>

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

@ -0,0 +1,55 @@
/**
* 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-js-func {
position: relative;
.tb-disabled {
color: rgba(0, 0, 0, .38);
}
.fill-height {
height: 100%;
}
.tb-js-func-panel {
height: calc(100% - 80px);
margin-left: 15px;
border: 1px solid #c0c0c0;
#tb-javascript-input {
width: 100%;
min-width: 200px;
height: 100%;
&:not(.fill-height) {
min-height: 200px;
}
}
}
.tb-js-func-toolbar {
.mat-button.tidy {
min-width: 32px;
min-height: 15px;
padding: 4px;
margin: 0 5px 0 0;
font-size: .8rem;
line-height: 15px;
color: #7b7b7b;
background: rgba(220, 220, 220, .35);
}
}
}

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

@ -0,0 +1,309 @@
///
/// 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, 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';
import { ActionNotificationHide, ActionNotificationShow } from '@core/notification/notification.actions';
import { Store } from '@ngrx/store';
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';
@Component({
selector: 'tb-js-func',
templateUrl: './js-func.component.html',
styleUrls: ['./js-func.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => JsFuncComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => JsFuncComponent),
multi: true,
}
],
encapsulation: ViewEncapsulation.None
})
export class JsFuncComponent implements OnInit, ControlValueAccessor, Validator {
@ViewChild('javascriptEditor', {static: true})
javascriptEditorElmRef: ElementRef;
private jsEditor: ace.Ace.Editor;
@Input() functionName: string;
@Input() functionArgs: Array<string>;
@Input() validationArgs: Array<string>;
@Input() resultType: string;
@Input() disabled: boolean;
@Input() fillHeight: boolean;
private noValidateValue: boolean;
get noValidate(): boolean {
return this.noValidateValue;
}
@Input()
set noValidate(value: boolean) {
this.noValidateValue = coerceBooleanProperty(value);
}
private requiredValue: boolean;
get required(): boolean {
return this.requiredValue;
}
@Input()
set required(value: boolean) {
this.requiredValue = coerceBooleanProperty(value);
}
functionArgsString = '';
fullscreen = false;
modelValue: string;
functionValid = true;
validationError: string;
errorShowed = false;
errorMarkers: number[] = [];
errorAnnotationId = -1;
private propagateChange = null;
constructor(public elementRef: ElementRef,
private utils: UtilsService,
private translate: TranslateService,
protected store: Store<AppState>) {
}
ngOnInit(): void {
if (!this.resultType || this.resultType.length === 0) {
this.resultType = 'nocheck';
}
if (this.functionArgs) {
this.functionArgs.forEach((functionArg) => {
if (this.functionArgsString.length > 0) {
this.functionArgsString += ', ';
}
this.functionArgsString += functionArg;
});
}
const editorElement = this.javascriptEditorElmRef.nativeElement;
let editorOptions: Partial<ace.Ace.EditorOptions> = {
mode: 'ace/mode/javascript',
showGutter: true,
showPrintMargin: true,
readOnly: this.disabled
};
const advancedOptions = {
enableSnippets: true,
enableBasicAutocompletion: true,
enableLiveAutocompletion: true
};
editorOptions = {...editorOptions, ...advancedOptions};
this.jsEditor = ace.edit(editorElement, editorOptions);
this.jsEditor.session.setUseWrapMode(true);
this.jsEditor.setValue(this.modelValue ? this.modelValue : '', -1);
this.jsEditor.on('change', () => {
this.cleanupJsErrors();
this.updateView();
});
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
public validate(c: FormControl) {
return (this.functionValid) ? null : {
jsFunc: {
valid: false,
},
};
}
beautifyJs() {
const res = js_beautify(this.modelValue, {indent_size: 4, wrap_line_length: 60});
this.jsEditor.setValue(res ? res : '', -1);
this.updateView();
}
validateOnSubmit(): void {
if (!this.disabled) {
this.cleanupJsErrors();
this.functionValid = this.validateJsFunc();
if (!this.functionValid) {
this.propagateChange(this.modelValue);
this.store.dispatch(new ActionNotificationShow(
{
message: this.validationError,
type: 'error',
target: 'jsFuncEditor',
verticalPosition: 'bottom',
horizontalPosition: 'left'
}));
this.errorShowed = true;
}
}
}
private validateJsFunc(): boolean {
try {
const toValidate = new Function(this.functionArgsString, this.modelValue);
if (this.noValidate) {
return true;
}
if (this.validationArgs) {
let res: any;
let validationError: any;
for (const validationArg of this.validationArgs) {
try {
res = toValidate.apply(this, validationArg);
validationError = null;
break;
} catch (e) {
validationError = e;
}
}
if (validationError) {
throw validationError;
}
if (this.resultType !== 'nocheck') {
if (this.resultType === 'any') {
if (isUndefined(res)) {
this.validationError = this.translate.instant('js-func.no-return-error');
return false;
}
} else {
const resType = typeof res;
if (resType !== this.resultType) {
this.validationError = this.translate.instant('js-func.return-type-mismatch', {type: this.resultType});
return false;
}
}
}
return true;
} else {
return true;
}
} catch (e) {
const details = this.utils.parseException(e);
let errorInfo = 'Error:';
if (details.name) {
errorInfo += ' ' + details.name + ':';
}
if (details.message) {
errorInfo += ' ' + details.message;
}
if (details.lineNumber) {
errorInfo += '<br>Line ' + details.lineNumber;
if (details.columnNumber) {
errorInfo += ' column ' + details.columnNumber;
}
errorInfo += ' of script.';
}
this.validationError = errorInfo;
if (details.lineNumber) {
const line = details.lineNumber - 1;
let column = 0;
if (details.columnNumber) {
column = details.columnNumber;
}
const errorMarkerId = this.jsEditor.session.addMarker(new ace.Range(line, 0, line, Infinity),
'ace_active-line', 'screenLine');
this.errorMarkers.push(errorMarkerId);
const annotations = this.jsEditor.session.getAnnotations();
const errorAnnotation: ace.Ace.Annotation = {
row: line,
column,
text: details.message,
type: 'error'
};
this.errorAnnotationId = annotations.push(errorAnnotation) - 1;
this.jsEditor.session.setAnnotations(annotations);
}
return false;
}
}
private cleanupJsErrors(): void {
if (this.errorShowed) {
this.store.dispatch(new ActionNotificationHide(
{
target: 'jsFuncEditor'
}));
this.errorShowed = false;
}
this.errorMarkers.forEach((errorMarker) => {
this.jsEditor.session.removeMarker(errorMarker);
});
this.errorMarkers.length = 0;
if (this.errorAnnotationId > -1) {
const annotations = this.jsEditor.session.getAnnotations();
annotations.splice(this.errorAnnotationId, 1);
this.jsEditor.session.setAnnotations(annotations);
this.errorAnnotationId = -1;
}
}
writeValue(value: string): void {
this.modelValue = value;
if (this.jsEditor) {
this.jsEditor.setValue(this.modelValue ? this.modelValue : '', -1);
}
}
updateView() {
const editorValue = this.jsEditor.getValue();
if (this.modelValue !== editorValue) {
this.modelValue = editorValue;
this.functionValid = true;
this.propagateChange(this.modelValue);
}
}
onFullscreen() {
if (this.jsEditor) {
setTimeout(() => {
this.jsEditor.resize();
}, 0);
}
}
}

22
ui-ngx/src/app/shared/components/json-form/json-form.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.
-->
<div class="tb-json-form" style="background: #fff;" tb-fullscreen [fullscreen]="isFullscreen"
(fullscreenChanged)="onFullscreenChanged()">
<div #reactRoot></div>
<div>{{ model | json }}</div>
</div>

20
ui-ngx/src/app/shared/components/json-form/json-form.component.scss

@ -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.
*/
.tb-json-form {
padding: 12px;
padding-bottom: 14px !important;
overflow: auto;
}

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

@ -0,0 +1,216 @@
///
/// 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,
OnChanges,
OnDestroy,
OnInit,
SimpleChanges,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import { ControlValueAccessor, FormControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validator } from '@angular/forms';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { deepClone, isString } from '@app/core/utils';
import { TranslateService } from '@ngx-translate/core';
import { JsonFormProps } from './react/json-form.models';
import inspector from 'schema-inspector';
import * as tinycolor from 'tinycolor2';
import { DialogService } from '@app/core/services/dialog.service';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import ReactSchemaForm from './react/json-form-react';
import JsonFormUtils from './react/json-form-utils';
@Component({
selector: 'tb-json-form',
templateUrl: './json-form.component.html',
styleUrls: ['./json-form.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => JsonFormComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => JsonFormComponent),
multi: true,
}
],
encapsulation: ViewEncapsulation.None
})
export class JsonFormComponent implements OnInit, ControlValueAccessor, Validator, OnChanges, OnDestroy {
@ViewChild('reactRoot', {static: true})
reactRootElmRef: ElementRef<HTMLElement>;
@Input() schema: any;
@Input() form: any;
@Input() groupInfoes: any[];
private readonlyValue: boolean;
get readonly(): boolean {
return this.readonlyValue;
}
@Input()
set required(value: boolean) {
this.readonlyValue = coerceBooleanProperty(value);
}
formProps: JsonFormProps = {
isFullscreen: false,
option: {
formDefaults: {
startEmpty: true
}
},
onModelChange: this.onModelChange.bind(this),
onColorClick: this.onColorClick.bind(this),
onToggleFullscreen: this.onToggleFullscreen.bind(this)
};
model: any;
isModelValid = true;
isFullscreen = false;
private propagateChange = null;
constructor(public elementRef: ElementRef,
private translate: TranslateService,
private dialogs: DialogService,
protected store: Store<AppState>) {
}
ngOnInit(): void {
}
ngOnDestroy(): void {
this.destroyReactSchemaForm();
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
}
public validate(c: FormControl) {
return this.isModelValid ? null : {
modelValid: false
};
}
writeValue(value: any): void {
this.model = value || {};
this.model = inspector.sanitize(this.schema, this.model).data;
this.updateAndRender();
this.isModelValid = this.validateModel();
if (!this.isModelValid) {
this.updateView();
}
}
updateView() {
this.propagateChange(this.model);
}
ngOnChanges(changes: SimpleChanges): void {
for (const propName of Object.keys(changes)) {
const change = changes[propName];
if (!change.firstChange && change.currentValue !== change.previousValue) {
if (propName === 'schema') {
this.model = inspector.sanitize(this.schema, this.model).data;
this.isModelValid = this.validateModel();
}
if (['readonly', 'schema', 'form', 'groupInfoes'].includes(propName)) {
this.updateAndRender();
}
}
}
}
onFullscreenChanged() {}
private onModelChange(key: string | string[], val: any) {
if (isString(val) && val === '') {
val = undefined;
}
if (JsonFormUtils.updateValue(key, this.model, val)) {
this.formProps.model = this.model;
this.updateView();
}
}
private onColorClick(event: MouseEvent, key: string, val: string) {
this.dialogs.colorPicker(tinycolor(val).toRgbString()).subscribe((color) => {
const e = event as any;
if (e.data && e.data.onValueChanged) {
e.data.onValueChanged(tinycolor(color).toRgb());
}
});
}
private onToggleFullscreen() {
this.isFullscreen = !this.isFullscreen;
this.formProps.isFullscreen = this.isFullscreen;
}
private updateAndRender() {
const schema = this.schema ? deepClone(this.schema) : {
type: 'object'
};
schema.strict = true;
const form = this.form ? deepClone(this.form) : [ '*' ];
const groupInfoes = this.groupInfoes ? deepClone(this.groupInfoes) : [];
this.formProps.option.formDefaults.readonly = this.readonly;
this.formProps.schema = schema;
this.formProps.form = form;
this.formProps.groupInfoes = groupInfoes;
this.formProps.model = deepClone(this.model);
this.renderReactSchemaForm();
}
private renderReactSchemaForm() {
ReactDOM.render(React.createElement(ReactSchemaForm, this.formProps), this.reactRootElmRef.nativeElement);
}
private destroyReactSchemaForm() {
ReactDOM.unmountComponentAtNode(this.reactRootElmRef.nativeElement);
}
private validateModel(): boolean {
if (this.schema && this.model) {
return JsonFormUtils.validateBySchema(this.schema, this.model).valid;
}
return true;
}
}

118
ui-ngx/src/app/shared/components/json-form/react/json-form-base-component.tsx

@ -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 * as React from 'react';
import JsonFormUtils from './json-form-utils';
import { JsonFormFieldProps, JsonFormFieldState } from '@shared/components/json-form/react/json-form.models';
export default ThingsboardBaseComponent => class extends React.Component<JsonFormFieldProps, JsonFormFieldState> {
constructor(props) {
super(props);
this.onChangeValidate = this.onChangeValidate.bind(this);
const value = this.defaultValue();
const validationResult = JsonFormUtils.validate(this.props.form, value);
this.state = {
value,
valid: !!(validationResult.valid || !value),
error: !validationResult.valid && value ? validationResult.error.message : null
};
}
componentDidMount() {
/* if (typeof this.state.value !== 'undefined') {
this.props.onChange(this.props.form.key, this.state.value);
}*/
}
onChangeValidate(e) {
let value = null;
if (this.props.form.schema.type === 'integer' || this.props.form.schema.type === 'number') {
if (e.target.value === null || e.target.value === '') {
value = undefined;
} else if (e.target.value.indexOf('.') === -1) {
value = parseInt(e.target.value, 10);
} else {
value = parseFloat(e.target.value);
}
} else if (this.props.form.schema.type === 'boolean') {
value = e.target.checked;
} else if (this.props.form.schema.type === 'date' || this.props.form.schema.type === 'array') {
value = e;
} else { // string
value = e.target.value;
}
const validationResult = JsonFormUtils.validate(this.props.form, value);
this.setState({
value,
valid: validationResult.valid,
error: validationResult.valid ? null : validationResult.error.message
});
this.props.onChange(this.props.form.key, value);
}
defaultValue() {
let value = JsonFormUtils.selectOrSet(this.props.form.key, this.props.model);
if (this.props.form.schema.type === 'boolean') {
if (typeof value !== 'boolean' && this.props.form.default) {
value = this.props.form.default;
}
if (typeof value !== 'boolean' && this.props.form.schema && this.props.form.schema.default) {
value = this.props.form.schema.default;
}
if (typeof value !== 'boolean' &&
this.props.form.schema &&
this.props.form.required) {
value = false;
}
} else if (this.props.form.schema.type === 'integer' || this.props.form.schema.type === 'number') {
if (typeof value !== 'number' && this.props.form.default) {
value = this.props.form.default;
}
if (typeof value !== 'number' && this.props.form.schema && this.props.form.schema.default) {
value = this.props.form.schema.default;
}
if (typeof value !== 'number' && this.props.form.titleMap && this.props.form.titleMap[0].value) {
value = this.props.form.titleMap[0].value;
}
if (value && typeof value === 'string') {
if (value.indexOf('.') === -1) {
value = parseInt(value, 10);
} else {
value = parseFloat(value);
}
}
} else {
if (!value && this.props.form.default) {
value = this.props.form.default;
}
if (!value && this.props.form.schema && this.props.form.schema.default) {
value = this.props.form.schema.default;
}
if (!value && this.props.form.titleMap && this.props.form.titleMap[0].value) {
value = this.props.form.titleMap[0].value;
}
}
return value;
}
render() {
if (this.props.form && this.props.form.schema) {
return <ThingsboardBaseComponent {...this.props} {...this.state} onChangeValidate={this.onChangeValidate}/>;
} else {
return <div></div>;
}
}
};

46
ui-ngx/src/app/shared/components/json-form/react/json-form-checkbox.tsx

@ -0,0 +1,46 @@
/*
* 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 * as React from 'react';
import ThingsboardBaseComponent from './json-form-base-component';
import Checkbox from '@material-ui/core/Checkbox';
import { JsonFormFieldProps, JsonFormFieldState } from './json-form.models.js';
import FormControlLabel from '@material-ui/core/FormControlLabel';
class ThingsboardCheckbox extends React.Component<JsonFormFieldProps, JsonFormFieldState> {
render() {
return (
<div>
<FormControlLabel
control={
<Checkbox
name={this.props.form.key.slice(-1)[0]}
value={this.props.form.key.slice(-1)[0]}
defaultChecked={this.props.value || false}
disabled={this.props.form.readonly}
onChange={(e, checked) => {
this.props.onChangeValidate(e);
}}
style={{paddingTop: '14px'}}
/>
}
label={this.props.form.title}
/>
</div>
);
}
}
export default ThingsboardBaseComponent(ThingsboardCheckbox);

12
ui-ngx/src/app/shared/components/json-form/react/json-form-help.tsx

@ -0,0 +1,12 @@
import * as React from 'react';
import { JsonFormFieldProps, JsonFormFieldState } from '@shared/components/json-form/react/json-form.models';
class ThingsboardHelp extends React.Component<JsonFormFieldProps, JsonFormFieldState> {
render() {
return (
<div className={this.props.form.htmlClass} dangerouslySetInnerHTML={{__html: this.props.form.description}} ></div>
);
}
}
export default ThingsboardHelp;

97
ui-ngx/src/app/shared/components/json-form/react/json-form-number.tsx

@ -0,0 +1,97 @@
/*
* 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 * as React from 'react';
import ThingsboardBaseComponent from './json-form-base-component';
import { JsonFormFieldProps, JsonFormFieldState } from '@shared/components/json-form/react/json-form.models';
import { TextField } from '@material-ui/core';
interface ThingsboardNumberState extends JsonFormFieldState {
focused: boolean;
lastSuccessfulValue: number;
}
class ThingsboardNumber extends React.Component<JsonFormFieldProps, ThingsboardNumberState> {
constructor(props) {
super(props);
this.preValidationCheck = this.preValidationCheck.bind(this);
this.onBlur = this.onBlur.bind(this);
this.onFocus = this.onFocus.bind(this);
this.state = {
lastSuccessfulValue: this.props.value,
focused: false
};
}
isNumeric(n) {
return n === null || n === '' || !isNaN(n) && isFinite(n);
}
onBlur() {
this.setState({focused: false});
}
onFocus() {
this.setState({focused: true});
}
preValidationCheck(e) {
if (this.isNumeric(e.target.value)) {
this.setState({
lastSuccessfulValue: e.target.value
});
this.props.onChangeValidate(e);
}
}
render() {
let fieldClass = 'tb-field';
if (this.props.form.required) {
fieldClass += ' tb-required';
}
if (this.props.form.readonly) {
fieldClass += ' tb-readonly';
}
if (this.state.focused) {
fieldClass += ' tb-focused';
}
let value = this.state.lastSuccessfulValue;
if (typeof value !== 'undefined') {
value = Number(value);
} else {
value = null;
}
return (
<div>
<TextField
className={fieldClass}
label={this.props.form.title}
type='number'
error={!this.props.valid}
helperText={this.props.valid ? this.props.form.placeholder : this.props.error}
onChange={this.preValidationCheck}
defaultValue={value}
disabled={this.props.form.readonly}
onFocus={this.onFocus}
onBlur={this.onBlur}
style={this.props.form.style || {width: '100%'}}/>
</div>
);
}
}
export default ThingsboardBaseComponent(ThingsboardNumber);

30
ui-ngx/src/app/shared/components/json-form/react/json-form-radios.tsx

@ -0,0 +1,30 @@
import * as React from 'react';
import { JsonFormFieldProps, JsonFormFieldState } from '@shared/components/json-form/react/json-form.models';
import FormControlLabel from '@material-ui/core/FormControlLabel';
import { Radio, FormLabel, RadioGroup } from '@material-ui/core';
import FormControl from '@material-ui/core/FormControl';
import ThingsboardBaseComponent from '@shared/components/json-form/react/json-form-base-component';
class ThingsboardRadios extends React.Component<JsonFormFieldProps, JsonFormFieldState> {
render() {
const items = this.props.form.titleMap.map((item, index) => {
return (
<FormControlLabel value={item.value} control={<Radio />} label={item.name} key={index} />
);
});
return (
<FormControl component='fieldset'
className={this.props.form.htmlClass}
disabled={this.props.form.readonly}>
<FormLabel component='legend'>{this.props.form.title}</FormLabel>
<RadioGroup name={this.props.form.title} value={this.props.value} onChange={this.props.onChangeValidate}>
{items}
</RadioGroup>
</FormControl>
);
}
}
export default ThingsboardBaseComponent(ThingsboardRadios);

186
ui-ngx/src/app/shared/components/json-form/react/json-form-rc-select.tsx

@ -0,0 +1,186 @@
/*
* 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 * as React from 'react';
import ThingsboardBaseComponent from './json-form-base-component';
import Select, {Option} from 'rc-select/lib';
import {
JsonFormFieldProps,
JsonFormFieldState,
KeyLabelItem
} from '@shared/components/json-form/react/json-form.models';
interface ThingsboardRcSelectState extends JsonFormFieldState {
currentValue: KeyLabelItem | KeyLabelItem[];
items: Array<KeyLabelItem>;
focused: boolean;
}
class ThingsboardRcSelect extends React.Component<JsonFormFieldProps, ThingsboardRcSelectState> {
constructor(props) {
super(props);
this.onSelect = this.onSelect.bind(this);
this.onDeselect = this.onDeselect.bind(this);
this.onBlur = this.onBlur.bind(this);
this.onFocus = this.onFocus.bind(this);
this.state = {
currentValue: this.keyToCurrentValue(this.props.value, this.props.form.schema.type === 'array'),
items: this.props.form.items,
focused: false
};
}
keyToCurrentValue(key: string | string[], isArray: boolean): KeyLabelItem | KeyLabelItem[] {
let currentValue: KeyLabelItem | KeyLabelItem[] = isArray ? [] : null;
if (isArray) {
const keys = key;
if (keys) {
(keys as string[]).forEach((keyVal) => {
(currentValue as KeyLabelItem[]).push({key: keyVal, label: this.labelFromKey(keyVal)});
});
}
} else {
currentValue = {key: key as string, label: this.labelFromKey(key as string)};
}
return currentValue;
}
labelFromKey(key: string): string {
let label = key || '';
if (key) {
for (const item of this.props.form.items) {
if (item.value === key) {
label = item.label;
break;
}
}
}
return label;
}
arrayValues(items: KeyLabelItem[]): string[] {
const v: string[] = [];
if (items) {
items.forEach(item => {
v.push(item.key);
});
}
return v;
}
keyIndex(values: KeyLabelItem[], key: string): number {
let index = -1;
if (values) {
for (let i = 0; i < values.length; i++) {
if (values[i].key === key) {
index = i;
break;
}
}
}
return index;
}
onSelect(value: KeyLabelItem, option) {
if (this.props.form.schema.type === 'array') {
const v = this.state.currentValue as KeyLabelItem[];
v.push(this.keyToCurrentValue(value.key, false) as KeyLabelItem);
this.setState({
currentValue: v
});
this.props.onChangeValidate(this.arrayValues(v));
} else {
this.setState({currentValue: this.keyToCurrentValue(value.key, false)});
this.props.onChangeValidate({target: {value: value.key}});
}
}
onDeselect(value: KeyLabelItem, option) {
if (this.props.form.schema.type === 'array') {
const v = this.state.currentValue as KeyLabelItem[];
const index = this.keyIndex(v, value.key);
if (index > -1) {
v.splice(index, 1);
}
this.setState({
currentValue: v
});
this.props.onChangeValidate(this.arrayValues(v));
}
}
onBlur() {
this.setState({ focused: false });
}
onFocus() {
this.setState({ focused: true });
}
render() {
let options: JSX.Element[] = [];
if (this.state.items && this.state.items.length > 0) {
options = this.state.items.map((item, idx) => (
<Option key={idx} value={item.value}>{item.label}</Option>
));
}
let labelClass = 'tb-label';
if (this.props.form.required) {
labelClass += ' tb-required';
}
if (this.props.form.readonly) {
labelClass += ' tb-readonly';
}
if (this.state.focused) {
labelClass += ' tb-focused';
}
return (
<div className='tb-container'>
<label className={labelClass}>{this.props.form.title}</label>
<Select
className={this.props.form.className}
dropdownClassName={this.props.form.dropdownClassName}
dropdownStyle={{...this.props.form.dropdownStyle, ...{zIndex: 100001}}}
dropdownMenuStyle={this.props.form.dropdownMenuStyle}
allowClear={this.props.form.allowClear}
tags={this.props.form.tags}
maxTagTextLength={this.props.form.maxTagTextLength}
multiple={this.props.form.multiple}
combobox={this.props.form.combobox}
disabled={this.props.form.readonly}
optionLabelProp='children'
value={this.state.currentValue}
labelInValue={true}
onSelect={this.onSelect}
onDeselect={this.onDeselect}
onFocus={this.onFocus}
onBlur={this.onBlur}
style={this.props.form.style || {width: '100%'}}>
{options}
</Select>
<div className='json-form-error'
style={{opacity: this.props.valid ? '0' : '1',
bottom: '-5px'}}>{this.props.error}</div>
</div>
);
}
}
export default ThingsboardBaseComponent(ThingsboardRcSelect);

55
ui-ngx/src/app/shared/components/json-form/react/json-form-react.tsx

@ -0,0 +1,55 @@
/*
* 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 './json-form.scss';
import * as React from 'react';
import { createMuiTheme, ThemeProvider } from '@material-ui/core/styles';
import thingsboardTheme from './styles/thingsboardTheme';
import ThingsboardSchemaForm from './json-form-schema-form';
import { JsonFormProps } from './json-form.models';
const tbTheme = createMuiTheme(thingsboardTheme);
class ReactSchemaForm extends React.Component<JsonFormProps, {}> {
static defaultProps: JsonFormProps;
constructor(props) {
super(props);
}
render() {
if (this.props.form.length > 0) {
return <ThemeProvider theme={tbTheme}><ThingsboardSchemaForm {...this.props} /></ThemeProvider>;
} else {
return <div></div>;
}
}
}
ReactSchemaForm.defaultProps = {
isFullscreen: false,
schema: {},
form: ['*'],
groupInfoes: [],
option: {
formDefaults: {
startEmpty: true
}
}
};
export default ReactSchemaForm;

183
ui-ngx/src/app/shared/components/json-form/react/json-form-schema-form.tsx

@ -0,0 +1,183 @@
/*
* 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 * as React from 'react';
import JsonFormUtils from './json-form-utils';
/*import ThingsboardArray from './json-form-array.jsx';
import ThingsboardJavaScript from './json-form-javascript.jsx';
import ThingsboardJson from './json-form-json.jsx';
import ThingsboardHtml from './json-form-html.jsx';
import ThingsboardCss from './json-form-css.jsx';
import ThingsboardColor from './json-form-color.jsx'*/
import ThingsboardRcSelect from './json-form-rc-select';
import ThingsboardNumber from './json-form-number';
import ThingsboardText from './json-form-text';
import ThingsboardSelect from './json-form-select';
import ThingsboardRadios from './json-form-radios';
/*import ThingsboardDate from './json-form-date.jsx';
import ThingsboardImage from './json-form-image.jsx';*/
import ThingsboardCheckbox from './json-form-checkbox';
import ThingsboardHelp from './json-form-help';
/*import ThingsboardFieldSet from './json-form-fieldset.jsx';*/
import { JsonFormProps, GroupInfo, JsonFormData } from './json-form.models';
import _ from 'lodash';
class ThingsboardSchemaForm extends React.Component<JsonFormProps, any> {
private hasConditions: boolean;
private mapper: {[type: string]: any};
constructor(props) {
super(props);
this.mapper = {
number: ThingsboardNumber,
text: ThingsboardText,
password: ThingsboardText,
textarea: ThingsboardText,
select: ThingsboardSelect,
radios: ThingsboardRadios,
// date: ThingsboardDate,
// image: ThingsboardImage,
checkbox: ThingsboardCheckbox,
help: ThingsboardHelp,
// array: ThingsboardArray,
// javascript: ThingsboardJavaScript,
// json: ThingsboardJson,
// html: ThingsboardHtml,
// css: ThingsboardCss,
// color: ThingsboardColor,
'rc-select': ThingsboardRcSelect,
// fieldset: ThingsboardFieldSet
};
this.onChange = this.onChange.bind(this);
this.onColorClick = this.onColorClick.bind(this);
this.onToggleFullscreen = this.onToggleFullscreen.bind(this);
this.hasConditions = false;
}
onChange(key: string | string[], val: any) {
this.props.onModelChange(key, val);
if (this.hasConditions) {
this.forceUpdate();
}
}
onColorClick(event, key, val) {
this.props.onColorClick(event, key, val);
}
onToggleFullscreen() {
this.props.onToggleFullscreen();
}
builder(form: JsonFormData,
model: any,
index: number,
onChange: (key: string | string[], val: any) => void,
onColorClick: (event: MouseEvent, key: string, val: string) => void,
onToggleFullscreen: () => void,
mapper: {[type: string]: any}): JSX.Element {
const type = form.type;
const Field = this.mapper[type];
if (!Field) {
console.log('Invalid field: \"' + form.key[0] + '\"!');
return null;
}
if (form.condition) {
this.hasConditions = true;
// tslint:disable-next-line:no-eval
if (eval(form.condition) === false) {
return null;
}
}
return <Field model={model} form={form} key={index} onChange={onChange}
onColorClick={onColorClick}
onToggleFullscreen={onToggleFullscreen}
mapper={mapper} builder={this.builder}/>;
}
createSchema(theForm: any[]): JSX.Element {
const merged = JsonFormUtils.merge(this.props.schema, theForm, this.props.ignore, this.props.option);
let mapper = this.mapper;
if (this.props.mapper) {
mapper = _.merge(this.mapper, this.props.mapper);
}
const forms = merged.map(function(form, index) {
return this.builder(form, this.props.model, index, this.onChange, this.onColorClick, this.onToggleFullscreen, mapper);
}.bind(this));
let formClass = 'SchemaForm';
if (this.props.isFullscreen) {
formClass += ' SchemaFormFullscreen';
}
return (
<div style={{width: '100%'}} className={formClass}>{forms}</div>
);
}
render() {
if (this.props.groupInfoes && this.props.groupInfoes.length > 0) {
const content: JSX.Element[] = [];
for (const info of this.props.groupInfoes) {
const forms = this.createSchema(this.props.form[info.formIndex]);
const item = <ThingsboardSchemaGroup key={content.length} forms={forms} info={info}></ThingsboardSchemaGroup>;
content.push(item);
}
return (<div>{content}</div>);
} else {
return this.createSchema(this.props.form);
}
}
}
export default ThingsboardSchemaForm;
interface ThingsboardSchemaGroupProps {
info: GroupInfo;
forms: JSX.Element;
}
interface ThingsboardSchemaGroupState {
showGroup: boolean;
}
class ThingsboardSchemaGroup extends React.Component<ThingsboardSchemaGroupProps, ThingsboardSchemaGroupState> {
constructor(props) {
super(props);
this.state = {
showGroup: true
};
}
toogleGroup(index) {
this.setState({
showGroup: !this.state.showGroup
});
}
render() {
const theCla = 'pull-right fa fa-chevron-down md-toggle-icon' + (this.state.showGroup ? '' : ' tb-toggled');
return (<section className='mat-elevation-z1' style={{marginTop: '10px'}}>
<div className='SchemaGroupname md-button-toggle'
onClick={this.toogleGroup.bind(this)}>{this.props.info.GroupTitle}<span className={theCla}></span></div>
<div style={{padding: '20px'}} className={this.state.showGroup ? '' : 'invisible'}>{this.props.forms}</div>
</section>);
}
}

75
ui-ngx/src/app/shared/components/json-form/react/json-form-select.tsx

@ -0,0 +1,75 @@
import * as React from 'react';
import {
JsonFormFieldProps,
JsonFormFieldState,
KeyLabelItem
} from '@shared/components/json-form/react/json-form.models';
import MenuItem from '@material-ui/core/MenuItem';
import FormControl from '@material-ui/core/FormControl';
import InputLabel from '@material-ui/core/InputLabel';
import Select from '@material-ui/core/Select';
import ThingsboardBaseComponent from '@shared/components/json-form/react/json-form-base-component';
interface ThingsboardSelectState extends JsonFormFieldState {
currentValue: any;
}
class ThingsboardSelect extends React.Component<JsonFormFieldProps, ThingsboardSelectState> {
constructor(props) {
super(props);
this.onSelected = this.onSelected.bind(this);
const possibleValue = this.getModelKey(this.props.model, this.props.form.key);
this.state = {
currentValue: this.props.model !== undefined && possibleValue ? possibleValue : this.props.form.titleMap != null ?
this.props.form.titleMap[0].value : ''
};
}
componentWillReceiveProps(nextProps) {
if (nextProps.model && nextProps.form.key) {
this.setState({
currentValue: this.getModelKey(nextProps.model, nextProps.form.key)
|| (nextProps.form.titleMap != null ? nextProps.form.titleMap[0].value : '')
});
}
}
getModelKey(model, key) {
if (Array.isArray(key)) {
return key.reduce((cur, nxt) => (cur[nxt] || {}), model);
} else {
return model[key];
}
}
onSelected(event: React.ChangeEvent<{ name?: string; value: any }>) {
this.setState({
currentValue: event.target.value
});
this.props.onChangeValidate(event);
}
render() {
const menuItems = this.props.form.titleMap.map((item, idx) => (
<MenuItem key={idx}
value={item.value}>{item.name}</MenuItem>
));
return (
<FormControl className={this.props.form.htmlClass}
disabled={this.props.form.readonly}
fullWidth={true}>
<InputLabel htmlFor='select-field'>{this.props.form.title}</InputLabel>
<Select
value={this.state.currentValue}
onChange={this.onSelected}>
{menuItems}
</Select>
</FormControl>
);
}
}
export default ThingsboardBaseComponent(ThingsboardSelect);

84
ui-ngx/src/app/shared/components/json-form/react/json-form-text.tsx

@ -0,0 +1,84 @@
/*
* 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 * as React from 'react';
import ThingsboardBaseComponent from './json-form-base-component';
import TextField from '@material-ui/core/TextField';
import { JsonFormFieldProps, JsonFormFieldState } from '@shared/components/json-form/react/json-form.models';
import { FormControl } from '@material-ui/core';
interface ThingsboardTextState extends JsonFormFieldState {
focused: boolean;
}
class ThingsboardText extends React.Component<JsonFormFieldProps, ThingsboardTextState> {
constructor(props) {
super(props);
this.onBlur = this.onBlur.bind(this);
this.onFocus = this.onFocus.bind(this);
this.state = {
focused: false
};
}
onBlur() {
this.setState({focused: false});
}
onFocus() {
this.setState({focused: true});
}
render() {
let fieldClass = 'tb-field';
if (this.props.form.required) {
fieldClass += ' tb-required';
}
if (this.props.form.readonly) {
fieldClass += ' tb-readonly';
}
if (this.state.focused) {
fieldClass += ' tb-focused';
}
const multiline = this.props.form.type === 'textarea';
const rows = multiline ? this.props.form.rows : 1;
const rowsMax = multiline ? this.props.form.rowsMax : 1;
return (
<div>
<TextField
className={fieldClass}
type={this.props.form.type}
label={this.props.form.title}
multiline={multiline}
error={!this.props.valid}
helperText={this.props.valid ? this.props.form.placeholder : this.props.error}
onChange={this.props.onChangeValidate}
defaultValue={this.props.value}
disabled={this.props.form.readonly}
rows={rows}
rowsMax={rowsMax}
onFocus={this.onFocus}
onBlur={this.onBlur}
style={this.props.form.style || {width: '100%'}}/>
</div>
);
}
}
export default ThingsboardBaseComponent(ThingsboardText);

542
ui-ngx/src/app/shared/components/json-form/react/json-form-utils.ts

@ -0,0 +1,542 @@
///
/// 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 * as tv from 'tv4';
import ObjectPath from 'objectpath';
import _ from 'lodash';
import { SchemaValidationResult, DefaultsFormOptions, FormOption } from './json-form.models';
import { isDefined, isString, isUndefined } from '@core/utils';
import * as equal from 'deep-equal';
function validateBySchema(schema: any, value: any): SchemaValidationResult {
return tv.validateResult(value, schema);
}
function validate(form: any, value: any): SchemaValidationResult {
if (!form) {
return {valid: true};
}
const schema = form.schema;
if (!schema) {
return {valid: true};
}
if (value === '') {
value = undefined;
}
// Numbers fields will give a null value, which also means empty field
if (form.type === 'number' && value === null) {
value = undefined;
}
if (form.type === 'number' && isNaN(parseFloat(value))) {
value = undefined;
}
const wrap: any = {type: 'object', properties: {}};
const propName = form.key[form.key.length - 1];
wrap.properties[propName] = schema;
if (form.required) {
wrap.required = [propName];
}
const valueWrap = {};
if (typeof value !== 'undefined') {
valueWrap[propName] = value;
}
const tv4Result: SchemaValidationResult = tv.validateResult(valueWrap, wrap);
if (tv4Result != null && !tv4Result.valid && form.validationMessage != null && typeof value !== 'undefined') {
tv4Result.error.message = form.validationMessage;
}
return tv4Result;
}
function stripNullType(type: any): string {
if (Array.isArray(type) && type.length === 2) {
if (type[0] === 'null') {
return type[1];
}
if (type[1] === 'null') {
return type[0];
}
}
return type;
}
const enumToTitleMap = (enm: string[]): { name: string, value: string }[] => {
const titleMap: { name: string, value: string }[] = [];
enm.forEach((name) => {
titleMap.push({name, value: name});
});
return titleMap;
};
const canonicalTitleMap = (titleMap: any, originalEnum?: string[]): { name: string, value: string }[] => {
if (!_.isArray(titleMap)) {
const canonical: { name: string, value: string }[] = [];
if (originalEnum) {
originalEnum.forEach((value) => {
canonical.push({name: titleMap[value], value});
});
} else {
for (const k of Object.keys(titleMap)) {
if (titleMap.hasOwnProperty(k)) {
canonical.push({name: k, value: titleMap[k]});
}
}
}
return canonical;
}
return titleMap;
};
const stdFormObj = (name: string, schema: any, options: DefaultsFormOptions): any => {
options = options || {};
const f = options.global && options.global.formDefaults ? _.cloneDeep(options.global.formDefaults) : {};
if (options.global && options.global.supressPropertyTitles === true) {
f.title = schema.title;
} else {
f.title = schema.title || name;
}
if (schema.description) {
f.description = schema.description;
}
if (options.required === true || schema.required === true) {
f.required = true;
}
if (schema.maxLength) {
f.maxlength = schema.maxLength;
}
if (schema.minLength) {
f.minlength = schema.minLength;
}
if (schema.readOnly || schema.readonly) {
f.readonly = true;
}
if (schema.minimum) {
f.minimum = schema.minimum + (schema.exclusiveMinimum ? 1 : 0);
}
if (schema.maximum) {
f.maximum = schema.maximum - (schema.exclusiveMaximum ? 1 : 0);
}
// Non standard attributes (DONT USE DEPRECATED)
// If you must set stuff like this in the schema use the x-schema-form attribute
if (schema.validationMessage) {
f.validationMessage = schema.validationMessage;
}
if (schema.enumNames) {
f.titleMap = canonicalTitleMap(schema.enumNames, schema.enum);
}
f.schema = schema;
// Ng model options doesn't play nice with undefined, might be defined
// globally though
f.ngModelOptions = f.ngModelOptions || {};
return f;
};
const text = (name: string, schema: any, options: DefaultsFormOptions): any => {
if (stripNullType(schema.type) === 'string' && !schema.enum) {
const f = stdFormObj(name, schema, options);
f.key = options.path;
f.type = 'text';
options.lookup[ObjectPath.stringify(options.path)] = f;
return f;
}
};
const numberType = (name: string, schema: any, options: DefaultsFormOptions): any => {
if (stripNullType(schema.type) === 'number') {
const f = stdFormObj(name, schema, options);
f.key = options.path;
f.type = 'number';
options.lookup[ObjectPath.stringify(options.path)] = f;
return f;
}
};
const integer = (name: string, schema: any, options: DefaultsFormOptions): any => {
if (stripNullType(schema.type) === 'integer') {
const f = stdFormObj(name, schema, options);
f.key = options.path;
f.type = 'number';
options.lookup[ObjectPath.stringify(options.path)] = f;
return f;
}
};
const date = (name: string, schema: any, options: DefaultsFormOptions): any => {
if (stripNullType(schema.type) === 'date') {
const f = stdFormObj(name, schema, options);
f.key = options.path;
f.type = 'date';
options.lookup[ObjectPath.stringify(options.path)] = f;
return f;
}
};
const checkbox = (name: string, schema: any, options: DefaultsFormOptions): any => {
if (stripNullType(schema.type) === 'boolean') {
const f = stdFormObj(name, schema, options);
f.key = options.path;
f.type = 'checkbox';
options.lookup[ObjectPath.stringify(options.path)] = f;
return f;
}
};
const select = (name: string, schema: any, options: DefaultsFormOptions): any => {
if (stripNullType(schema.type) === 'string' && schema.enum) {
const f = stdFormObj(name, schema, options);
f.key = options.path;
f.type = 'select';
if (!f.titleMap) {
f.titleMap = enumToTitleMap(schema.enum);
}
options.lookup[ObjectPath.stringify(options.path)] = f;
return f;
}
};
const checkboxes = (name: string, schema: any, options: DefaultsFormOptions): any => {
if (stripNullType(schema.type) === 'array' && schema.items && schema.items.enum) {
const f = stdFormObj(name, schema, options);
f.key = options.path;
f.type = 'checkboxes';
if (!f.titleMap) {
f.titleMap = enumToTitleMap(schema.items.enum);
}
options.lookup[ObjectPath.stringify(options.path)] = f;
return f;
}
};
const fieldset = (name: string, schema: any, options: DefaultsFormOptions): any => {
if (stripNullType(schema.type) === 'object') {
const f = stdFormObj(name, schema, options);
f.type = 'fieldset';
f.items = [];
options.lookup[ObjectPath.stringify(options.path)] = f;
// recurse down into properties
for (const k of Object.keys(schema.properties)) {
if (schema.properties.hasOwnProperty(k)) {
const path = options.path.slice();
path.push(k);
if (options.ignore[ObjectPath.stringify(path)] !== true) {
const required = schema.required && schema.required.indexOf(k) !== -1;
const def = defaultFormDefinition(k, schema.properties[k], {
path,
required: required || false,
lookup: options.lookup,
ignore: options.ignore,
global: options.global
});
if (def) {
f.items.push(def);
}
}
}
}
return f;
}
};
const array = (name: string, schema: any, options: DefaultsFormOptions): any => {
if (stripNullType(schema.type) === 'array') {
const f = stdFormObj(name, schema, options);
f.type = 'array';
f.key = options.path;
options.lookup[ObjectPath.stringify(options.path)] = f;
// don't do anything if items is not defined.
if (typeof schema.items !== 'undefined') {
const required = schema.required && schema.required.indexOf(options.path[options.path.length - 1]) !== -1;
const arrPath = options.path.slice();
arrPath.push('');
const def = defaultFormDefinition(name, schema.items, {
path: arrPath,
required: required || false,
lookup: options.lookup,
ignore: options.ignore,
global: options.global
});
if (def) {
f.items = [def];
} else {
// This is the case that item only contains key value pair for rc-select multipel
f.items = schema.items;
}
}
return f;
}
};
const defaults: { [key: string]: ((name: string, schema: any, options: DefaultsFormOptions) => any)[] } = {
string: [select, text],
object: [fieldset],
number: [numberType],
integer: [integer],
boolean: [checkbox],
array: [checkboxes, array],
date: [date]
};
function defaultFormDefinition(name: string, schema: any, options: DefaultsFormOptions): any {
const rules = defaults[stripNullType(schema.type)];
if (rules) {
let def;
rules.forEach((rule) => {
def = rule(name, schema, options);
if (def) {
// Do we have form defaults in the schema under the x-schema-form-attribute?
if (def.schema['x-schema-form'] && _.isObject(def.schema['x-schema-form'])) {
def = _.extend(def, def.schema['x-schema-form']);
}
return def;
}
});
}
}
interface DefaultsFormData {
form: any[];
lookup: { [key: string]: any };
}
function getDefaults(schema: any, ignore: { [key: string]: boolean }, globalOptions: FormOption): DefaultsFormData {
const form = [];
const lookup: { [key: string]: any } = {};
ignore = ignore || {};
globalOptions = globalOptions || {};
if (stripNullType(schema.type) === 'object') {
for (const k of Object.keys(schema.properties)) {
if (schema.properties.hasOwnProperty(k)) {
if (ignore[k] !== true) {
const required = schema.required && schema.required.indexOf(k) !== -1;
const def = defaultFormDefinition(k, schema.properties[k], {
path: [k], // Path to this property in bracket notation.
lookup, // Extra map to register with. Optimization for merger.
ignore, // The ignore list of paths (sans root level name)
required, // Is it required? (v4 json schema style)
global: globalOptions // Global options, including form defaults
});
if (def) {
form.push(def);
}
}
}
}
} else {
throw new Error('Not implemented. Only type "object" allowed at root level of schema.');
}
return {form, lookup};
}
const postProcessFn = (form: any[]): any[] => {
return form;
};
function merge(schema: any, form: any[], ignore: { [key: string]: boolean }, options: FormOption, isReadonly?: boolean): any[] {
form = form || ['*'];
options = options || {};
isReadonly = isReadonly || schema.readonly || schema.readOnly;
const stdForm = getDefaults(schema, ignore, options);
const idx = form.indexOf('*');
if (idx !== -1) {
form = form.slice(0, idx).concat(stdForm.form).concat(form.slice(idx + 1));
}
const lookup = stdForm.lookup;
return postProcessFn(form.map((obj) => {
if (typeof obj === 'string') {
obj = {key: obj};
}
if (obj.key) {
if (typeof obj.key === 'string') {
obj.key = ObjectPath.parse(obj.key);
}
}
if (obj.titleMap) {
obj.titleMap = canonicalTitleMap(obj.titleMap);
}
if (obj.itemForm) {
obj.items = [];
const str: string = ObjectPath.stringify(obj.key);
const lookupForm = lookup[str];
lookupForm.items.forEach((item) => {
const o = _.cloneDeep(obj.itemForm);
o.key = item.key;
obj.items.push(o);
});
}
if (obj.key) {
const strid: string = ObjectPath.stringify(obj.key);
if (lookup[strid]) {
const schemaDefaults = lookup[strid];
for (const k of Object.keys(schemaDefaults)) {
if (schemaDefaults.hasOwnProperty(k)) {
if (obj[k] === undefined) {
obj[k] = schemaDefaults[k];
}
}
}
}
}
if (isReadonly === true) {
obj.readonly = true;
}
if (obj.items && obj.items.length > 0) {
obj.items = merge(schema, obj.items, ignore, options, obj.readonly);
}
if (obj.tabs) {
obj.tabs.forEach((tab) => {
tab.items = merge(schema, tab.items, ignore, options, obj.readonly);
});
}
if (obj.type === 'checkbox' && _.isUndefined(obj.schema.default)) {
obj.schema.default = false;
}
return obj;
}));
}
function selectOrSet(projection: string | string[], obj: any, valueToSet?: any): any {
const numRe = /^\d+$/;
if (!obj) {
obj = this;
}
const parts = typeof projection === 'string' ? ObjectPath.parse(projection) : projection;
if (typeof valueToSet !== 'undefined' && parts.length === 1) {
obj[parts[0]] = valueToSet;
return obj;
}
if (typeof valueToSet !== 'undefined' && typeof obj[parts[0]] === 'undefined') {
obj[parts[0]] = parts.length > 2 && numRe.test(parts[1]) ? [] : {};
}
let value = obj[parts[0]];
for (let i = 1; i < parts.length; i++) {
if (parts[i] === '') {
return undefined;
}
if (typeof valueToSet !== 'undefined') {
if (i === parts.length - 1) {
value[parts[i]] = valueToSet;
return valueToSet;
} else {
let tmp = value[parts[i]];
if (typeof tmp === 'undefined' || tmp === null) {
tmp = numRe.test(parts[i + 1]) ? [] : {};
value[parts[i]] = tmp;
}
value = tmp;
}
} else if (value) {
value = value[parts[i]];
}
}
return value;
}
function updateValue(projection: string | string[], obj: any, valueToSet: any): boolean {
const numRe = /^\d+$/;
if (!obj) {
obj = this;
}
if (!obj) {
return false;
}
const parts: string[] = isString(projection) ? ObjectPath.parse(projection) : projection;
if (parts.length === 1) {
return setValue(obj, parts[0], valueToSet);
}
if (isUndefined(obj[parts[0]])) {
obj[parts[0]] = parts.length > 2 && numRe.test(parts[1]) ? [] : {};
}
let value = obj[parts[0]];
for (let i = 1; i < parts.length; i++) {
if (parts[i] === '') {
return false;
}
if (i === parts.length - 1) {
return setValue(value, parts[i], valueToSet);
} else {
let tmp = value[parts[i]];
if (isUndefined(tmp) || tmp === null) {
tmp = numRe.test(parts[i + 1]) ? [] : {};
value[parts[i]] = tmp;
}
value = tmp;
}
}
return value;
}
function setValue(obj: any, key: string, val: any): boolean {
let changed = false;
if (obj) {
if (isUndefined(val)) {
if (isDefined(obj[key])) {
delete obj[key];
changed = true;
}
} else {
changed = !equal(obj[key], val);
obj[key] = val;
}
}
return changed;
}
const utils = {
validateBySchema,
validate,
merge,
updateValue,
selectOrSet
};
export default utils;

119
ui-ngx/src/app/shared/components/json-form/react/json-form.models.ts

@ -0,0 +1,119 @@
///
/// 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 { isUndefined, isDefined, isString } from '@app/core/utils';
import * as equal from 'deep-equal';
import ObjectPath from 'objectpath';
import * as React from 'react';
export interface SchemaValidationResult {
valid: boolean;
error?: {
message?: string;
}
}
export interface FormOption {
formDefaults?: {
startEmpty?: boolean;
readonly?: boolean;
};
supressPropertyTitles?: boolean;
}
export interface DefaultsFormOptions {
global?: FormOption;
required?: boolean;
path?: string[];
lookup?: {[key: string]: any};
ignore?: {[key: string]: boolean};
}
export interface GroupInfo {
formIndex: number;
GroupTitle: string;
}
export interface JsonFormProps {
model?: any;
schema?: any;
form?: any;
groupInfoes?: GroupInfo[];
isFullscreen: boolean;
ignore?: {[key: string]: boolean};
option: FormOption;
onModelChange?: (key: string | string[], val: any) => void;
onColorClick?: (event: MouseEvent, key: string, val: string) => void;
onToggleFullscreen?: () => void;
mapper?: {[type: string]: any};
}
export interface KeyLabelItem {
key: string;
label: string;
value?: string;
}
export interface JsonFormData {
type: string;
key: string | string[];
title: string;
readonly: boolean;
required: boolean;
default?: any;
condition?: string;
style?: any;
rows?: number;
rowsMax?: number;
placeholder?: string;
schema: {
type: string;
default: any;
};
titleMap: {
value: any;
name: string;
}[];
items?: Array<KeyLabelItem>;
tags?: any;
[key: string]: any;
}
export interface JsonFormFieldProps {
value: any;
model: any;
form: JsonFormData;
builder: (form: JsonFormData,
model: any,
index: number,
onChange: (key: string | string[], val: any) => void,
onColorClick: (event: MouseEvent, key: string, val: string) => void,
onToggleFullscreen: () => void,
mapper: {[type: string]: any}) => JSX.Element;
mapper?: {[type: string]: any};
onChange?: (key: string | string[], val: any) => void;
onColorClick?: (event: MouseEvent, key: string, val: string) => void;
onChangeValidate?: (e: any) => void;
onToggleFullscreen?: () => void;
valid?: boolean;
error?: string;
}
export interface JsonFormFieldState {
value?: any;
valid?: boolean;
error?: string;
}

157
ui-ngx/src/app/shared/components/json-form/react/json-form.scss

@ -0,0 +1,157 @@
/**
* 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 "~compass-sass-mixins/lib/compass";
$swift-ease-out-duration: .4s !default;
$swift-ease-out-timing-function: cubic-bezier(.25, .8, .25, 1) !default;
$input-label-float-offset: 6px !default;
$input-label-float-scale: .75 !default;
.tb-json-form {
&.tb-fullscreen {
[name="ReactSchemaForm"] {
.SchemaForm {
&.SchemaFormFullscreen {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
> div:not(.fullscreen-form-field) {
display: none !important;
}
> div.fullscreen-form-field {
position: relative;
width: 100%;
height: 100%;
}
}
}
> div {
> section {
margin: 0 !important;
box-shadow: none !important;
.SchemaGroupname {
display: none !important;
}
> div {
padding: 0 !important;
}
}
}
}
}
.json-form-error {
position: relative;
bottom: -5px;
font-size: 12px;
line-height: 12px;
color: rgb(244, 67, 54);
transition: all 450ms cubic-bezier(.23, 1, .32, 1) 0ms;
}
.tb-container {
position: relative;
padding: 10px 0;
margin-top: 32px;
}
.tb-field {
padding-bottom: 18px;
&.tb-required {
label::after {
font-size: 13px;
color: rgba(0, 0, 0, .54);
vertical-align: top;
content: " *";
}
}
&.tb-focused:not(.tb-readonly) {
label::after {
color: rgb(221, 44, 0);
}
}
}
.tb-date-field {
&.tb-required {
div > div:first-child::after {
font-size: 13px;
color: rgba(0, 0, 0, .54);
vertical-align: top;
content: " *";
}
}
&.tb-focused:not(.tb-readonly) {
div > div:first-child::after {
color: rgb(221, 44, 0);
}
}
}
label.tb-label {
position: absolute;
right: auto;
bottom: 100%;
left: 0;
color: rgba(0, 0, 0, .54);
transition: transform $swift-ease-out-timing-function $swift-ease-out-duration, width $swift-ease-out-timing-function $swift-ease-out-duration;
transform: translate3d(0, $input-label-float-offset, 0) scale($input-label-float-scale);
transform-origin: left top;
-webkit-font-smoothing: antialiased;
&.tb-focused {
color: rgb(96, 125, 139);
}
&.tb-required::after {
font-size: 13px;
color: rgba(0, 0, 0, .54);
vertical-align: top;
content: " *";
}
&.tb-focused:not(.tb-readonly)::after {
color: rgb(221, 44, 0);
}
}
.tb-head-label {
color: rgba(0, 0, 0, .54);
}
.SchemaGroupname {
padding: 10px 20px;
background-color: #f1f1f1;
}
.invisible {
display: none;
}
}

61
ui-ngx/src/app/shared/components/json-form/react/styles/thingsboardTheme.ts

@ -0,0 +1,61 @@
///
/// 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.
///
/*
* 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 indigo from '@material-ui/core/colors/indigo';
import deeepOrange from '@material-ui/core/colors/deepOrange';
import { ThemeOptions } from '@material-ui/core/styles/createMuiTheme';
import { PaletteOptions } from '@material-ui/core/styles/createPalette';
import { deepClone } from '@core/utils';
const PRIMARY_COLOR = '#305680';
const SECONDARY_COLOR = '#527dad';
const HUE3_COLOR = '#a7c1de';
const tbIndigo = deepClone(indigo);
tbIndigo['500'] = PRIMARY_COLOR;
tbIndigo['600'] = SECONDARY_COLOR;
tbIndigo['700'] = tbIndigo['500'];
tbIndigo.A100 = HUE3_COLOR;
const thingsboardPalette: PaletteOptions = {
primary: tbIndigo,
secondary: deeepOrange,
background: {
default: '#eee'
}
};
export default {
typography: {
fontFamily: 'Roboto, \'Helvetica Neue\', sans-serif'
},
palette: thingsboardPalette,
} as ThemeOptions;

15
ui-ngx/src/app/shared/components/json-object-edit.component.scss

@ -14,21 +14,6 @@
* limitations under the License.
*/
/**
* 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 {
position: relative;

265
ui-ngx/src/app/shared/components/mat-chip-draggable.directive.ts

@ -0,0 +1,265 @@
///
/// 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,
Directive,
ElementRef,
OnDestroy,
Renderer,
Renderer2,
HostListener,
QueryList,
Output,
EventEmitter
} from '@angular/core';
import { MatChip, MatChipList } from '@angular/material/chips';
import DragStartEvent = JQuery.DragStartEvent;
import Timeout = NodeJS.Timeout;
export interface MatChipDropEvent {
from: number;
to: number;
}
@Directive({
selector: 'mat-chip-list[tb-chip-draggable]',
})
export class MatChipDraggableDirective implements AfterViewInit {
@Output()
chipDrop = new EventEmitter<MatChipDropEvent>();
private draggableChips: Array<DraggableChip> = [];
constructor(private chipsList: MatChipList,
private elementRef: ElementRef<HTMLElement>) {
}
@HostListener('document:mouseup', ['$event'])
onDocumentMouseUp() {
this.draggableChips.forEach((draggableChip) => {
draggableChip.preventDrag = false;
});
}
ngAfterViewInit(): void {
this.configureDraggableChipList();
this.chipsList.chips.changes.subscribe(() => {
this.configureDraggableChipList();
});
}
private configureDraggableChipList() {
const toRemove: Array<DraggableChip> = [];
this.chipsList.chips.forEach((chip) => {
const found = this.draggableChips.find((draggableChip) => draggableChip.chip === chip);
if (!found) {
this.draggableChips.push(new DraggableChip(chip,
this.chipsList,
this.elementRef.nativeElement,
this.chipDrop));
}
}
);
this.draggableChips.forEach((draggableChip) => {
const found = this.chipsList.chips.find((chip) => chip === draggableChip.chip);
if (!found) {
toRemove.push(draggableChip);
}
});
toRemove.forEach((draggableChip) => {
const index = this.draggableChips.indexOf(draggableChip);
this.draggableChips.splice(index, 1);
});
}
}
const draggingClassName = 'dragging';
const droppingClassName = 'dropping';
const droppingBeforeClassName = 'dropping-before';
const droppingAfterClassName = 'dropping-after';
let globalDraggingChipListId = null;
class DraggableChip {
private chipElement: HTMLElement;
private handle: HTMLElement;
private dragging = false;
private counter = 0;
private dropPosition: 'after' | 'before';
private dropTimeout: Timeout;
public preventDrag = false;
private dropHandler = this.onDrop.bind(this);
private dragOverHandler = this.onDragOver.bind(this);
constructor(public chip: MatChip,
private chipsList: MatChipList,
private chipListElement: HTMLElement,
private chipDrop: EventEmitter<MatChipDropEvent>) {
this.chipElement = chip._elementRef.nativeElement;
this.chipElement.setAttribute('draggable', 'true');
this.handle = this.chipElement.getElementsByClassName('tb-chip-drag-handle')[0] as HTMLElement;
this.chipElement.addEventListener('mousedown', this.onMouseDown.bind(this));
this.chipElement.addEventListener('dragstart', this.onDragStart.bind(this));
this.chipElement.addEventListener('dragend', this.onDragEnd.bind(this));
this.chipElement.addEventListener('dragenter', this.onDragEnter.bind(this));
this.chipElement.addEventListener('dragleave', this.onDragLeave.bind(this));
}
private onMouseDown(event: MouseEvent) {
if (event.target !== this.handle) {
this.preventDrag = true;
}
}
private onDragStart(event: DragEvent) {
if (this.preventDrag) {
event.preventDefault();
} else {
this.dragging = true;
globalDraggingChipListId = this.chipListElement.id;
this.chipListElement.classList.add(draggingClassName);
this.chipElement.classList.add(draggingClassName);
event = (event as any).originalEvent || event;
const dataTransfer = event.dataTransfer;
dataTransfer.effectAllowed = 'copyMove';
dataTransfer.dropEffect = 'move';
dataTransfer.setData('text/plain', this.index() + '');
}
}
private onDragEnter(event: DragEvent) {
this.counter++;
if (this.dragging) {
return;
}
this.chipElement.removeEventListener('dragover', this.dragOverHandler);
this.chipElement.removeEventListener('drop', this.dropHandler);
this.chipElement.addEventListener('dragover', this.dragOverHandler);
this.chipElement.addEventListener('drop', this.dropHandler);
}
private onDragLeave(event: DragEvent) {
this.counter--;
if (this.counter <= 0) {
this.counter = 0;
this.chipElement.classList.remove(droppingClassName);
this.chipElement.classList.remove(droppingAfterClassName);
this.chipElement.classList.remove(droppingBeforeClassName);
}
}
private onDragEnd(event: DragEvent) {
this.dragging = false;
globalDraggingChipListId = null;
this.chipListElement.classList.remove(draggingClassName);
this.chipElement.classList.remove(draggingClassName);
}
private onDragOver(event: DragEvent) {
if (this.dragging) {
return;
}
event.preventDefault();
if (globalDraggingChipListId !== this.chipListElement.id) {
return;
}
const bounds = this.chipElement.getBoundingClientRect();
event = (event as any).originalEvent || event;
const props = {
width: bounds.right - bounds.left,
height: bounds.bottom - bounds.top,
x: event.clientX - bounds.left,
y: event.clientY - bounds.top,
};
const horizontalOffset = props.x;
const horizontalMidPoint = props.width / 2;
const verticalOffset = props.y;
const verticalMidPoint = props.height / 2;
this.chipElement.classList.add(droppingClassName);
this.chipElement.classList.remove(droppingAfterClassName);
this.chipElement.classList.remove(droppingBeforeClassName);
if (horizontalOffset >= horizontalMidPoint || verticalOffset >= verticalMidPoint) {
this.dropPosition = 'after';
this.chipElement.classList.add(droppingAfterClassName);
} else {
this.dropPosition = 'before';
this.chipElement.classList.add(droppingBeforeClassName);
}
}
private onDrop(event: DragEvent) {
this.counter = 0;
event.preventDefault();
if (globalDraggingChipListId !== this.chipListElement.id) {
return;
}
event = (event as any).originalEvent || event;
const droppedItemIndex = parseInt(event.dataTransfer.getData('text/plain'), 10);
const currentIndex = this.index();
let newIndex;
if (this.dropPosition === 'before') {
if (droppedItemIndex < currentIndex) {
newIndex = currentIndex - 1;
} else {
newIndex = currentIndex;
}
} else {
if (droppedItemIndex < currentIndex) {
newIndex = currentIndex;
} else {
newIndex = currentIndex + 1;
}
}
if (this.dropTimeout) {
clearTimeout(this.dropTimeout);
}
this.dropTimeout = setTimeout(() => {
this.dropPosition = null;
this.chipElement.classList.remove(droppingClassName);
this.chipElement.classList.remove(droppingAfterClassName);
this.chipElement.classList.remove(droppingBeforeClassName);
this.chipElement.removeEventListener('drop', this.dropHandler);
const dropEvent: MatChipDropEvent = {
from: droppedItemIndex,
to: newIndex
};
this.chipDrop.emit(dropEvent);
}, 1000 / 16);
}
private index(): number {
return this.chipsList.chips.toArray().indexOf(this.chip);
}
}

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

@ -58,6 +58,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { ShareModule as ShareButtonsModule } from '@ngx-share/core';
import { HotkeyModule } from 'angular2-hotkeys';
import { ColorPickerModule } from 'ngx-color-picker';
import { UserMenuComponent } from '@shared/components/user-menu.component';
import { NospacePipe } from './pipe/nospace.pipe';
import { TranslateModule } from '@ngx-translate/core';
@ -100,6 +101,11 @@ import { TbErrorComponent } from './components/tb-error.component';
import { EntityTypeListComponent } from './components/entity/entity-type-list.component';
import { EntitySubTypeListComponent } from './components/entity/entity-subtype-list.component';
import { TruncatePipe } from './pipe/truncate.pipe';
import { ColorPickerDialogComponent } from './components/dialog/color-picker-dialog.component';
import { MatChipDraggableDirective } from './components/mat-chip-draggable.directive';
import { ColorInputComponent } from './components/color-input.component';
import { JsFuncComponent } from './components/js-func.component';
import { JsonFormComponent } from './components/json-form/json-form.component';
@NgModule({
providers: [
@ -114,7 +120,8 @@ import { TruncatePipe } from './pipe/truncate.pipe';
TbAnchorComponent,
TimewindowPanelComponent,
DashboardSelectPanelComponent,
MatSpinner
MatSpinner,
ColorPickerDialogComponent
],
declarations: [
FooterComponent,
@ -123,6 +130,7 @@ import { TruncatePipe } from './pipe/truncate.pipe';
ToastDirective,
FullscreenDirective,
CircularProgressDirective,
MatChipDraggableDirective,
TbAnchorComponent,
HelpComponent,
TbCheckboxComponent,
@ -152,10 +160,14 @@ import { TruncatePipe } from './pipe/truncate.pipe';
RelationTypeAutocompleteComponent,
SocialSharePanelComponent,
JsonObjectEditComponent,
JsFuncComponent,
FabTriggerDirective,
FabActionsDirective,
FabToolbarComponent,
WidgetsBundleSelectComponent,
ColorPickerDialogComponent,
ColorInputComponent,
JsonFormComponent,
NospacePipe,
MillisecondsToTimeStringPipe,
EnumToArrayPipe,
@ -204,7 +216,8 @@ import { TruncatePipe } from './pipe/truncate.pipe';
ReactiveFormsModule,
OverlayModule,
ShareButtonsModule,
HotkeyModule
HotkeyModule,
ColorPickerModule
],
exports: [
FooterComponent,
@ -213,6 +226,7 @@ import { TruncatePipe } from './pipe/truncate.pipe';
ToastDirective,
FullscreenDirective,
CircularProgressDirective,
MatChipDraggableDirective,
TbAnchorComponent,
HelpComponent,
TbCheckboxComponent,
@ -239,6 +253,7 @@ import { TruncatePipe } from './pipe/truncate.pipe';
RelationTypeAutocompleteComponent,
SocialSharePanelComponent,
JsonObjectEditComponent,
JsFuncComponent,
FabTriggerDirective,
FabActionsDirective,
FabToolbarComponent,
@ -282,6 +297,10 @@ import { TruncatePipe } from './pipe/truncate.pipe';
OverlayModule,
ShareButtonsModule,
HotkeyModule,
ColorPickerModule,
ColorPickerDialogComponent,
ColorInputComponent,
JsonFormComponent,
NospacePipe,
MillisecondsToTimeStringPipe,
EnumToArrayPipe,

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

@ -23,6 +23,7 @@
"no": "No",
"update": "Update",
"remove": "Remove",
"select": "Select",
"search": "Search",
"clear-search": "Clear search",
"assign": "Assign",

41
ui-ngx/src/scss/mixins.scss

@ -0,0 +1,41 @@
/**
* 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.
*/
@mixin tb-mat-icon-size($size) {
width: #{$size}px;
min-width: #{$size}px;
height: #{$size}px;
min-height: #{$size}px;
font-size: #{$size}px;
line-height: #{$size}px;
svg {
width: 24px;
height: 24px;
transform: scale($size/24);
transform-origin: top;
}
}
@mixin tb-mat-icon-button-size($size) {
width: #{$size}px;
height: #{$size}px;
line-height: #{$size}px;
padding: 0 !important;
.mat-icon {
display: block;
margin: auto;
}
}

452
ui-ngx/src/styles.scss

@ -21,6 +21,7 @@
@import 'theme.scss';
@import './scss/constants';
@import './scss/animations';
@import './scss/mixins';
body, html {
height: 100%;
@ -401,3 +402,454 @@ mat-label {
}
}
}
.tb-default, .tb-dark {
/*********************************
* MATERIAL DESIGN CUSTOMIZATIONS
********************************/
.mat-tooltip {
white-space: pre-line;
}
button {
pointer-events: all;
}
button:not(.mat-menu-item):not(.mat-sort-header-button) {
text-transform: uppercase;
}
button.mat-menu-item {
font-size: 15px;
}
button.mat-fab.mat-fab-bottom-right {
top: auto;
right: 20px;
bottom: 20px;
left: auto;
position: absolute;
}
.layout-padding, .layout-padding > * {
@media #{$mat-lt-md} {
padding: 4px;
}
@media #{$mat-gt-sm} {
padding: 8px;
}
}
.mat-padding {
padding: 8px;
@media #{$mat-gt-sm} {
padding: 16px;
}
}
.mat-content {
position: relative;
overflow: auto;
}
.layout-wrap {
flex-wrap: wrap;
}
mat-form-field.mat-block {
display: block;
}
button.mat-menu-item {
overflow: hidden;
fill: #737373;
display: block;
.tb-alt-text {
float: right;
}
}
mat-toolbar.mat-table-toolbar {
background: #fff;
padding: 0 24px;
.mat-toolbar-tools {
padding: 0;
& > button.mat-icon-button:last-child {
margin-right: -12px;
}
}
}
mat-toolbar.mat-table-toolbar, .mat-cell {
button.mat-icon-button {
mat-icon {
color: rgba(0, 0, 0, .54);
}
}
}
.mat-cell {
mat-icon {
color: rgba(0, 0, 0, .54);
}
}
mat-toolbar.mat-primary {
button.mat-icon-button {
mat-icon {
color: white;
}
}
}
.mat-row {
transition: background-color .2s;
&:hover:not(.tb-current-entity) {
background-color: #f4f4f4;
}
&.tb-current-entity {
background-color: #e9e9e9;
}
}
.mat-row:not(.mat-row-select), .mat-header-row:not(.mat-row-select) {
mat-cell:first-child, mat-footer-cell:first-child, mat-header-cell:first-child {
padding: 0 12px;
}
mat-cell:nth-child(n+2):nth-last-child(n+2), mat-footer-cell:nth-child(n+2):nth-last-child(n+2), mat-header-cell:nth-child(n+2):nth-last-child(n+2) {
padding: 0 28px 0 0;
}
}
.mat-row.mat-row-select, .mat-header-row.mat-row-select {
mat-cell:first-child, mat-footer-cell:first-child, mat-header-cell:first-child {
width: 30px;
padding: 0 0 0 12px;
}
mat-cell:nth-child(2), mat-footer-cell:nth-child(2), mat-header-cell:nth-child(2) {
padding: 0 12px;
}
mat-cell:nth-child(n+3):nth-last-child(n+2), mat-footer-cell:nth-child(n+3):nth-last-child(n+2), mat-header-cell:nth-child(n+3):nth-last-child(n+2) {
padding: 0 28px 0 0;
}
&.mat-selected:not(.tb-current-entity) {
background-color: #ededed;
}
}
.mat-header-cell {
white-space: nowrap;
}
.mat-cell, .mat-header-cell {
min-width: 40px;
&:last-child {
padding: 0 12px 0 0;
}
&.mat-column-select {
min-width: 30px;
max-width: 30px;
width: 30px;
padding: 0 0 0 12px;
}
&.mat-column-actions {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.mat-cell, .mat-footer-cell {
font-size: 13px;
}
.mat-cell, .mat-footer-cell, .mat-header-cell {
// fix for ie11 'align-items: center'
height: 20px;
}
.mat-toolbar-tools {
font-size: 20px;
letter-spacing: .005em;
box-sizing: border-box;
font-weight: 400;
display: flex;
align-items: center;
flex-direction: row;
width: 100%;
height: 64px;
max-height: 64px;
padding: 0 16px;
margin: 0;
}
.mat-icon {
vertical-align: middle;
&.tb-mat-16 {
@include tb-mat-icon-size(16);
}
&.tb-mat-20 {
@include tb-mat-icon-size(20);
}
&.tb-mat-32 {
@include tb-mat-icon-size(32);
}
&.tb-mat-96 {
@include tb-mat-icon-size(96);
}
}
.mat-icon-button {
&.tb-mat-32 {
@include tb-mat-icon-button-size(32);
}
&.tb-mat-96 {
@include tb-mat-icon-button-size(96);
}
}
.mat-snack-bar-container {
position: absolute;
background: none;
box-shadow: none;
margin: 0;
padding: 0;
border: none;
border-radius: inherit;
max-width: inherit;
min-width: inherit;
pointer-events: none;
display: flex;
}
.mat-snack-bar-handset {
.mat-snack-bar-container {
position: relative !important;
width: 100% !important;
top: 0 !important;
left: 0 !important;
height: inherit !important;
tb-snack-bar-component {
width: 100%;
}
}
}
.mat-drawer-side {
border: none;
}
.mat-drawer-inner-container {
display: flex;
flex-direction: column;
overflow: hidden;
}
mat-drawer.tb-details-drawer {
z-index: 59 !important;
width: 100% !important;
max-width: 100% !important;
@media #{$mat-gt-sm} {
width: 80% !important;
}
@media #{$mat-gt-md} {
width: 65% !important;
}
}
.mat-card-subtitle, .mat-card-content {
font-size: 16px;
}
.mat-toolbar > button:first-child {
margin-left: -8px;
}
.mat-toolbar > button:last-child {
margin-right: -8px;
}
.mat-toolbar {
line-height: normal;
}
mat-toolbar *, mat-toolbar :after, mat-toolbar :before {
box-sizing: border-box;
}
.mat-button, .mat-flat-button, .mat-stroked-button, .mat-raised-button {
&:not(.mat-icon-button) {
@media #{$mat-lt-md} {
padding: 0 6px;
min-width: 88px;
}
mat-icon {
margin-right: 5px;
}
}
}
.tb-dialog {
.mat-dialog-container {
padding: 0;
> *:first-child, form {
max-width: 100%;
min-width: 100%;
display: flex;
flex-direction: column;
overflow: auto;
height: 100%;
}
.mat-dialog-content {
margin: 0;
padding: 24px;
}
.mat-dialog-actions {
margin-bottom: 0;
padding: 8px 8px 8px 16px;
}
}
}
.tb-fullscreen-dialog-gt-sm {
@media #{$mat-gt-sm} {
min-height: 100%;
min-width: 100%;
max-width: none !important;
position: absolute !important;
top: 0;
bottom: 0;
left: 0;
right: 0;
.mat-dialog-container {
> *:first-child, form {
min-width: 100% !important;
}
.mat-dialog-content {
max-height: 100%;
}
}
}
}
.tb-fullscreen-dialog {
@media #{$mat-lt-md} {
min-height: 100%;
min-width: 100%;
max-width: none !important;
position: absolute !important;
top: 0;
bottom: 0;
left: 0;
right: 0;
.mat-dialog-container {
> *:first-child, form {
min-width: 100% !important;
}
.mat-dialog-content {
max-height: 100%;
}
}
}
}
.tb-absolute-fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.tb-progress-cover {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 6;
background-color: #eee;
opacity: 1;
}
.mat-button.tb-fullscreen-button-style,
.tb-fullscreen-button-style {
background: #ccc;
opacity: .85;
mat-icon {
color: #666;
}
}
span.no-data-found {
position: relative;
display: flex;
height: calc(100% - 57px);
text-transform: uppercase;
}
mat-tab-group.tb-headless {
margin-top: -50px;
}
.mat-tab-label {
text-transform: uppercase;
}
.tb-primary-background {
background-color: $primary;
}
.mat-chip-list.dragging {
.mat-chip {
&.mat-standard-chip {
&::after {
transition: none;
}
&.dropping {
//border: dashed 2px;
//opacity: .5;
//.md-chip-content {
// margin: -2px;
//}
}
&.dropping-before {
&::after {
position: absolute;
top: 0;
right: 50%;
bottom: 0;
left: 0;
content: "";
background-color: #fff;
border: dashed 2px;
border-radius: 16px;
opacity: .7;
}
}
&.dropping-after {
&::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 50%;
content: "";
background-color: #fff;
border: dashed 2px;
border-radius: 16px;
opacity: .7;
}
}
}
}
}
}

429
ui-ngx/src/theme.scss

@ -217,432 +217,3 @@ $tb-dark-theme: get-tb-dark-theme(
.tb-dark {
@include angular-material-theme($tb-dark-theme);
}
.tb-default, .tb-dark {
/*********************************
* MATERIAL DESIGN CUSTOMIZATIONS
********************************/
.mat-tooltip {
white-space: pre-line;
}
button {
pointer-events: all;
}
button:not(.mat-menu-item):not(.mat-sort-header-button) {
text-transform: uppercase;
}
button.mat-menu-item {
font-size: 15px;
}
button.mat-fab.mat-fab-bottom-right {
top: auto;
right: 20px;
bottom: 20px;
left: auto;
position: absolute;
}
.layout-padding, .layout-padding > * {
@media #{$mat-lt-md} {
padding: 4px;
}
@media #{$mat-gt-sm} {
padding: 8px;
}
}
.mat-padding {
padding: 8px;
@media #{$mat-gt-sm} {
padding: 16px;
}
}
.mat-content {
position: relative;
overflow: auto;
}
.layout-wrap {
flex-wrap: wrap;
}
mat-form-field.mat-block {
display: block;
}
button.mat-menu-item {
overflow: hidden;
fill: #737373;
display: block;
.tb-alt-text {
float: right;
}
}
mat-toolbar.mat-table-toolbar {
background: #fff;
padding: 0 24px;
.mat-toolbar-tools {
padding: 0;
& > button.mat-icon-button:last-child {
margin-right: -12px;
}
}
}
mat-toolbar.mat-table-toolbar, .mat-cell {
button.mat-icon-button {
mat-icon {
color: rgba(0, 0, 0, .54);
}
}
}
.mat-cell {
mat-icon {
color: rgba(0, 0, 0, .54);
}
}
mat-toolbar.mat-primary {
button.mat-icon-button {
mat-icon {
color: white;
}
}
}
.mat-row {
transition: background-color .2s;
&:hover:not(.tb-current-entity) {
background-color: #f4f4f4;
}
&.tb-current-entity {
background-color: #e9e9e9;
}
}
.mat-row:not(.mat-row-select), .mat-header-row:not(.mat-row-select) {
mat-cell:first-child, mat-footer-cell:first-child, mat-header-cell:first-child {
padding: 0 12px;
}
mat-cell:nth-child(n+2):nth-last-child(n+2), mat-footer-cell:nth-child(n+2):nth-last-child(n+2), mat-header-cell:nth-child(n+2):nth-last-child(n+2) {
padding: 0 28px 0 0;
}
}
.mat-row.mat-row-select, .mat-header-row.mat-row-select {
mat-cell:first-child, mat-footer-cell:first-child, mat-header-cell:first-child {
width: 30px;
padding: 0 0 0 12px;
}
mat-cell:nth-child(2), mat-footer-cell:nth-child(2), mat-header-cell:nth-child(2) {
padding: 0 12px;
}
mat-cell:nth-child(n+3):nth-last-child(n+2), mat-footer-cell:nth-child(n+3):nth-last-child(n+2), mat-header-cell:nth-child(n+3):nth-last-child(n+2) {
padding: 0 28px 0 0;
}
&.mat-selected:not(.tb-current-entity) {
background-color: #ededed;
}
}
.mat-header-cell {
white-space: nowrap;
}
.mat-cell, .mat-header-cell {
min-width: 40px;
&:last-child {
padding: 0 12px 0 0;
}
&.mat-column-select {
min-width: 30px;
max-width: 30px;
width: 30px;
padding: 0 0 0 12px;
}
&.mat-column-actions {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.mat-cell, .mat-footer-cell {
font-size: 13px;
}
.mat-cell, .mat-footer-cell, .mat-header-cell {
// fix for ie11 'align-items: center'
height: 20px;
}
.mat-toolbar-tools {
font-size: 20px;
letter-spacing: .005em;
box-sizing: border-box;
font-weight: 400;
display: flex;
align-items: center;
flex-direction: row;
width: 100%;
height: 64px;
max-height: 64px;
padding: 0 16px;
margin: 0;
}
.mat-icon {
vertical-align: middle;
&.tb-mat-20 {
width: 20px;
height: 20px;
font-size: 20px;
svg {
width: 24px;
height: 24px;
transform: scale(0.83);
}
}
&.tb-mat-32 {
width: 32px;
height: 32px;
font-size: 32px;
svg {
width: 24px;
height: 24px;
transform: scale(1.33);
}
}
&.tb-mat-96 {
width: 96px;
height: 96px;
font-size: 96px;
svg {
width: 24px;
height: 24px;
transform: scale(4);
}
}
}
.mat-icon-button {
&.tb-mat-32 {
width: 32px;
height: 32px;
line-height: 32px;
padding: 0 !important;
}
&.tb-mat-96 {
width: 96px;
height: 96px;
line-height: 96px;
padding: 0 !important;
}
}
.mat-snack-bar-container {
position: absolute;
background: none;
box-shadow: none;
margin: 0;
padding: 0;
border: none;
border-radius: inherit;
max-width: inherit;
min-width: inherit;
pointer-events: none;
display: flex;
}
.mat-snack-bar-handset {
.mat-snack-bar-container {
position: relative !important;
width: 100% !important;
top: 0 !important;
left: 0 !important;
height: inherit !important;
tb-snack-bar-component {
width: 100%;
}
}
}
.mat-drawer-side {
border: none;
}
.mat-drawer-inner-container {
display: flex;
flex-direction: column;
overflow: hidden;
}
mat-drawer.tb-details-drawer {
z-index: 59 !important;
width: 100% !important;
max-width: 100% !important;
@media #{$mat-gt-sm} {
width: 80% !important;
}
@media #{$mat-gt-md} {
width: 65% !important;
}
}
.mat-card-subtitle, .mat-card-content {
font-size: 16px;
}
.mat-toolbar > button:first-child {
margin-left: -8px;
}
.mat-toolbar > button:last-child {
margin-right: -8px;
}
.mat-toolbar {
line-height: normal;
}
mat-toolbar *, mat-toolbar :after, mat-toolbar :before {
box-sizing: border-box;
}
.mat-button, .mat-flat-button, .mat-stroked-button, .mat-raised-button {
&:not(.mat-icon-button) {
@media #{$mat-lt-md} {
padding: 0 6px;
min-width: 88px;
}
mat-icon {
margin-right: 5px;
}
}
}
.tb-dialog {
.mat-dialog-container {
padding: 0;
> *:first-child, form {
max-width: 100%;
min-width: 100%;
display: flex;
flex-direction: column;
overflow: auto;
height: 100%;
}
.mat-dialog-content {
margin: 0;
padding: 24px;
}
.mat-dialog-actions {
margin-bottom: 0;
padding: 8px 8px 8px 16px;
}
}
}
.tb-fullscreen-dialog-gt-sm {
@media #{$mat-gt-sm} {
min-height: 100%;
min-width: 100%;
max-width: none !important;
position: absolute !important;
top: 0;
bottom: 0;
left: 0;
right: 0;
.mat-dialog-container {
> *:first-child, form {
min-width: 100% !important;
}
.mat-dialog-content {
max-height: 100%;
}
}
}
}
.tb-fullscreen-dialog {
@media #{$mat-lt-md} {
min-height: 100%;
min-width: 100%;
max-width: none !important;
position: absolute !important;
top: 0;
bottom: 0;
left: 0;
right: 0;
.mat-dialog-container {
> *:first-child, form {
min-width: 100% !important;
}
.mat-dialog-content {
max-height: 100%;
}
}
}
}
.tb-absolute-fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.tb-progress-cover {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 6;
background-color: #eee;
opacity: 1;
}
.mat-button.tb-fullscreen-button-style,
.tb-fullscreen-button-style {
background: #ccc;
opacity: .85;
mat-icon {
color: #666;
}
}
span.no-data-found {
position: relative;
display: flex;
height: calc(100% - 57px);
text-transform: uppercase;
}
mat-tab-group.tb-headless {
margin-top: -50px;
}
.mat-tab-label {
text-transform: uppercase;
}
.tb-primary-background {
background-color: $primary;
}
}

2
ui-ngx/src/tsconfig.app.json

@ -2,7 +2,7 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/app",
"types": ["node", "jquery", "flot", "tinycolor2", "js-beautify"]
"types": ["node", "jquery", "flot", "tinycolor2", "js-beautify", "react", "react-dom"]
},
"exclude": [
"test.ts",

2
ui-ngx/tsconfig.json

@ -9,8 +9,10 @@
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"importHelpers": true,
"target": "es5",
"jsx": "react",
"typeRoots": [
"node_modules/@types",
"src/typings/jquery.typings.d.ts",

Loading…
Cancel
Save