Browse Source

UI: Implement thresholds config for Time series chart.

pull/10315/head
Igor Kulikov 2 years ago
parent
commit
b91c1ba288
  1. 10
      ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html
  2. 146
      ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.html
  3. 26
      ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.scss
  4. 235
      ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.ts
  5. 95
      ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts
  6. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-bar-settings.component.html
  7. 2
      ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.html
  8. 4
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.html
  9. 107
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.html
  10. 64
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.scss
  11. 261
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.ts
  12. 121
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component.html
  13. 47
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component.scss
  14. 139
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component.ts
  15. 47
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.html
  16. 47
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.scss
  17. 213
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.ts
  18. 152
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.html
  19. 46
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.scss
  20. 398
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.ts
  21. 49
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.html
  22. 20
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.scss
  23. 156
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.ts
  24. 25
      ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts
  25. 31
      ui-ngx/src/assets/locale/locale.constant-en_US.json

10
ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html

@ -39,9 +39,13 @@
[entityAliasId]="datasource?.entityAliasId"
formControlName="series">
</tb-data-keys-panel>
<div class="tb-form-panel">
<div class="tb-form-panel-title">TODO: Thresholds</div>
</div>
<tb-time-series-chart-thresholds-panel
formControlName="thresholds"
[aliasController]="aliasController"
[dataKeyCallbacks]="callbacks"
[datasource]="datasource"
[widgetConfig]="widgetConfig?.config">
</tb-time-series-chart-thresholds-panel>
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widget-config.appearance</div>
<div class="tb-form-row column-xs">

146
ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.html

