diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html index 4fd7de467f..14cf0c7e11 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html +++ b/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"> -
-
TODO: Thresholds
-
+ +
widget-config.appearance
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.html index 1c4767b963..3ef6891808 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.html @@ -22,123 +22,19 @@ {{ 'datakey.latest' | translate }} - - - -
-
-
- - notifications - - - timeline - -
-
- - - -
-
- - -
-
- -
- - - - - notifications - - - timeline - - - - - -
-
- entity.no-keys-found -
- - - {{ translate.get('entity.no-key-matching', - {key: truncate.transform(keySearchText, true, 6, '...')}) | async }} - - - entity.create-new-key - - - {{'entity.create-new-key' | translate }} - notifications - - - timeline - - -
-
-
-
+ + @@ -193,19 +89,3 @@
- - - f() - - - - - - - - {{ modelValue?.aggregationType }}({{ modelValue?.name }}) - - - {{modelValue?.name}} - - diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.scss b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.scss index 198fa64518..be91ff173c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.scss +++ b/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} { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.ts index d862a51566..b2710db346 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.ts +++ b/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; - @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>; - - keySearchText = ''; - - alarmKeys: Array; - functionTypeKeys: Array; - - private latestKeySearchTextResult: Array = null; - private keyFetchObservable$: Observable> = 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, { @@ -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> { - 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> { - if (this.keyFetchObservable$ === null) { - let fetchObservable: Observable>; - 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}; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts index 6b2e3a5c6b..dfcd1aaced 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts +++ b/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.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.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, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-bar-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-bar-settings.component.html index a62157dba5..447784091e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-bar-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-bar-settings.component.html @@ -49,6 +49,8 @@ +
+
+ + + + {{ timeSeriesThresholdTypeTranslations.get(type) | translate }} + + + + + +
+
+ + + + warning + + + + + + +
+
+ + +
+
+ + +
+
+ + + +
+
+
+ +
+ +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.scss new file mode 100644 index 0000000000..2a7dafc5f1 --- /dev/null +++ b/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; + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.ts new file mode 100644 index 0000000000..e93969f795 --- /dev/null +++ b/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); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component.html new file mode 100644 index 0000000000..db4cc27bc9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component.html @@ -0,0 +1,121 @@ + +
+
{{ 'widgets.time-series-chart.threshold.threshold-settings' | translate }}
+
+
+
widgets.time-series-chart.threshold.line-appearance
+
+
widgets.time-series-chart.threshold.line-color
+ + +
+
+
widgets.time-series-chart.line-type
+ + + + {{ timeSeriesLineTypeTranslations.get(lineType) | translate }} + + + +
+
+
widgets.time-series-chart.line-width
+ + + +
+
+
widgets.time-series-chart.threshold.start-symbol
+
+ + + + {{ timeSeriesChartShapeTranslations.get(shape) | translate }} + + + +
widgets.time-series-chart.threshold.symbol-size
+ + + +
+
+
+
widgets.time-series-chart.threshold.end-symbol
+
+ + + + {{ timeSeriesChartShapeTranslations.get(shape) | translate }} + + + +
widgets.time-series-chart.threshold.symbol-size
+ + + +
+
+
+ + {{ 'widgets.time-series-chart.threshold.label' | translate }} + +
+ + + + {{ timeSeriesThresholdLabelPositionTranslations.get(position) | translate }} + + + + + + + +
+
+
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component.scss new file mode 100644 index 0000000000..260b199854 --- /dev/null +++ b/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; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component.ts new file mode 100644 index 0000000000..a93a1c0181 --- /dev/null +++ b/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; + + @Input() + widgetConfig: WidgetConfig; + + @Input() + popover: TbPopoverComponent; + + @Output() + thresholdSettingsApplied = new EventEmitter>(); + + 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); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.html new file mode 100644 index 0000000000..811106fc7b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.html @@ -0,0 +1,47 @@ + +
+
{{ 'widgets.time-series-chart.threshold.thresholds' | translate }}
+
+
+
widgets.time-series-chart.threshold.source
+
widgets.time-series-chart.threshold.key-value
+
widgets.color.color
+
widget-config.units-short
+
widget-config.decimals-short
+
+
+
+
+ + +
+
+
+
+ +
+
+ + {{ 'widgets.time-series-chart.threshold.no-thresholds' | translate }} + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.scss new file mode 100644 index 0000000000..d1f1ec6eed --- /dev/null +++ b/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; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.ts new file mode 100644 index 0000000000..40db98ba4b --- /dev/null +++ b/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({} 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 = []; + 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); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.html new file mode 100644 index 0000000000..072c637641 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.html @@ -0,0 +1,152 @@ + + + + +
+
+
+ + notifications + + + timeline + +
+
+ + + +
+
+ + +
+
+ +
+ + + + + notifications + + + timeline + + + + + +
+
+ entity.no-keys-found +
+ + + {{ translate.get('entity.no-key-matching', + {key: truncate.transform(keySearchText, true, 6, '...')}) | async }} + + + entity.create-new-key + + + {{'entity.create-new-key' | translate }} + notifications + + + timeline + + +
+
+
+
+ + + f() + + + + + + + + {{ modelValue?.aggregationType }}({{ modelValue?.name }}) + + + {{modelValue?.name}} + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.scss new file mode 100644 index 0000000000..6d443d0057 --- /dev/null +++ b/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; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.ts new file mode 100644 index 0000000000..04908bdd7b --- /dev/null +++ b/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; + @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(); + + keysFormControl: UntypedFormControl; + + keyFormControl: UntypedFormControl; + + modelValue: DataKey; + + filteredKeys: Observable>; + + keySearchText = ''; + + alarmKeys: Array; + functionTypeKeys: Array; + + allowedDataKeyTypes: DataKeyType[] = []; + + private latestKeySearchTextResult: Array = null; + private keyFetchObservable$: Observable> = 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> { + 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> { + if (this.keyFetchObservable$ === null) { + let fetchObservable: Observable>; + 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); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.html new file mode 100644 index 0000000000..78d0533865 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.html @@ -0,0 +1,49 @@ + + + + + warning + + + + + + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.scss new file mode 100644 index 0000000000..baccbf2ab7 --- /dev/null +++ b/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%; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.ts new file mode 100644 index 0000000000..dd6d19f14e --- /dev/null +++ b/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>; + aliasSearchText = ''; + + private entityAliasList: Array = []; + 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> { + 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; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts index a7922dfdc4..e741314315 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts +++ b/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, diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 4a05e1c3c1..29fbafe83c 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/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",