25 changed files with 2049 additions and 394 deletions
@ -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> |
||||
@ -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; |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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> |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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> |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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, '...')}) | 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> |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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> |
||||
@ -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%; |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
Loading…
Reference in new issue