@ -22,123 +22,19 @@
<mat-option [value]="true">{{ 'datakey.latest' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="tb-inline-field tb-key-field" subscriptSizing="dynamic">
<mat-chip-grid #chipList [formControl]="keysFormControl">
<mat-chip-row class="tb-datakey-chip" *ngIf="modelValue?.type"
(removed)="removeKey()">
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="4px" class="tb-attribute-chip">
<div class="tb-chip-labels">
<div class="tb-chip-label tb-chip-text tb-chip-icon">
<ng-container *ngIf="isEntityDatasource">
<mat-icon class="tb-mat-18 tb-datakey-icon" *ngIf="modelValue.type === dataKeyTypes.alarm"
matTooltip="{{'datakey.alarm' | translate }}"
matTooltipPosition="above">notifications</mat-icon>
<mat-icon class="tb-mat-18 tb-datakey-icon" *ngIf="modelValue.type === dataKeyTypes.attribute"
matTooltip="{{'datakey.attributes' | translate }}"
matTooltipPosition="above" svgIcon="mdi:alpha-a-circle-outline"></mat-icon>
<mat-icon class="tb-mat-18 tb-datakey-icon" *ngIf="modelValue.type === dataKeyTypes.entityField"
matTooltip="{{'datakey.entity-field' | translate }}"
matTooltipPosition="above" svgIcon="mdi:alpha-e-circle-outline"></mat-icon>
<mat-icon class="tb-mat-18 tb-datakey-icon" *ngIf="modelValue.type === dataKeyTypes.timeseries"
matTooltip="{{'datakey.timeseries' | translate }}"
matTooltipPosition="above">timeline</mat-icon>
</ng-container>
</div>
<div class="tb-chip-label tb-chip-text">
<strong>
<ng-container *ngTemplateOutlet="keyName"></ng-container>
</strong>
</div>
</div>
<button type="button"
(click)="editKey()" mat-icon-button class="tb-mat-24">
<mat-icon class="tb-mat-18">edit</mat-icon>
</button>
<button matChipRemove
type="button"
mat-icon-button class="tb-mat-24">
<mat-icon class="tb-mat-18">close</mat-icon>
</button>
</div>
</mat-chip-row>
<input matInput
type="text"
placeholder="{{ 'widget-config.set' | translate }}"
#keyInput
[formControl]="keyFormControl"
matAutocompleteOrigin
[fxHide]="!!modelValue?.type"
[readonly]="!!modelValue?.type"
#origin="matAutocompleteOrigin"
[matAutocompleteConnectedTo]="origin"
(focusin)="onKeyInputFocus()"
(drop)="$event.preventDefault();"
[matAutocomplete]="keyAutocomplete"
[matChipInputFor]="chipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
(matChipInputTokenEnd)="addKey($event)"
/>
</mat-chip-grid>
<mat-autocomplete #keyAutocomplete="matAutocomplete"
class="tb-autocomplete"
panelWidth="fit-content"
[displayWith]="displayKeyFn">
<mat-option *ngFor="let key of filteredKeys | async" [value]="key">
<span style="white-space: nowrap;">
<ng-container *ngIf="isEntityDatasource">
<mat-icon class="tb-datakey-icon" *ngIf="key.type === dataKeyTypes.alarm"
matTooltip="{{'datakey.alarm' | translate }}"
matTooltipPosition="above">notifications</mat-icon>
<mat-icon class="tb-datakey-icon" *ngIf="key.type === dataKeyTypes.attribute"
matTooltip="{{'datakey.attributes' | translate }}"
matTooltipPosition="above" svgIcon="mdi:alpha-a-circle-outline"></mat-icon>
<mat-icon class="tb-datakey-icon" *ngIf="key.type === dataKeyTypes.entityField"
matTooltip="{{'datakey.entity-field' | translate }}"
matTooltipPosition="above" svgIcon="mdi:alpha-e-circle-outline"></mat-icon>
<mat-icon class="tb-datakey-icon" *ngIf="key.type === dataKeyTypes.timeseries"
matTooltip="{{'datakey.timeseries' | translate }}"
matTooltipPosition="above">timeline</mat-icon>
</ng-container>
<span [innerHTML]="key.name | highlight:keySearchText"></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(keySearchText); else searchNotEmpty">
<span translate>entity.no-keys-found</span>
</div>
<ng-template #searchNotEmpty>
<span>
{{ translate.get('entity.no-key-matching',
{key: truncate.transform(keySearchText, true, 6, &apos;...&apos;)}) | async }}
</span>
<span *ngIf="!isEntityDatasource; else createEntityKey">
<a translate (click)="createKey(keySearchText)">entity.create-new-key</a>
</span>
<ng-template #createEntityKey>
<span>{{'entity.create-new-key' | translate }} </span>
<mat-icon class="tb-datakey-icon new-key" *ngIf="widgetType === widgetTypes.alarm"
matTooltip="{{'datakey.alarm' | translate }}"
matTooltipPosition="above"
(click)="createKey(keySearchText, dataKeyTypes.alarm)">notifications</mat-icon>
<mat-icon class="tb-datakey-icon new-key" *ngIf="widgetType === widgetTypes.latest || widgetType === widgetTypes.alarm"
matTooltip="{{'datakey.attributes' | translate }}"
matTooltipPosition="above" svgIcon="mdi:alpha-a-circle-outline"
(click)="createKey(keySearchText, dataKeyTypes.attribute)"></mat-icon>
<mat-icon class="tb-datakey-icon new-key" *ngIf="widgetType === widgetTypes.latest || widgetType === widgetTypes.alarm"
matTooltip="{{'datakey.entity-field' | translate }}"
matTooltipPosition="above" svgIcon="mdi:alpha-e-circle-outline"
(click)="createKey(keySearchText, dataKeyTypes.entityField)"></mat-icon>
<mat-icon class="tb-datakey-icon new-key"
matTooltip="{{'datakey.timeseries' | translate }}"
matTooltipPosition="above"
(click)="createKey(keySearchText, dataKeyTypes.timeseries)">timeline</mat-icon>
</ng-template>
</ng-template>
</div>
</mat-option>
</mat-autocomplete>
</mat-form-field>
<tb-data-key-input
[required]="required"
[widgetType]="widgetType"
[datasourceType]="datasourceType"
[entityAliasId]="entityAliasId"
[deviceId]="deviceId"
[dataKeyType]="dataKeyType"
[isLatestDataKeys]="isLatestDataKeys"
[callbacks]="callbacks"
[generateKey]="generateDataKey"
(keyEdit)="editKey()"
[formControl]="keyFormControl">
</tb-data-key-input>
<mat-form-field *ngIf="!hideDataKeyLabel" class="tb-inline-field tb-label-field" appearance="outline" subscriptSizing="dynamic">
<input matInput formControlName="label" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
@ -193,19 +89,3 @@
</button>
</div>
</div>
<ng-template #keyName>
<ng-container *ngIf="dataKeyHasPostprocessing(); else keyName">
<span>f(</span><ng-container *ngTemplateOutlet="keyNameTemplate"></ng-container><span>)</span>
</ng-container>
<ng-template #keyName>
<ng-container *ngTemplateOutlet="keyNameTemplate"></ng-container>
</ng-template>
</ng-template>
<ng-template #keyNameTemplate>
<ng-container *ngIf="dataKeyHasAggregation(); else keyName;">
<span class="tb-agg-func">{{ modelValue?.aggregationType }}</span><span>({{ modelValue?.name }})</span>
</ng-container>
<ng-template #keyName>
<span>{{modelValue?.name}}</span>
</ng-template>
</ng-template>

26
ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.scss

@ -16,37 +16,13 @@
@import '../../../../../../../../scss/constants';
.tb-data-key-row {
.mat-mdc-form-field.tb-inline-field.tb-key-field {
.mat-mdc-text-field-wrapper:not(.mdc-text-field--outlined) {
padding-left: 8px;
padding-right: 0;
.mat-mdc-form-field-infix {
padding-top: 0;
padding-bottom: 6px;
.mdc-evolution-chip-set .mdc-evolution-chip {
margin: 0;
}
input.mat-mdc-chip-input {
height: 32px;
margin-left: 0;
}
}
}
.mat-mdc-chip.mat-mdc-standard-chip.tb-datakey-chip {
.tb-attribute-chip {
.tb-chip-labels {
background: transparent;
}
}
}
}
.tb-source-field {
width: 108px;
min-width: 108px;
}
.tb-key-field {
.tb-data-key-input {
flex: 1;
min-width: 150px;
@media #{$mat-gt-sm} {

235
ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.ts

@ -17,15 +17,11 @@
import {
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
forwardRef,
Input,
OnChanges,
OnInit,
Output,
SimpleChanges,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import {
@ -49,15 +45,8 @@ import {
widgetType
} from '@shared/models/widget.models';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { AggregationType } from '@shared/models/time/time.models';
import { COMMA, ENTER, SEMICOLON } from '@angular/cdk/keycodes';
import { MatChipGrid, MatChipInputEvent } from '@angular/material/chips';
import { DataKeysCallbacks, DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models';
import { MatAutocomplete, MatAutocompleteTrigger } from '@angular/material/autocomplete';
import { Observable, of } from 'rxjs';
import { filter, map, mergeMap, publishReplay, refCount, share, tap } from 'rxjs/operators';
import { TranslateService } from '@ngx-translate/core';
import { TruncatePipe } from '@shared/pipe/truncate.pipe';
import { merge } from 'rxjs';
import {
DataKeyConfigDialogComponent,
DataKeyConfigDialogData
@ -66,8 +55,6 @@ import { deepClone } from '@core/utils';
import { Dashboard } from '@shared/models/dashboard.models';
import { IAliasController } from '@core/api/widget-api.models';
import { coerceBoolean } from '@shared/decorators/coercion';
import { alarmFields } from '@shared/models/alarm.models';
import { UtilsService } from '@core/services/utils.service';
import {
TimeSeriesChartKeySettings,
TimeSeriesChartSeriesType,
@ -101,22 +88,12 @@ export const dataKeyRowValidator = (control: AbstractControl): ValidationErrors
],
encapsulation: ViewEncapsulation.None
})
export class DataKeyRowComponent implements ControlValueAccessor, OnInit, OnChanges {
dataKeyTypes = DataKeyType;
widgetTypes = widgetType;
export class DataKeyRowComponent implements ControlValueAccessor, OnInit {
timeSeriesChartSeriesTypes = timeSeriesChartSeriesTypes;
timeSeriesChartSeriesTypeTranslations = timeSeriesChartSeriesTypeTranslations;
timeSeriesChartSeriesTypeIcons = timeSeriesChartSeriesTypeIcons;
separatorKeysCodes: number[] = [ENTER, COMMA, SEMICOLON];
@ViewChild('keyInput') keyInput: ElementRef<HTMLInputElement>;
@ViewChild('keyAutocomplete') matAutocomplete: MatAutocomplete;
@ViewChild(MatAutocompleteTrigger) autocomplete: MatAutocompleteTrigger;
@ViewChild('chipList') chipList: MatChipGrid;
@Input()
disabled: boolean;
@ -181,23 +158,13 @@ export class DataKeyRowComponent implements ControlValueAccessor, OnInit, OnChan
@Output()
keyRemoved = new EventEmitter();
keysFormControl: UntypedFormControl;
keyFormControl: UntypedFormControl;
keyRowFormGroup: UntypedFormGroup;
modelValue: DataKey;
filteredKeys: Observable<Array<DataKey>>;
keySearchText = '';
alarmKeys: Array<DataKey>;
functionTypeKeys: Array<DataKey>;
private latestKeySearchTextResult: Array<DataKey> = null;
private keyFetchObservable$: Observable<Array<DataKey>> = null;
generateDataKey = this._generateDataKey.bind(this);
get widgetType(): widgetType {
return this.widgetConfigComponent.widgetType;
@ -256,29 +223,11 @@ export class DataKeyRowComponent implements ControlValueAccessor, OnInit, OnChan
constructor(private fb: UntypedFormBuilder,
private dialog: MatDialog,
private cd: ChangeDetectorRef,
public translate: TranslateService,
public truncate: TruncatePipe,
private utils: UtilsService,
private widgetConfigComponent: WidgetConfigComponent) {
}
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.keyFormControl = this.fb.control('');
this.keysFormControl = this.fb.control([], this.required ? [Validators.required] : []);
this.keyFormControl = this.fb.control(null, this.required ? [Validators.required] : []);
this.keyRowFormGroup = this.fb.group({
label: [null, []],
color: [null, []],
@ -287,59 +236,13 @@ export class DataKeyRowComponent implements ControlValueAccessor, OnInit, OnChan
});
if (this.hasAdditionalLatestDataKeys) {
this.keyRowFormGroup.addControl('latest', this.fb.control(false));
this.keyRowFormGroup.valueChanges.subscribe(
() => this.clearKeySearchCache()
);
}
if (this.showTimeSeriesType) {
this.keyRowFormGroup.addControl('timeSeriesType', this.fb.control(null));
}
this.keyRowFormGroup.valueChanges.subscribe(
merge(this.keyFormControl.valueChanges, this.keyRowFormGroup.valueChanges).subscribe(
() => this.updateModel()
);
this.filteredKeys = this.keyFormControl.valueChanges
.pipe(
tap((value: string | DataKey) => {
if (value && typeof value !== 'string') {
this.addKeyFromChipValue(value);
} else if (value === null) {
this.clearKeyChip(this.keyInput.nativeElement.value);
}
}),
filter((value) => typeof value === 'string'),
map((value) => value ? (typeof value === 'string' ? value : value.name) : ''),
mergeMap(name => this.fetchKeys(name) ),
share()
);
}
private reset() {
if (this.keyInput) {
this.keyInput.nativeElement.value = '';
}
this.keyFormControl.patchValue('', {emitEvent: false});
this.latestKeySearchTextResult = null;
}
ngOnChanges(changes: SimpleChanges): void {
for (const propName of Object.keys(changes)) {
const change = changes[propName];
if (!change.firstChange && change.currentValue !== change.previousValue) {
if (['deviceId', 'entityAliasId'].includes(propName)) {
this.clearKeySearchCache();
} else if (['datasourceType'].includes(propName)) {
if ([DatasourceType.device, DatasourceType.entity].includes(change.previousValue) &&
[DatasourceType.device, DatasourceType.entity].includes(change.currentValue)) {
this.clearKeySearchCache();
} else {
this.clearKeySearchCache();
setTimeout(() => {
this.reset();
}, 1);
}
}
}
}
}
registerOnChange(fn: any): void {
@ -352,10 +255,10 @@ export class DataKeyRowComponent implements ControlValueAccessor, OnInit, OnChan
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (isDisabled) {
this.keysFormControl.disable({emitEvent: false});
this.keyFormControl.disable({emitEvent: false});
this.keyRowFormGroup.disable({emitEvent: false});
} else {
this.keysFormControl.enable({emitEvent: false});
this.keyFormControl.enable({emitEvent: false});
this.keyRowFormGroup.enable({emitEvent: false});
}
}
@ -382,36 +285,10 @@ export class DataKeyRowComponent implements ControlValueAccessor, OnInit, OnChan
timeSeriesType
}, {emitEvent: false});
}
this.keysFormControl.patchValue(this.modelValue ? [this.modelValue] : [], {emitEvent: false});
this.keyFormControl.patchValue(deepClone(this.modelValue), {emitEvent: false});
this.cd.markForCheck();
}
dataKeyHasAggregation(): boolean {
return this.widgetConfigComponent.widgetType === widgetType.latest && this.modelValue?.type === DataKeyType.timeseries
&& this.modelValue?.aggregationType && this.modelValue?.aggregationType !== AggregationType.NONE;
}
dataKeyHasPostprocessing(): boolean {
return !!this.modelValue?.postFuncBody;
}
displayKeyFn(key?: DataKey): string | undefined {
return key ? key.name : undefined;
}
createKey(name: string, dataKeyType: DataKeyType = this.dataKeyType) {
this.addKeyFromChipValue({name: name ? name.trim() : '', type: dataKeyType});
}
addKey(event: MatChipInputEvent): void {
const value = event.value;
if ((value || '').trim() && this.dataKeyType) {
this.addKeyFromChipValue({name: value.trim(), type: this.dataKeyType});
} else {
this.clearKeyChip();
}
}
editKey(advanced = false) {
this.dialog.open<DataKeyConfigDialogComponent, DataKeyConfigDialogData, DataKey>(DataKeyConfigDialogComponent,
{
@ -451,104 +328,20 @@ export class DataKeyRowComponent implements ControlValueAccessor, OnInit, OnChan
});
}
removeKey() {
this.modelValue = null;
this.updateModel();
this.clearKeyChip();
}
textIsNotEmpty(text: string): boolean {
return text && text.length > 0;
}
clearKeyChip(value: string = '', focus = true) {
this.autocomplete.closePanel();
this.keyInput.nativeElement.value = value;
this.keyFormControl.patchValue(value, {emitEvent: focus});
if (focus) {
setTimeout(() => {
this.keyInput.nativeElement.blur();
this.keyInput.nativeElement.focus();
}, 0);
}
}
onKeyInputFocus() {
if (!this.modelValue?.type) {
this.keyFormControl.updateValueAndValidity({onlySelf: true, emitEvent: true});
}
}
private fetchKeys(searchText?: string): Observable<Array<DataKey>> {
if (this.keySearchText !== searchText || this.latestKeySearchTextResult === null) {
this.keySearchText = searchText;
const dataKeyFilter = this.createDataKeyFilter(this.keySearchText);
return this.getKeys().pipe(
map(name => name.filter(dataKeyFilter)),
tap(res => this.latestKeySearchTextResult = res)
);
}
return of(this.latestKeySearchTextResult);
}
private getKeys(): Observable<Array<DataKey>> {
if (this.keyFetchObservable$ === null) {
let fetchObservable: Observable<Array<DataKey>>;
if (this.datasourceType === DatasourceType.function) {
const targetKeysList = this.widgetType === widgetType.alarm ? this.alarmKeys : this.functionTypeKeys;
fetchObservable = of(targetKeysList);
} else if (this.datasourceType === DatasourceType.entity && this.entityAliasId ||
this.datasourceType === DatasourceType.device && this.deviceId) {
const dataKeyTypes = [DataKeyType.timeseries];
if (this.isLatestDataKeys || this.widgetType === widgetType.latest || this.widgetType === widgetType.alarm) {
dataKeyTypes.push(DataKeyType.attribute);
dataKeyTypes.push(DataKeyType.entityField);
if (this.widgetType === widgetType.alarm) {
dataKeyTypes.push(DataKeyType.alarm);
}
}
if (this.datasourceType === DatasourceType.device) {
fetchObservable = this.callbacks.fetchEntityKeysForDevice(this.deviceId, dataKeyTypes);
} else {
fetchObservable = this.callbacks.fetchEntityKeys(this.entityAliasId, dataKeyTypes);
}
} else {
fetchObservable = of([]);
}
this.keyFetchObservable$ = fetchObservable.pipe(
publishReplay(1),
refCount()
);
}
return this.keyFetchObservable$;
}
private createDataKeyFilter(query: string): (key: DataKey) => boolean {
const lowercaseQuery = query.toLowerCase();
return key => key.name.toLowerCase().startsWith(lowercaseQuery);
}
private addKeyFromChipValue(chip: DataKey) {
this.modelValue = this.callbacks.generateDataKey(chip.name, chip.type, this.dataKeySettingsSchema, this.isLatestDataKeys,
private _generateDataKey(key: DataKey): DataKey {
key = this.callbacks.generateDataKey(key.name, key.type, this.dataKeySettingsSchema, this.isLatestDataKeys,
this.dataKeySettingsFunction);
if (!this.keyRowFormGroup.get('label').value) {
this.keyRowFormGroup.get('label').patchValue(this.modelValue.label, {emitEvent: false});
this.keyRowFormGroup.get('label').patchValue(key.label, {emitEvent: false});
}
if (this.showTimeSeriesType) {
this.keyRowFormGroup.get('timeSeriesType').patchValue(this.modelValue.settings?.type, {emitEvent: false});
this.keyRowFormGroup.get('timeSeriesType').patchValue(key.settings?.type, {emitEvent: false});
}
this.updateModel();
this.clearKeyChip('', false);
}
private clearKeySearchCache() {
this.keySearchText = '';
this.keyFetchObservable$ = null;
this.latestKeySearchTextResult = null;
return key;
}
private updateModel() {
this.keysFormControl.patchValue(this.modelValue ? [this.modelValue] : [], {emitEvent: false});
this.modelValue = this.keyFormControl.value;
if (this.modelValue !== null) {
const value: DataKey = this.keyRowFormGroup.value;
this.modelValue = {...this.modelValue, ...value};

95
ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts

@ -25,7 +25,7 @@ import {
import { ComponentStyle, Font, simpleDateFormat, textStyle } from '@shared/models/widget-settings.models';
import { XAXisOption, YAXisOption } from 'echarts/types/dist/shared';
import { CustomSeriesOption, LineSeriesOption } from 'echarts/charts';
import { formatValue, isDefinedAndNotNull, parseFunction } from '@core/utils';
import { formatValue, isDefinedAndNotNull, isUndefinedOrNull, parseFunction } from '@core/utils';
import { LinearGradientObject } from 'zrender/lib/graphic/LinearGradient';
import tinycolor from 'tinycolor2';
import Axis2D from 'echarts/types/src/coord/cartesian/Axis2D';
@ -40,7 +40,8 @@ import {
import { DataKey } from '@shared/models/widget.models';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { TbColorScheme } from '@shared/models/color.models';
import { DoughnutLayout } from '@home/components/widget/lib/chart/doughnut-widget.models';
import { AbstractControl, ValidationErrors } from '@angular/forms';
import { MarkLine2DDataItemOption } from 'echarts/types/src/component/marker/MarkLineModel';
const timeSeriesChartColorScheme: TbColorScheme = {
'threshold.line': {
@ -154,12 +155,41 @@ export enum ThresholdLabelPosition {
insideEndBottom = 'insideEndBottom'
}
export const timeSeriesThresholdLabelPositions = Object.keys(ThresholdLabelPosition) as ThresholdLabelPosition[];
export const timeSeriesThresholdLabelPositionTranslations = new Map<ThresholdLabelPosition, string>(
[
[ThresholdLabelPosition.start, 'widgets.time-series-chart.threshold.label-position-start'],
[ThresholdLabelPosition.middle, 'widgets.time-series-chart.threshold.label-position-middle'],
[ThresholdLabelPosition.end, 'widgets.time-series-chart.threshold.label-position-end'],
[ThresholdLabelPosition.insideStart, 'widgets.time-series-chart.threshold.label-position-inside-start'],
[ThresholdLabelPosition.insideStartTop, 'widgets.time-series-chart.threshold.label-position-inside-start-top'],
[ThresholdLabelPosition.insideStartBottom, 'widgets.time-series-chart.threshold.label-position-inside-start-bottom'],
[ThresholdLabelPosition.insideMiddle, 'widgets.time-series-chart.threshold.label-position-inside-middle'],
[ThresholdLabelPosition.insideMiddleTop, 'widgets.time-series-chart.threshold.label-position-inside-middle-top'],
[ThresholdLabelPosition.insideMiddleBottom, 'widgets.time-series-chart.threshold.label-position-inside-middle-bottom'],
[ThresholdLabelPosition.insideEnd, 'widgets.time-series-chart.threshold.label-position-inside-end'],
[ThresholdLabelPosition.insideEndTop, 'widgets.time-series-chart.threshold.label-position-inside-end-top'],
[ThresholdLabelPosition.insideEndBottom, 'widgets.time-series-chart.threshold.label-position-inside-end-bottom']
]
);
export enum TimeSeriesChartThresholdType {
constant = 'constant',
latestKey = 'latestKey',
entity = 'entity'
}
export const timeSeriesThresholdTypes = Object.keys(TimeSeriesChartThresholdType) as TimeSeriesChartThresholdType[];
export const timeSeriesThresholdTypeTranslations = new Map<TimeSeriesChartThresholdType, string>(
[
[TimeSeriesChartThresholdType.constant, 'widgets.time-series-chart.threshold.type-constant'],
[TimeSeriesChartThresholdType.latestKey, 'widgets.time-series-chart.threshold.type-latest-key'],
[TimeSeriesChartThresholdType.entity, 'widgets.time-series-chart.threshold.type-entity']
]
);
export enum SeriesFillType {
none = 'none',
opacity = 'opacity',
@ -273,6 +303,40 @@ export interface TimeSeriesChartThreshold {
labelColor: string;
}
export const timeSeriesChartThresholdValid = (threshold: TimeSeriesChartThreshold): boolean => {
if (!threshold.type) {
return false;
}
switch (threshold.type) {
case TimeSeriesChartThresholdType.constant:
if (isUndefinedOrNull(threshold.value)) {
return false;
}
break;
case TimeSeriesChartThresholdType.latestKey:
if (!threshold.latestKey || !threshold.latestKeyType) {
return false;
}
break;
case TimeSeriesChartThresholdType.entity:
if (!threshold.entityAlias || !threshold.entityKey || !threshold.entityKeyType) {
return false;
}
break;
}
return true;
};
export const timeSeriesChartThresholdValidator = (control: AbstractControl): ValidationErrors | null => {
const threshold: TimeSeriesChartThreshold = control.value;
if (!timeSeriesChartThresholdValid(threshold)) {
return {
threshold: true
};
}
return null;
};
export const timeSeriesChartThresholdDefaultSettings: TimeSeriesChartThreshold = {
type: TimeSeriesChartThresholdType.constant,
units: '',
@ -709,8 +773,6 @@ const generateChartThresholds = (thresholdItems: TimeSeriesChartThresholdItem[],
},
markLine: {
animation: true,
symbol: [item.settings.startSymbol, item.settings.endSymbol],
symbolSize: [item.settings.startSymbolSize, item.settings.endSymbolSize],
lineStyle: {
width: item.settings.lineWidth,
color: prepareChartThemeColor(item.settings.lineColor, darkMode, 'threshold.line'),
@ -737,14 +799,10 @@ const generateChartThresholds = (thresholdItems: TimeSeriesChartThresholdItem[],
seriesOption.markLine.data = [];
if (Array.isArray(item.value)) {
for (const val of item.value) {
seriesOption.markLine.data.push({
yAxis: val
});
seriesOption.markLine.data.push(createThresholdData(val, item));
}
} else {
seriesOption.markLine.data.push({
yAxis: item.value
});
seriesOption.markLine.data.push(createThresholdData(item.value, item));
}
series.push(seriesOption);
}
@ -752,6 +810,23 @@ const generateChartThresholds = (thresholdItems: TimeSeriesChartThresholdItem[],
return series;
};
const createThresholdData = (val: string | number, item: TimeSeriesChartThresholdItem): MarkLine2DDataItemOption => [
{
xAxis: 'min',
yAxis: val,
value: val,
symbol: item.settings.startSymbol,
symbolSize: item.settings.startSymbolSize
},
{
xAxis: 'max',
yAxis: val,
value: val,
symbol: item.settings.endSymbol,
symbolSize: item.settings.endSymbolSize
}
];
const generateChartSeries = (dataItems: TimeSeriesChartDataItem[],
timeInterval: Interval,
stack: boolean,

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-bar-settings.component.html

@ -49,6 +49,8 @@
</mat-form-field>
<tb-font-settings formControlName="labelFont"
clearButton
disabledLineHeight
forceSizeUnit="px"
[previewText]="labelPreviewFn">
</tb-font-settings>
<tb-color-input asBoxInput

2
ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.html

@ -80,6 +80,8 @@
</mat-form-field>
<tb-font-settings formControlName="pointLabelFont"
clearButton
disabledLineHeight
forceSizeUnit="px"
[previewText]="pointLabelPreviewFn">
</tb-font-settings>
<tb-color-input asBoxInput

4
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.html

@ -36,6 +36,8 @@
</mat-form-field>
<tb-font-settings formControlName="labelFont"
clearButton
disabledLineHeight
forceSizeUnit="px"
[previewText]="axisSettingsFormGroup.get('label').value">
</tb-font-settings>
<tb-color-input asBoxInput
@ -61,6 +63,8 @@
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<tb-font-settings formControlName="tickLabelFont"
clearButton
disabledLineHeight
forceSizeUnit="px"
previewText="100">
</tb-font-settings>
<tb-color-input asBoxInput

107
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.html

@ -0,0 +1,107 @@
<!--
Copyright © 2016-2024 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 [formGroup]="thresholdFormGroup" class="tb-form-table-row tb-time-series-threshold-row">
<div class="tb-threshold-source-field">
<mat-form-field class="tb-inline-field tb-threshold-type-field" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="type">
<mat-option *ngFor="let type of timeSeriesThresholdTypes" [value]="type">
{{ timeSeriesThresholdTypeTranslations.get(type) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<tb-entity-alias-input
*ngIf="thresholdFormGroup.get('type').value === TimeSeriesChartThresholdType.entity"
[aliasController]="aliasController"
required
formControlName="entityAlias">
</tb-entity-alias-input>
</div>
<div class="tb-threshold-key-value-field">
<mat-form-field *ngIf="thresholdFormGroup.get('type').value === TimeSeriesChartThresholdType.constant"
appearance="outline" class="tb-inline-field number" subscriptSizing="dynamic"
[class.tb-suffix-absolute]="!thresholdFormGroup.get('value').value">
<input required matInput formControlName="value" type="number" placeholder="{{ 'widget-config.set' | translate }}">
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'widgets.time-series-chart.threshold.threshold-value-required' | translate"
*ngIf="thresholdFormGroup.get('value').hasError('required')
&& thresholdFormGroup.get('value').touched"
class="tb-error">
warning
</mat-icon>
</mat-form-field>
<tb-data-key-input
*ngIf="thresholdFormGroup.get('type').value === TimeSeriesChartThresholdType.latestKey"
required
[datasourceType]="datasource?.type"
[entityAliasId]="datasource?.entityAliasId"
[deviceId]="datasource?.deviceId"
[aliasController]="aliasController"
[dataKeyTypes]="[DataKeyType.attribute, DataKeyType.timeseries]"
[callbacks]="dataKeyCallbacks"
[editable]="false"
[formControl]="latestKeyFormControl">
</tb-data-key-input>
<tb-data-key-input
*ngIf="thresholdFormGroup.get('type').value === TimeSeriesChartThresholdType.entity"
required
[datasourceType]="DatasourceType.entity"
[aliasController]="aliasController"
[entityAlias]="thresholdFormGroup.get('entityAlias').value"
[dataKeyTypes]="[DataKeyType.attribute, DataKeyType.timeseries]"
[callbacks]="dataKeyCallbacks"
[editable]="false"
[formControl]="entityKeyFormControl">
</tb-data-key-input>
</div>
<div class="tb-color-field">
<tb-color-input asBoxInput
formControlName="lineColor">
</tb-color-input>
</div>
<div class="tb-units-field">
<tb-unit-input formControlName="units">
</tb-unit-input>
</div>
<div class="tb-decimals-field">
<mat-form-field appearance="outline" class="tb-inline-field number" subscriptSizing="dynamic">
<input matInput formControlName="decimals" type="number" min="0" max="15" step="1" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
<div class="tb-form-table-row-cell-buttons">
<div class="tb-settings-button">
<button *ngIf="modelValue"
type="button"
mat-icon-button
#matButton
(click)="editThreshold($event, matButton)"
matTooltip="{{ 'widgets.time-series-chart.threshold.threshold-settings' | translate }}"
matTooltipPosition="above">
<mat-icon>settings</mat-icon>
</button>
</div>
<button type="button"
mat-icon-button
(click)="thresholdRemoved.emit()"
matTooltip="{{ 'widgets.time-series-chart.threshold.remove-threshold' | translate }}"
matTooltipPosition="above">
<mat-icon>delete</mat-icon>
</button>
</div>
</div>

64
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.scss

@ -0,0 +1,64 @@
/**
* Copyright © 2016-2024 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-time-series-threshold-row {
.tb-threshold-source-field {
flex: 1;
min-width: 200px;
display: flex;
align-items: center;
flex-direction: row;
gap: 12px;
}
.tb-threshold-type-field {
flex: 1;
}
.tb-entity-alias-input {
flex: 1;
}
.tb-threshold-key-value-field {
flex: 1;
min-width: 150px;
.tb-inline-field, .tb-data-key-input {
flex: 1;
}
}
.tb-threshold-key-value-field, .tb-color-field, .tb-units-field, .tb-decimals-field {
display: flex;
flex-direction: row;
place-content: center;
align-items: center;
}
.tb-units-field {
width: 80px;
min-width: 80px;
}
.tb-color-field {
width: 40px;
min-width: 40px;
}
.tb-decimals-field {
width: 60px;
min-width: 60px;
}
}

261
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.ts

@ -0,0 +1,261 @@
///
/// Copyright © 2016-2024 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 {
ChangeDetectorRef,
Component,
EventEmitter,
forwardRef,
Input,
OnInit,
Output,
Renderer2,
ViewContainerRef,
ViewEncapsulation
} from '@angular/core';
import {
ControlValueAccessor,
NG_VALUE_ACCESSOR,
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
Validators
} from '@angular/forms';
import {
TimeSeriesChartThreshold,
TimeSeriesChartThresholdType,
timeSeriesThresholdTypes,
timeSeriesThresholdTypeTranslations
} from '@home/components/widget/lib/chart/time-series-chart.models';
import {
TimeSeriesChartThresholdsPanelComponent
} from '@home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component';
import { IAliasController } from '@core/api/widget-api.models';
import { DataKey, Datasource, DatasourceType, WidgetConfig } from '@shared/models/widget.models';
import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { MatButton } from '@angular/material/button';
import { TbPopoverService } from '@shared/components/popover.service';
import { deepClone } from '@core/utils';
import {
TimeSeriesChartThresholdSettingsPanelComponent
} from '@home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component';
@Component({
selector: 'tb-time-series-chart-threshold-row',
templateUrl: './time-series-chart-threshold-row.component.html',
styleUrls: ['./time-series-chart-threshold-row.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => TimeSeriesChartThresholdRowComponent),
multi: true
}
],
encapsulation: ViewEncapsulation.None
})
export class TimeSeriesChartThresholdRowComponent implements ControlValueAccessor, OnInit {
DataKeyType = DataKeyType;
DatasourceType = DatasourceType;
TimeSeriesChartThresholdType = TimeSeriesChartThresholdType;
timeSeriesThresholdTypes = timeSeriesThresholdTypes;
timeSeriesThresholdTypeTranslations = timeSeriesThresholdTypeTranslations;
get aliasController(): IAliasController {
return this.thresholdsPanel.aliasController;
}
get dataKeyCallbacks(): DataKeysCallbacks {
return this.thresholdsPanel.dataKeyCallbacks;
}
get datasource(): Datasource {
return this.thresholdsPanel.datasource;
}
get widgetConfig(): WidgetConfig {
return this.thresholdsPanel.widgetConfig;
}
@Input()
disabled: boolean;
@Output()
thresholdRemoved = new EventEmitter();
thresholdFormGroup: UntypedFormGroup;
modelValue: TimeSeriesChartThreshold;
latestKeyFormControl: UntypedFormControl;
entityKeyFormControl: UntypedFormControl;
private propagateChange = (_val: any) => {};
constructor(private fb: UntypedFormBuilder,
private popoverService: TbPopoverService,
private renderer: Renderer2,
private viewContainerRef: ViewContainerRef,
private thresholdsPanel: TimeSeriesChartThresholdsPanelComponent,
private cd: ChangeDetectorRef) {
}
ngOnInit() {
this.thresholdFormGroup = this.fb.group({
type: [null, []],
value: [null, [Validators.required]],
entityAlias: [null, [Validators.required]],
lineColor: [null, []],
units: [null, []],
decimals: [null, []]
});
this.latestKeyFormControl = this.fb.control(null, [Validators.required]);
this.entityKeyFormControl = this.fb.control(null, [Validators.required]);
this.thresholdFormGroup.valueChanges.subscribe(
() => this.updateModel()
);
this.latestKeyFormControl.valueChanges.subscribe(
() => this.updateModel()
);
this.entityKeyFormControl.valueChanges.subscribe(
() => this.updateModel()
);
this.thresholdFormGroup.get('type').valueChanges.subscribe(() => {
this.updateValidators();
});
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (isDisabled) {
this.thresholdFormGroup.disable({emitEvent: false});
this.latestKeyFormControl.disable({emitEvent: false});
this.entityKeyFormControl.disable({emitEvent: false});
} else {
this.thresholdFormGroup.enable({emitEvent: false});
this.updateValidators();
}
}
writeValue(value: TimeSeriesChartThreshold): void {
this.modelValue = value;
this.thresholdFormGroup.patchValue(
{
type: value.type,
value: value.value,
entityAlias: value.entityAlias,
lineColor: value.lineColor,
units: value.units,
decimals: value.decimals,
}, {emitEvent: false}
);
if (value.type === TimeSeriesChartThresholdType.latestKey) {
this.latestKeyFormControl.patchValue({
type: value.latestKeyType,
name: value.latestKey
}, {emitEvent: false});
} else if (value.type === TimeSeriesChartThresholdType.entity) {
this.entityKeyFormControl.patchValue({
type: value.entityKeyType,
name: value.entityKey
}, {emitEvent: false});
}
this.updateValidators();
this.cd.markForCheck();
}
editThreshold($event: Event, matButton: MatButton) {
if ($event) {
$event.stopPropagation();
}
const trigger = matButton._elementRef.nativeElement;
if (this.popoverService.hasPopover(trigger)) {
this.popoverService.hidePopover(trigger);
} else {
const ctx: any = {
thresholdSettings: deepClone(this.modelValue),
widgetConfig: this.widgetConfig
};
const thresholdSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer,
this.viewContainerRef, TimeSeriesChartThresholdSettingsPanelComponent, ['leftOnly', 'leftTopOnly', 'leftBottomOnly'], true, null,
ctx,
{},
{}, {}, true);
thresholdSettingsPanelPopover.tbComponentRef.instance.popover = thresholdSettingsPanelPopover;
thresholdSettingsPanelPopover.tbComponentRef.instance.thresholdSettingsApplied.subscribe((thresholdSettings) => {
thresholdSettingsPanelPopover.hide();
this.modelValue = {...this.modelValue, ...thresholdSettings};
this.thresholdFormGroup.patchValue(
{lineColor: this.modelValue.lineColor},
{emitEvent: false});
this.propagateChange(this.modelValue);
});
}
}
private updateValidators() {
const type: TimeSeriesChartThresholdType = this.thresholdFormGroup.get('type').value;
if (type === TimeSeriesChartThresholdType.constant) {
this.thresholdFormGroup.get('value').enable({emitEvent: false});
this.thresholdFormGroup.get('entityAlias').disable({emitEvent: false});
this.latestKeyFormControl.disable({emitEvent: false});
this.entityKeyFormControl.disable({emitEvent: false});
} else if (type === TimeSeriesChartThresholdType.latestKey) {
this.thresholdFormGroup.get('value').disable({emitEvent: false});
this.thresholdFormGroup.get('entityAlias').disable({emitEvent: false});
this.latestKeyFormControl.enable({emitEvent: false});
this.entityKeyFormControl.disable({emitEvent: false});
} else if (type === TimeSeriesChartThresholdType.entity) {
this.thresholdFormGroup.get('value').disable({emitEvent: false});
this.thresholdFormGroup.get('entityAlias').enable({emitEvent: false});
this.latestKeyFormControl.disable({emitEvent: false});
this.entityKeyFormControl.enable({emitEvent: false});
}
}
private updateModel() {
const value = this.thresholdFormGroup.value;
this.modelValue.type = value.type;
this.modelValue.value = value.value;
this.modelValue.entityAlias = value.entityAlias;
this.modelValue.lineColor = value.lineColor;
this.modelValue.units = value.units;
this.modelValue.decimals = value.decimals;
if (value.type === TimeSeriesChartThresholdType.latestKey) {
const latestKey: DataKey = this.latestKeyFormControl.value;
this.modelValue.latestKey = latestKey?.name;
this.modelValue.latestKeyType = (latestKey?.type as any);
} else if (value.type === TimeSeriesChartThresholdType.entity) {
const entityKey: DataKey = this.entityKeyFormControl.value;
this.modelValue.entityKey = entityKey?.name;
this.modelValue.entityKeyType = (entityKey?.type as any);
}
this.propagateChange(this.modelValue);
}
}

121
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component.html

@ -0,0 +1,121 @@
<!--
Copyright © 2016-2024 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-threshold-settings-panel" [formGroup]="thresholdSettingsFormGroup">
<div class="tb-threshold-settings-title">{{ 'widgets.time-series-chart.threshold.threshold-settings' | translate }}</div>
<div class="tb-threshold-settings-panel-content">
<div class="tb-form-panel">
<div class="tb-form-panel-title" translate>widgets.time-series-chart.threshold.line-appearance</div>
<div class="tb-form-row space-between">
<div translate>widgets.time-series-chart.threshold.line-color</div>
<tb-color-input asBoxInput
colorClearButton
formControlName="lineColor">
</tb-color-input>
</div>
<div class="tb-form-row space-between column-xs">
<div translate>widgets.time-series-chart.line-type</div>
<mat-form-field class="medium-width" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="lineType">
<mat-option *ngFor="let lineType of timeSeriesLineTypes" [value]="lineType">
{{ timeSeriesLineTypeTranslations.get(lineType) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="tb-form-row space-between column-xs">
<div translate>widgets.time-series-chart.line-width</div>
<mat-form-field class="medium-width number" appearance="outline" subscriptSizing="dynamic">
<input matInput type="number" formControlName="lineWidth" min="0" step="1" placeholder="{{ 'widget-config.set' | translate }}"/>
</mat-form-field>
</div>
<div class="tb-form-row space-between column-xs">
<div translate>widgets.time-series-chart.threshold.start-symbol</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="startSymbol">
<mat-option *ngFor="let shape of timeSeriesChartShapes" [value]="shape">
{{ timeSeriesChartShapeTranslations.get(shape) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<div class="tb-small-label" translate>widgets.time-series-chart.threshold.symbol-size</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="startSymbolSize"
type="number" min="0" step="1" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div>
<div class="tb-form-row space-between column-xs">
<div translate>widgets.time-series-chart.threshold.end-symbol</div>
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="endSymbol">
<mat-option *ngFor="let shape of timeSeriesChartShapes" [value]="shape">
{{ timeSeriesChartShapeTranslations.get(shape) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<div class="tb-small-label" translate>widgets.time-series-chart.threshold.symbol-size</div>
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic">
<input matInput formControlName="endSymbolSize"
type="number" min="0" step="1" placeholder="{{ 'widget-config.set' | translate }}">
</mat-form-field>
</div>
</div>
<div class="tb-form-row space-between column-lt-md">
<mat-slide-toggle class="mat-slide" formControlName="showLabel">
{{ 'widgets.time-series-chart.threshold.label' | translate }}
</mat-slide-toggle>
<div fxLayout="row" fxFlex.lt-md fxLayoutAlign="start center" fxLayoutGap="8px">
<mat-form-field class="medium-width" fxFlex.lt-md appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="labelPosition">
<mat-option *ngFor="let position of timeSeriesThresholdLabelPositions" [value]="position">
{{ timeSeriesThresholdLabelPositionTranslations.get(position) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<tb-font-settings formControlName="labelFont"
clearButton
disabledLineHeight
forceSizeUnit="px"
[previewText]="labelPreviewFn">
</tb-font-settings>
<tb-color-input asBoxInput
colorClearButton
formControlName="labelColor">
</tb-color-input>
</div>
</div>
</div>
</div>
<div class="tb-threshold-settings-panel-buttons">
<button mat-button
color="primary"
type="button"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button
color="primary"
type="button"
(click)="applyThresholdSettings()"
[disabled]="thresholdSettingsFormGroup.invalid || !thresholdSettingsFormGroup.dirty">
{{ 'action.apply' | translate }}
</button>
</div>
</div>

47
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component.scss

@ -0,0 +1,47 @@
/**
* Copyright © 2016-2024 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 '../../../../../../../../../scss/constants';
.tb-threshold-settings-panel {
width: 530px;
display: flex;
flex-direction: column;
gap: 16px;
@media #{$mat-lt-md} {
width: 90vw;
}
.tb-threshold-settings-panel-content {
display: flex;
flex-direction: column;
gap: 16px;
overflow: auto;
}
.tb-threshold-settings-title {
font-size: 16px;
font-weight: 500;
line-height: 24px;
letter-spacing: 0.25px;
color: rgba(0, 0, 0, 0.87);
}
.tb-threshold-settings-panel-buttons {
height: 40px;
display: flex;
flex-direction: row;
gap: 16px;
justify-content: flex-end;
align-items: flex-end;
}
}

139
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component.ts

@ -0,0 +1,139 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import {
TimeSeriesChartShape,
timeSeriesChartShapes,
timeSeriesChartShapeTranslations,
TimeSeriesChartThreshold,
timeSeriesLineTypes,
timeSeriesLineTypeTranslations,
timeSeriesThresholdLabelPositions,
timeSeriesThresholdLabelPositionTranslations
} from '@home/components/widget/lib/chart/time-series-chart.models';
import { merge } from 'rxjs';
import { WidgetConfig } from '@shared/models/widget.models';
import { formatValue, isDefinedAndNotNull } from '@core/utils';
@Component({
selector: 'tb-time-series-chart-threshold-settings-panel',
templateUrl: './time-series-chart-threshold-settings-panel.component.html',
providers: [],
styleUrls: ['./time-series-chart-threshold-settings-panel.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class TimeSeriesChartThresholdSettingsPanelComponent implements OnInit {
timeSeriesLineTypes = timeSeriesLineTypes;
timeSeriesLineTypeTranslations = timeSeriesLineTypeTranslations;
timeSeriesChartShapes = timeSeriesChartShapes;
timeSeriesChartShapeTranslations = timeSeriesChartShapeTranslations;
timeSeriesThresholdLabelPositions = timeSeriesThresholdLabelPositions;
timeSeriesThresholdLabelPositionTranslations = timeSeriesThresholdLabelPositionTranslations;
labelPreviewFn = this._labelPreviewFn.bind(this);
@Input()
thresholdSettings: Partial<TimeSeriesChartThreshold>;
@Input()
widgetConfig: WidgetConfig;
@Input()
popover: TbPopoverComponent<TimeSeriesChartThresholdSettingsPanelComponent>;
@Output()
thresholdSettingsApplied = new EventEmitter<Partial<TimeSeriesChartThreshold>>();
thresholdSettingsFormGroup: UntypedFormGroup;
constructor(private fb: UntypedFormBuilder) {
}
ngOnInit(): void {
this.thresholdSettingsFormGroup = this.fb.group(
{
lineColor: [this.thresholdSettings.lineColor, []],
lineType: [this.thresholdSettings.lineType, []],
lineWidth: [this.thresholdSettings.lineWidth, [Validators.min(0)]],
startSymbol: [this.thresholdSettings.startSymbol, []],
startSymbolSize: [this.thresholdSettings.startSymbolSize, [Validators.min(0)]],
endSymbol: [this.thresholdSettings.endSymbol, []],
endSymbolSize: [this.thresholdSettings.endSymbolSize, [Validators.min(0)]],
showLabel: [this.thresholdSettings.showLabel, []],
labelPosition: [this.thresholdSettings.labelPosition, []],
labelFont: [this.thresholdSettings.labelFont, []],
labelColor: [this.thresholdSettings.labelColor, []]
}
);
merge(this.thresholdSettingsFormGroup.get('showLabel').valueChanges,
this.thresholdSettingsFormGroup.get('startSymbol').valueChanges,
this.thresholdSettingsFormGroup.get('endSymbol').valueChanges).subscribe(() => {
this.updateValidators();
});
this.updateValidators();
}
cancel() {
this.popover?.hide();
}
applyThresholdSettings() {
const thresholdSettings = this.thresholdSettingsFormGroup.getRawValue();
this.thresholdSettingsApplied.emit(thresholdSettings);
}
private updateValidators() {
const showLabel: boolean = this.thresholdSettingsFormGroup.get('showLabel').value;
const startSymbol: TimeSeriesChartShape = this.thresholdSettingsFormGroup.get('startSymbol').value;
const endSymbol: TimeSeriesChartShape = this.thresholdSettingsFormGroup.get('endSymbol').value;
if (showLabel) {
this.thresholdSettingsFormGroup.get('labelPosition').enable({emitEvent: false});
this.thresholdSettingsFormGroup.get('labelFont').enable({emitEvent: false});
this.thresholdSettingsFormGroup.get('labelColor').enable({emitEvent: false});
} else {
this.thresholdSettingsFormGroup.get('labelPosition').disable({emitEvent: false});
this.thresholdSettingsFormGroup.get('labelFont').disable({emitEvent: false});
this.thresholdSettingsFormGroup.get('labelColor').disable({emitEvent: false});
}
if (startSymbol === TimeSeriesChartShape.none) {
this.thresholdSettingsFormGroup.get('startSymbolSize').disable({emitEvent: false});
} else {
this.thresholdSettingsFormGroup.get('startSymbolSize').enable({emitEvent: false});
}
if (endSymbol === TimeSeriesChartShape.none) {
this.thresholdSettingsFormGroup.get('endSymbolSize').disable({emitEvent: false});
} else {
this.thresholdSettingsFormGroup.get('endSymbolSize').enable({emitEvent: false});
}
}
private _labelPreviewFn(): string {
const units = this.thresholdSettings.units && this.thresholdSettings.units.length ?
this.thresholdSettings.units : this.widgetConfig.units;
const decimals = isDefinedAndNotNull(this.thresholdSettings.decimals) ? this.thresholdSettings.decimals :
(isDefinedAndNotNull(this.widgetConfig.decimals) ? this.widgetConfig.decimals : 2);
return formatValue(22, decimals, units, false);
}
}

47
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.html

@ -0,0 +1,47 @@
<!--
Copyright © 2016-2024 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-form-panel tb-time-series-thresholds-panel">
<div class="tb-form-panel-title">{{ 'widgets.time-series-chart.threshold.thresholds' | translate }}</div>
<div class="tb-form-table">
<div class="tb-form-table-header">
<div class="tb-form-table-header-cell tb-threshold-source-header" translate>widgets.time-series-chart.threshold.source</div>
<div class="tb-form-table-header-cell tb-threshold-key-value-header" translate>widgets.time-series-chart.threshold.key-value</div>
<div class="tb-form-table-header-cell tb-color-header" translate>widgets.color.color</div>
<div class="tb-form-table-header-cell tb-units-header" translate>widget-config.units-short</div>
<div class="tb-form-table-header-cell tb-decimals-header" translate>widget-config.decimals-short</div>
<div class="tb-form-table-header-cell tb-actions-header"></div>
</div>
<div *ngIf="thresholdsFormArray().controls.length; else noThresholds" class="tb-form-table-body">
<div *ngFor="let thresholdControl of thresholdsFormArray().controls; trackBy: trackByThreshold; let $index = index;">
<tb-time-series-chart-threshold-row fxFlex
[formControl]="thresholdControl"
(thresholdRemoved)="removeThreshold($index)">
</tb-time-series-chart-threshold-row>
</div>
</div>
</div>
<div>
<button type="button" mat-stroked-button color="primary" (click)="addThreshold()">
{{ 'widgets.time-series-chart.threshold.add-threshold' | translate }}
</button>
</div>
</div>
<ng-template #noThresholds>
<span fxLayoutAlign="center center"
class="tb-prompt">{{ 'widgets.time-series-chart.threshold.no-thresholds' | translate }}</span>
</ng-template>

47
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.scss

@ -0,0 +1,47 @@
/**
* Copyright © 2016-2024 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-time-series-thresholds-panel {
.tb-form-table-header-cell {
&.tb-threshold-source-header {
flex: 1;
min-width: 200px;
}
&.tb-threshold-key-value-header {
flex: 1;
min-width: 150px;
}
&.tb-units-header {
width: 80px;
min-width: 80px;
}
&.tb-color-header {
width: 40px;
min-width: 40px;
}
&.tb-decimals-header {
width: 60px;
min-width: 60px;
}
&.tb-actions-header {
width: 80px;
min-width: 80px;
}
}
}

213
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.ts

@ -0,0 +1,213 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, forwardRef, Input, OnInit, ViewEncapsulation } from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
UntypedFormArray,
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
Validator
} from '@angular/forms';
import {
TimeSeriesChartThreshold,
timeSeriesChartThresholdDefaultSettings,
TimeSeriesChartThresholdType,
timeSeriesChartThresholdValid,
timeSeriesChartThresholdValidator
} from '@home/components/widget/lib/chart/time-series-chart.models';
import { mergeDeep } from '@core/utils';
import { IAliasController } from '@core/api/widget-api.models';
import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models';
import { DataKey, Datasource, WidgetConfig } from '@shared/models/widget.models';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
@Component({
selector: 'tb-time-series-chart-thresholds-panel',
templateUrl: './time-series-chart-thresholds-panel.component.html',
styleUrls: ['./time-series-chart-thresholds-panel.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => TimeSeriesChartThresholdsPanelComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => TimeSeriesChartThresholdsPanelComponent),
multi: true
}
],
encapsulation: ViewEncapsulation.None
})
export class TimeSeriesChartThresholdsPanelComponent implements ControlValueAccessor, OnInit, Validator {
@Input()
disabled: boolean;
@Input()
aliasController: IAliasController;
@Input()
dataKeyCallbacks: DataKeysCallbacks;
@Input()
datasource: Datasource;
@Input()
widgetConfig: WidgetConfig;
thresholdsFormGroup: UntypedFormGroup;
private propagateChange = (_val: any) => {};
constructor(private fb: UntypedFormBuilder) {
}
ngOnInit() {
this.thresholdsFormGroup = this.fb.group({
thresholds: [this.fb.array([]), []]
});
this.thresholdsFormGroup.valueChanges.subscribe(
() => {
let thresholds: TimeSeriesChartThreshold[] = this.thresholdsFormGroup.get('thresholds').value;
if (thresholds) {
thresholds = thresholds.filter(t => timeSeriesChartThresholdValid(t));
}
this.updateLatestDataKeys(thresholds);
this.propagateChange(thresholds);
}
);
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (isDisabled) {
this.thresholdsFormGroup.disable({emitEvent: false});
} else {
this.thresholdsFormGroup.enable({emitEvent: false});
}
}
writeValue(value: TimeSeriesChartThreshold[] | undefined): void {
const thresholds = this.checkLatestDataKeys(value || []);
this.thresholdsFormGroup.setControl('thresholds', this.prepareThresholdsFormArray(thresholds), {emitEvent: false});
}
public validate(c: UntypedFormControl) {
const valid = this.thresholdsFormGroup.valid;
return valid ? null : {
thresholds: {
valid: false,
},
};
}
thresholdsFormArray(): UntypedFormArray {
return this.thresholdsFormGroup.get('thresholds') as UntypedFormArray;
}
trackByThreshold(index: number, thresholdControl: AbstractControl): any {
return thresholdControl;
}
removeThreshold(index: number) {
(this.thresholdsFormGroup.get('thresholds') as UntypedFormArray).removeAt(index);
}
addThreshold() {
const threshold = mergeDeep<TimeSeriesChartThreshold>({} as TimeSeriesChartThreshold,
timeSeriesChartThresholdDefaultSettings);
const thresholdsArray = this.thresholdsFormGroup.get('thresholds') as UntypedFormArray;
const thresholdControl = this.fb.control(threshold, [timeSeriesChartThresholdValidator]);
thresholdsArray.push(thresholdControl);
}
private prepareThresholdsFormArray(thresholds: TimeSeriesChartThreshold[] | undefined): UntypedFormArray {
const thresholdsControls: Array<AbstractControl> = [];
if (thresholds) {
thresholds.forEach((threshold) => {
thresholdsControls.push(this.fb.control(threshold, [timeSeriesChartThresholdValidator]));
});
}
return this.fb.array(thresholdsControls);
}
private checkLatestDataKeys(thresholds: TimeSeriesChartThreshold[]): TimeSeriesChartThreshold[] {
const result: TimeSeriesChartThreshold[] = [];
const latestKeys = this.datasource?.latestDataKeys || [];
for (const threshold of thresholds) {
if (threshold.type === TimeSeriesChartThresholdType.latestKey) {
const found = latestKeys.find(k => this.isThresholdKey(k, threshold));
if (found) {
result.push(threshold);
}
} else {
result.push(threshold);
}
}
return result;
}
private updateLatestDataKeys(thresholds: TimeSeriesChartThreshold[]) {
if (this.datasource) {
let latestKeys = this.datasource.latestDataKeys;
if (!latestKeys) {
latestKeys = [];
this.datasource.latestDataKeys = latestKeys;
}
const existingThresholdKeys = latestKeys.filter(k => k.settings?.__thresholdKey === true);
const foundThresholdKeys: DataKey[] = [];
for (const threshold of thresholds) {
if (threshold.type === TimeSeriesChartThresholdType.latestKey) {
const found = existingThresholdKeys.find(k => this.isThresholdKey(k, threshold));
if (!found) {
const newKey = this.dataKeyCallbacks.generateDataKey(threshold.latestKey, threshold.latestKeyType,
null, true, null);
newKey.settings.__thresholdKey = true;
latestKeys.push(newKey);
} else if (foundThresholdKeys.indexOf(found) === -1) {
foundThresholdKeys.push(found);
}
}
}
const toRemove = existingThresholdKeys.filter(k => foundThresholdKeys.indexOf(k) === -1);
for (const key of toRemove) {
const index = latestKeys.indexOf(key);
if (index > -1) {
latestKeys.splice(index, 1);
}
}
}
}
private isThresholdKey(d: DataKey, threshold: TimeSeriesChartThreshold): boolean {
return (d.type === DataKeyType.function && d.label === threshold.latestKey) ||
(d.type !== DataKeyType.function && d.name === threshold.latestKey &&
d.type === threshold.latestKeyType);
}
}

152
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.html

@ -0,0 +1,152 @@
<!--
Copyright © 2016-2024 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 class="tb-inline-field tb-key-field" subscriptSizing="dynamic">
<mat-chip-grid #chipList [formControl]="keysFormControl">
<mat-chip-row class="tb-datakey-chip" *ngIf="modelValue?.type"
(removed)="removeKey()">
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="4px" class="tb-attribute-chip">
<div class="tb-chip-labels">
<div class="tb-chip-label tb-chip-text tb-chip-icon">
<ng-container *ngIf="isEntityDatasource">
<mat-icon class="tb-mat-18 tb-datakey-icon" *ngIf="modelValue.type === DataKeyType.alarm"
matTooltip="{{'datakey.alarm' | translate }}"
matTooltipPosition="above">notifications</mat-icon>
<mat-icon class="tb-mat-18 tb-datakey-icon" *ngIf="modelValue.type === DataKeyType.attribute"
matTooltip="{{'datakey.attributes' | translate }}"
matTooltipPosition="above" svgIcon="mdi:alpha-a-circle-outline"></mat-icon>
<mat-icon class="tb-mat-18 tb-datakey-icon" *ngIf="modelValue.type === DataKeyType.entityField"
matTooltip="{{'datakey.entity-field' | translate }}"
matTooltipPosition="above" svgIcon="mdi:alpha-e-circle-outline"></mat-icon>
<mat-icon class="tb-mat-18 tb-datakey-icon" *ngIf="modelValue.type === DataKeyType.timeseries"
matTooltip="{{'datakey.timeseries' | translate }}"
matTooltipPosition="above">timeline</mat-icon>
</ng-container>
</div>
<div class="tb-chip-label tb-chip-text">
<strong>
<ng-container *ngTemplateOutlet="keyName"></ng-container>
</strong>
</div>
</div>
<button *ngIf="editable"
type="button"
(click)="editKey()" mat-icon-button class="tb-mat-24">
<mat-icon class="tb-mat-18">edit</mat-icon>
</button>
<button matChipRemove
type="button"
mat-icon-button class="tb-mat-24">
<mat-icon class="tb-mat-18">close</mat-icon>
</button>
</div>
</mat-chip-row>
<input matInput
type="text"
placeholder="{{ 'widget-config.set' | translate }}"
#keyInput
[required]="required"
[formControl]="keyFormControl"
matAutocompleteOrigin
[fxHide]="!!modelValue?.type"
[readonly]="!!modelValue?.type"
#origin="matAutocompleteOrigin"
[matAutocompleteConnectedTo]="origin"
(focusin)="onKeyInputFocus()"
(drop)="$event.preventDefault();"
[matAutocomplete]="keyAutocomplete"
[matChipInputFor]="chipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
(matChipInputTokenEnd)="addKey($event)"
/>
</mat-chip-grid>
<mat-autocomplete #keyAutocomplete="matAutocomplete"
class="tb-autocomplete"
panelWidth="fit-content"
[displayWith]="displayKeyFn">
<mat-option *ngFor="let key of filteredKeys | async" [value]="key">
<span style="white-space: nowrap;">
<ng-container *ngIf="isEntityDatasource">
<mat-icon class="tb-datakey-icon" *ngIf="key.type === DataKeyType.alarm"
matTooltip="{{'datakey.alarm' | translate }}"
matTooltipPosition="above">notifications</mat-icon>
<mat-icon class="tb-datakey-icon" *ngIf="key.type === DataKeyType.attribute"
matTooltip="{{'datakey.attributes' | translate }}"
matTooltipPosition="above" svgIcon="mdi:alpha-a-circle-outline"></mat-icon>
<mat-icon class="tb-datakey-icon" *ngIf="key.type === DataKeyType.entityField"
matTooltip="{{'datakey.entity-field' | translate }}"
matTooltipPosition="above" svgIcon="mdi:alpha-e-circle-outline"></mat-icon>
<mat-icon class="tb-datakey-icon" *ngIf="key.type === DataKeyType.timeseries"
matTooltip="{{'datakey.timeseries' | translate }}"
matTooltipPosition="above">timeline</mat-icon>
</ng-container>
<span [innerHTML]="key.name | highlight:keySearchText"></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(keySearchText); else searchNotEmpty">
<span translate>entity.no-keys-found</span>
</div>
<ng-template #searchNotEmpty>
<span>
{{ translate.get('entity.no-key-matching',
{key: truncate.transform(keySearchText, true, 6, &apos;...&apos;)}) | async }}
</span>
<span *ngIf="!isEntityDatasource; else createEntityKey">
<a translate (click)="createKey(keySearchText)">entity.create-new-key</a>
</span>
<ng-template #createEntityKey>
<span>{{'entity.create-new-key' | translate }} </span>
<mat-icon class="tb-datakey-icon new-key" *ngIf="allowedDataKeyTypes.includes(DataKeyType.alarm)"
matTooltip="{{'datakey.alarm' | translate }}"
matTooltipPosition="above"
(click)="createKey(keySearchText, DataKeyType.alarm)">notifications</mat-icon>
<mat-icon class="tb-datakey-icon new-key" *ngIf="allowedDataKeyTypes.includes(DataKeyType.attribute)"
matTooltip="{{'datakey.attributes' | translate }}"
matTooltipPosition="above" svgIcon="mdi:alpha-a-circle-outline"
(click)="createKey(keySearchText, DataKeyType.attribute)"></mat-icon>
<mat-icon class="tb-datakey-icon new-key" *ngIf="allowedDataKeyTypes.includes(DataKeyType.entityField)"
matTooltip="{{'datakey.entity-field' | translate }}"
matTooltipPosition="above" svgIcon="mdi:alpha-e-circle-outline"
(click)="createKey(keySearchText, DataKeyType.entityField)"></mat-icon>
<mat-icon class="tb-datakey-icon new-key" *ngIf="allowedDataKeyTypes.includes(DataKeyType.timeseries)"
matTooltip="{{'datakey.timeseries' | translate }}"
matTooltipPosition="above"
(click)="createKey(keySearchText, DataKeyType.timeseries)">timeline</mat-icon>
</ng-template>
</ng-template>
</div>
</mat-option>
</mat-autocomplete>
</mat-form-field>
<ng-template #keyName>
<ng-container *ngIf="dataKeyHasPostprocessing(); else keyName">
<span>f(</span><ng-container *ngTemplateOutlet="keyNameTemplate"></ng-container><span>)</span>
</ng-container>
<ng-template #keyName>
<ng-container *ngTemplateOutlet="keyNameTemplate"></ng-container>
</ng-template>
</ng-template>
<ng-template #keyNameTemplate>
<ng-container *ngIf="dataKeyHasAggregation(); else keyName;">
<span class="tb-agg-func">{{ modelValue?.aggregationType }}</span><span>({{ modelValue?.name }})</span>
</ng-container>
<ng-template #keyName>
<span>{{modelValue?.name}}</span>
</ng-template>
</ng-template>

46
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.scss

@ -0,0 +1,46 @@
/**
* Copyright © 2016-2024 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-data-key-input {
.mat-mdc-form-field.tb-inline-field.tb-key-field {
width: 100%;
.mat-mdc-text-field-wrapper:not(.mdc-text-field--outlined) {
padding-left: 8px;
padding-right: 0;
.mat-mdc-form-field-infix {
padding-top: 0;
padding-bottom: 6px;
.mdc-evolution-chip-set .mdc-evolution-chip {
margin: 0;
}
input.mat-mdc-chip-input {
height: 32px;
margin-left: 0;
}
}
}
.mat-mdc-chip.mat-mdc-standard-chip.tb-datakey-chip {
.tb-attribute-chip {
.tb-chip-labels {
background: transparent;
}
}
}
}
}

398
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.ts

@ -0,0 +1,398 @@
///
/// Copyright © 2016-2024 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 {
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
forwardRef,
HostBinding,
Input,
OnChanges,
OnInit,
Output,
SimpleChanges,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import {
ControlValueAccessor,
NG_VALUE_ACCESSOR,
UntypedFormBuilder,
UntypedFormControl,
Validators
} from '@angular/forms';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { COMMA, ENTER, SEMICOLON } from '@angular/cdk/keycodes';
import { MatAutocomplete, MatAutocompleteTrigger } from '@angular/material/autocomplete';
import { MatChipGrid, MatChipInputEvent } from '@angular/material/chips';
import { coerceBoolean } from '@shared/decorators/coercion';
import { DataKey, DatasourceType, widgetType } from '@shared/models/widget.models';
import { Observable, of } from 'rxjs';
import { MatDialog } from '@angular/material/dialog';
import { TranslateService } from '@ngx-translate/core';
import { TruncatePipe } from '@shared/pipe/truncate.pipe';
import { UtilsService } from '@core/services/utils.service';
import { alarmFields } from '@shared/models/alarm.models';
import { filter, map, mergeMap, publishReplay, refCount, share, tap } from 'rxjs/operators';
import { AggregationType } from '@shared/models/time/time.models';
import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models';
import { IAliasController } from '@core/api/widget-api.models';
@Component({
selector: 'tb-data-key-input',
templateUrl: './data-key-input.component.html',
styleUrls: ['./data-key-input.component.scss', '../../../config/data-keys.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DataKeyInputComponent),
multi: true
}
],
encapsulation: ViewEncapsulation.None
})
export class DataKeyInputComponent implements ControlValueAccessor, OnInit, OnChanges {
@HostBinding('class')
hostClass = 'tb-data-key-input';
DataKeyType = DataKeyType;
separatorKeysCodes: number[] = [ENTER, COMMA, SEMICOLON];
@ViewChild('keyInput') keyInput: ElementRef<HTMLInputElement>;
@ViewChild('keyAutocomplete') matAutocomplete: MatAutocomplete;
@ViewChild(MatAutocompleteTrigger) autocomplete: MatAutocompleteTrigger;
@ViewChild('chipList') chipList: MatChipGrid;
@Input()
disabled: boolean;
@Input()
@coerceBoolean()
required = false;
@Input()
@coerceBoolean()
isLatestDataKeys = false;
@Input()
@coerceBoolean()
editable = true;
@Input()
datasourceType: DatasourceType;
@Input()
entityAliasId: string;
@Input()
entityAlias: string;
@Input()
deviceId: string;
@Input()
widgetType: widgetType;
@Input()
callbacks: DataKeysCallbacks;
@Input()
aliasController: IAliasController;
@Input()
dataKeyType: DataKeyType;
@Input()
dataKeyTypes: DataKeyType[];
@Input()
generateKey: (key: DataKey) => DataKey = (key) => key;
@Output()
keyEdit = new EventEmitter<DataKey>();
keysFormControl: UntypedFormControl;
keyFormControl: UntypedFormControl;
modelValue: DataKey;
filteredKeys: Observable<Array<DataKey>>;
keySearchText = '';
alarmKeys: Array<DataKey>;
functionTypeKeys: Array<DataKey>;
allowedDataKeyTypes: DataKeyType[] = [];
private latestKeySearchTextResult: Array<DataKey> = null;
private keyFetchObservable$: Observable<Array<DataKey>> = null;
get isEntityDatasource(): boolean {
return [DatasourceType.device, DatasourceType.entity].includes(this.datasourceType);
}
private propagateChange = (_val: any) => {};
constructor(private fb: UntypedFormBuilder,
private dialog: MatDialog,
private cd: ChangeDetectorRef,
public translate: TranslateService,
public truncate: TruncatePipe,
private utils: UtilsService) {
}
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.keyFormControl = this.fb.control('');
this.keysFormControl = this.fb.control([], this.required ? [Validators.required] : []);
this.filteredKeys = this.keyFormControl.valueChanges
.pipe(
tap((value: string | DataKey) => {
if (value && typeof value !== 'string') {
this.addKeyFromChipValue(value);
} else if (value === null) {
this.clearKeyChip(this.keyInput.nativeElement.value);
}
}),
filter((value) => typeof value === 'string'),
map((value) => value ? (typeof value === 'string' ? value : value.name) : ''),
mergeMap(name => this.fetchKeys(name) ),
share()
);
this.updateAllowedDataKeys();
}
private updateAllowedDataKeys() {
this.allowedDataKeyTypes.length = 0;
if (this.dataKeyTypes?.length) {
this.allowedDataKeyTypes = this.allowedDataKeyTypes.concat(this.dataKeyTypes);
} else {
this.allowedDataKeyTypes = [DataKeyType.timeseries];
if (this.isLatestDataKeys || this.widgetType === widgetType.latest || this.widgetType === widgetType.alarm) {
this.allowedDataKeyTypes.push(DataKeyType.attribute);
this.allowedDataKeyTypes.push(DataKeyType.entityField);
if (this.widgetType === widgetType.alarm) {
this.allowedDataKeyTypes.push(DataKeyType.alarm);
}
}
}
}
private reset() {
if (this.keyInput) {
this.keyInput.nativeElement.value = '';
}
this.keyFormControl.patchValue('', {emitEvent: false});
this.latestKeySearchTextResult = null;
}
ngOnChanges(changes: SimpleChanges): void {
for (const propName of Object.keys(changes)) {
const change = changes[propName];
if (!change.firstChange && change.currentValue !== change.previousValue) {
if (['deviceId', 'entityAliasId', 'entityAlias', 'isLatestDataKeys'].includes(propName)) {
this.clearKeySearchCache();
if (propName === 'isLatestDataKeys') {
this.updateAllowedDataKeys();
if (!this.isLatestDataKeys) {
if (this.widgetType === widgetType.timeseries &&
this.modelValue?.type &&
this.modelValue.type !== DataKeyType.timeseries) {
setTimeout(() => {
this.modelValue = null;
this.updateModel();
this.clearKeyChip('', false);
}, 1);
}
}
}
} else if (['datasourceType'].includes(propName)) {
if ([DatasourceType.device, DatasourceType.entity].includes(change.previousValue) &&
[DatasourceType.device, DatasourceType.entity].includes(change.currentValue)) {
this.clearKeySearchCache();
} else {
this.clearKeySearchCache();
setTimeout(() => {
this.reset();
}, 1);
}
}
}
}
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (isDisabled) {
this.keysFormControl.disable({emitEvent: false});
} else {
this.keysFormControl.enable({emitEvent: false});
}
}
writeValue(value: DataKey): void {
this.modelValue = (value?.name && value?.type) ? value : null;
this.keysFormControl.patchValue(this.modelValue ? [this.modelValue] : [], {emitEvent: false});
this.cd.markForCheck();
}
dataKeyHasAggregation(): boolean {
return this.widgetType === widgetType.latest && this.modelValue?.type === DataKeyType.timeseries
&& this.modelValue?.aggregationType && this.modelValue?.aggregationType !== AggregationType.NONE;
}
dataKeyHasPostprocessing(): boolean {
return !!this.modelValue?.postFuncBody;
}
displayKeyFn(key?: DataKey): string | undefined {
return key ? key.name : undefined;
}
createKey(name: string, dataKeyType: DataKeyType = this.dataKeyType) {
this.addKeyFromChipValue({name: name ? name.trim() : '', type: dataKeyType});
}
addKey(event: MatChipInputEvent): void {
const value = event.value;
if ((value || '').trim() && this.dataKeyType) {
this.addKeyFromChipValue({name: value.trim(), type: this.dataKeyType});
} else {
this.clearKeyChip();
}
}
editKey() {
this.keyEdit.emit(this.modelValue);
}
removeKey() {
this.modelValue = null;
this.updateModel();
this.clearKeyChip();
}
textIsNotEmpty(text: string): boolean {
return text && text.length > 0;
}
clearKeyChip(value: string = '', focus = true) {
this.autocomplete.closePanel();
this.keyInput.nativeElement.value = value;
this.keyFormControl.patchValue(value, {emitEvent: focus});
if (focus) {
setTimeout(() => {
this.keyInput.nativeElement.blur();
this.keyInput.nativeElement.focus();
}, 0);
}
}
onKeyInputFocus() {
if (!this.modelValue?.type) {
this.keyFormControl.updateValueAndValidity({onlySelf: true, emitEvent: true});
}
}
private fetchKeys(searchText?: string): Observable<Array<DataKey>> {
if (this.keySearchText !== searchText || this.latestKeySearchTextResult === null) {
this.keySearchText = searchText;
const dataKeyFilter = this.createDataKeyFilter(this.keySearchText);
return this.getKeys().pipe(
map(name => name.filter(dataKeyFilter)),
tap(res => this.latestKeySearchTextResult = res)
);
}
return of(this.latestKeySearchTextResult);
}
private getKeys(): Observable<Array<DataKey>> {
if (this.keyFetchObservable$ === null) {
let fetchObservable: Observable<Array<DataKey>>;
if (this.datasourceType === DatasourceType.function) {
const targetKeysList = this.widgetType === widgetType.alarm ? this.alarmKeys : this.functionTypeKeys;
fetchObservable = of(targetKeysList);
} else if (this.datasourceType === DatasourceType.entity && (this.entityAliasId || this.entityAlias) ||
this.datasourceType === DatasourceType.device && this.deviceId) {
if (this.datasourceType === DatasourceType.device) {
fetchObservable = this.callbacks.fetchEntityKeysForDevice(this.deviceId, this.allowedDataKeyTypes);
} else {
let entityAliasId = this.entityAliasId;
if (!entityAliasId && this.entityAlias && this.aliasController) {
entityAliasId = this.aliasController.getEntityAliasId(this.entityAlias);
}
fetchObservable = entityAliasId ? this.callbacks.fetchEntityKeys(entityAliasId, this.allowedDataKeyTypes) : of([]);
}
} else {
fetchObservable = of([]);
}
this.keyFetchObservable$ = fetchObservable.pipe(
publishReplay(1),
refCount()
);
}
return this.keyFetchObservable$;
}
private createDataKeyFilter(query: string): (key: DataKey) => boolean {
const lowercaseQuery = query.toLowerCase();
return key => key.name.toLowerCase().startsWith(lowercaseQuery);
}
private addKeyFromChipValue(chip: DataKey) {
this.modelValue = this.generateKey(chip);
this.updateModel();
this.clearKeyChip('', false);
}
private clearKeySearchCache() {
this.keySearchText = '';
this.keyFetchObservable$ = null;
this.latestKeySearchTextResult = null;
}
private updateModel() {
this.keysFormControl.patchValue(this.modelValue ? [this.modelValue] : [], {emitEvent: false});
this.propagateChange(this.modelValue);
}
}

49
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.html

@ -0,0 +1,49 @@
<!--
Copyright © 2016-2024 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 class="tb-inline-field" appearance="outline" subscriptSizing="dynamic"
[class.tb-suffix-absolute]="required && !entityAliasFormControl.value">
<input [required]="required"
matInput type="text"
placeholder="{{ 'entity.entity-alias' | translate }}"
#entityAliasInput
[formControl]="entityAliasFormControl"
(focusin)="onEntityAliasFocus()"
[matAutocomplete]="entityAliasAutocomplete">
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'entity.alias-required' | translate"
*ngIf="required && entityAliasFormControl.hasError('required')
&& entityAliasFormControl.touched"
class="tb-error">
warning
</mat-icon>
<button *ngIf="entityAliasFormControl.value && !entityAliasFormControl.disabled"
type="button"
matSuffix mat-icon-button aria-label="Clear"
(click)="clearEntityAlias()">
<mat-icon class="material-icons">close</mat-icon>
</button>
<mat-autocomplete
class="tb-autocomplete"
#entityAliasAutocomplete="matAutocomplete">
<mat-option *ngFor="let entityAlias of filteredEntityAliases | async" [value]="entityAlias">
<span [innerHTML]="entityAlias | highlight:aliasSearchText"></span>
</mat-option>
</mat-autocomplete>
</mat-form-field>

20
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.scss

@ -0,0 +1,20 @@
/**
* Copyright © 2016-2024 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-entity-alias-input {
.mat-mdc-form-field.tb-inline-field {
width: 100%;
}
}

156
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.ts

@ -0,0 +1,156 @@
///
/// Copyright © 2016-2024 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 {
ChangeDetectorRef,
Component,
ElementRef,
forwardRef,
HostBinding,
Input,
OnInit,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import {
ControlValueAccessor,
NG_VALUE_ACCESSOR,
UntypedFormBuilder,
UntypedFormControl,
Validators
} from '@angular/forms';
import { coerceBoolean } from '@shared/decorators/coercion';
import { IAliasController } from '@core/api/widget-api.models';
import { map, mergeMap } from 'rxjs/operators';
import { Observable, of } from 'rxjs';
import { TimeSeriesChartThresholdType } from '@home/components/widget/lib/chart/time-series-chart.models';
@Component({
selector: 'tb-entity-alias-input',
templateUrl: './entity-alias-input.component.html',
styleUrls: ['./entity-alias-input.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => EntityAliasInputComponent),
multi: true
}
],
encapsulation: ViewEncapsulation.None
})
export class EntityAliasInputComponent implements ControlValueAccessor, OnInit {
@HostBinding('class')
hostClass = 'tb-entity-alias-input';
@ViewChild('entityAliasInput') entityAliasInput: ElementRef;
@Input()
disabled: boolean;
@Input()
@coerceBoolean()
required = false;
@Input()
aliasController: IAliasController;
entityAliasFormControl: UntypedFormControl;
filteredEntityAliases: Observable<Array<string>>;
aliasSearchText = '';
private entityAliasList: Array<string> = [];
private entityAliasDirty = false;
private propagateChange = (_val: any) => {};
constructor(private fb: UntypedFormBuilder,
private cd: ChangeDetectorRef) {
}
ngOnInit() {
this.entityAliasFormControl = this.fb.control(null, this.required ? [Validators.required] : []);
this.entityAliasFormControl.valueChanges.subscribe(
() => this.updateModel()
);
this.filteredEntityAliases = this.entityAliasFormControl.valueChanges
.pipe(
map(value => value ? value : ''),
mergeMap(name => this.fetchEntityAliases(name) )
);
if (this.aliasController) {
const entityAliases = this.aliasController.getEntityAliases();
for (const aliasId of Object.keys(entityAliases)) {
this.entityAliasList.push(entityAliases[aliasId].alias);
}
}
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (isDisabled) {
this.entityAliasFormControl.disable({emitEvent: false});
} else {
this.entityAliasFormControl.enable({emitEvent: false});
}
}
writeValue(value: string): void {
this.entityAliasFormControl.patchValue(value, {emitEvent: false});
this.entityAliasDirty = true;
}
onEntityAliasFocus() {
if (this.entityAliasDirty) {
this.entityAliasFormControl.updateValueAndValidity({onlySelf: true});
this.entityAliasDirty = false;
}
}
clearEntityAlias() {
this.entityAliasFormControl.patchValue(null, {emitEvent: true});
setTimeout(() => {
this.entityAliasInput.nativeElement.blur();
this.entityAliasInput.nativeElement.focus();
}, 0);
}
private fetchEntityAliases(searchText?: string): Observable<Array<string>> {
this.aliasSearchText = searchText;
let result = this.entityAliasList;
if (searchText && searchText.length) {
result = this.entityAliasList.filter((entityAlias) => entityAlias.toLowerCase().includes(searchText.toLowerCase()));
}
return of(result);
}
private updateModel() {
const value = this.entityAliasFormControl.value;
this.propagateChange(value);
}
protected readonly TimeSeriesChartThresholdType = TimeSeriesChartThresholdType;
}

25
ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts

@ -95,6 +95,17 @@ import {
import {
TimeSeriesChartAxisSettingsComponent
} from '@home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component';
import {
TimeSeriesChartThresholdsPanelComponent
} from '@home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component';
import {
TimeSeriesChartThresholdRowComponent
} from '@home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component';
import { DataKeyInputComponent } from '@home/components/widget/lib/settings/common/data-key-input.component';
import { EntityAliasInputComponent } from '@home/components/widget/lib/settings/common/entity-alias-input.component';
import {
TimeSeriesChartThresholdSettingsPanelComponent
} from '@home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component';
@NgModule({
declarations: [
@ -131,7 +142,12 @@ import {
WidgetButtonAppearanceComponent,
WidgetButtonCustomStyleComponent,
WidgetButtonCustomStylePanelComponent,
TimeSeriesChartAxisSettingsComponent
TimeSeriesChartAxisSettingsComponent,
TimeSeriesChartThresholdsPanelComponent,
TimeSeriesChartThresholdRowComponent,
TimeSeriesChartThresholdSettingsPanelComponent,
DataKeyInputComponent,
EntityAliasInputComponent
],
imports: [
CommonModule,
@ -172,7 +188,12 @@ import {
WidgetButtonAppearanceComponent,
WidgetButtonCustomStyleComponent,
WidgetButtonCustomStylePanelComponent,
TimeSeriesChartAxisSettingsComponent
TimeSeriesChartAxisSettingsComponent,
TimeSeriesChartThresholdsPanelComponent,
TimeSeriesChartThresholdRowComponent,
TimeSeriesChartThresholdSettingsPanelComponent,
DataKeyInputComponent,
EntityAliasInputComponent
],
providers: [
ColorSettingsComponentService,

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

@ -6615,6 +6615,37 @@
"shape-pin": "Pin",
"shape-arrow": "Arrow",
"shape-none": "None",
"threshold": {
"thresholds": "Thresholds",
"source": "Source",
"key-value": "Key / Value",
"no-thresholds": "No thresholds configured",
"add-threshold": "Add threshold",
"type-constant": "Constant",
"type-latest-key": "Key",
"type-entity": "Entity",
"threshold-settings": "Threshold settings",
"remove-threshold": "Remove threshold",
"threshold-value-required": "Threshold value is required.",
"line-appearance": "Line appearance",
"line-color": "Line color",
"start-symbol": "Start symbol",
"end-symbol": "End symbol",
"symbol-size": "size",
"label": "Label",
"label-position-start": "Start",
"label-position-middle": "Middle",
"label-position-end": "End",
"label-position-inside-start": "Inside start",
"label-position-inside-start-top": "Inside start top",
"label-position-inside-start-bottom": "Inside start bottom",
"label-position-inside-middle": "Inside middle",
"label-position-inside-middle-top": "Inside middle top",
"label-position-inside-middle-bottom": "Inside middle bottom",
"label-position-inside-end": "Inside end",
"label-position-inside-end-top": "Inside end top",
"label-position-inside-end-bottom": "Inside end bottom"
},
"axis": {
"axes": "Axes",
"x-axis": "X axis",

Loading…
Cancel
Save