15 changed files with 1257 additions and 121 deletions
@ -0,0 +1,62 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2023 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. |
|||
|
|||
--> |
|||
<ng-container [formGroup]="entitiesTableWidgetConfigForm"> |
|||
<tb-timewindow-config-panel *ngIf="displayTimewindowConfig" |
|||
[onlyHistoryTimewindow]="onlyHistoryTimewindow()" |
|||
formControlName="timewindowConfig"> |
|||
</tb-timewindow-config-panel> |
|||
<tb-datasources |
|||
[configMode]="basicMode" |
|||
hideDataKeys |
|||
formControlName="datasources"> |
|||
</tb-datasources> |
|||
<tb-data-keys-panel |
|||
panelTitle="{{ 'widgets.table.columns' | translate }}" |
|||
addKeyTitle="{{ 'widgets.table.add-column' | translate }}" |
|||
removeKeyTitle="{{ 'widgets.table.remove-column' | translate }}" |
|||
noKeysText="{{ 'widgets.table.no-columns' | translate }}" |
|||
[datasourceType]="datasource?.type" |
|||
[deviceId]="datasource?.deviceId" |
|||
[entityAliasId]="datasource?.entityAliasId" |
|||
formControlName="columns"> |
|||
</tb-data-keys-panel> |
|||
<div class="tb-widget-config-panel"> |
|||
<div class="tb-widget-config-panel-title" translate>widget-config.appearance</div> |
|||
<div class="tb-widget-config-row space-between same-padding"> |
|||
<div>{{ 'widget-config.text-color' | translate }}</div> |
|||
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="16px"> |
|||
<mat-divider vertical></mat-divider> |
|||
<tb-color-input asBoxInput |
|||
formControlName="color"> |
|||
</tb-color-input> |
|||
</div> |
|||
</div> |
|||
<div class="tb-widget-config-row space-between same-padding"> |
|||
<div>{{ 'widget-config.background' | translate }}</div> |
|||
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="16px"> |
|||
<mat-divider vertical></mat-divider> |
|||
<tb-color-input asBoxInput |
|||
formControlName="backgroundColor"> |
|||
</tb-color-input> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<tb-widget-actions-panel |
|||
formControlName="actions"> |
|||
</tb-widget-actions-panel> |
|||
</ng-container> |
|||
@ -0,0 +1,107 @@ |
|||
///
|
|||
/// Copyright © 2016-2023 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 } from '@angular/core'; |
|||
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models'; |
|||
import { WidgetConfigComponentData } from '@home/models/widget-component.models'; |
|||
import { |
|||
DataKey, |
|||
Datasource, |
|||
datasourcesHasAggregation, |
|||
datasourcesHasOnlyComparisonAggregation |
|||
} from '@shared/models/widget.models'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-entities-table-basic-config', |
|||
templateUrl: './entities-table-basic-config.component.html', |
|||
styleUrls: ['../basic-config.scss', '../../widget-config.scss'] |
|||
}) |
|||
export class EntitiesTableBasicConfigComponent extends BasicWidgetConfigComponent { |
|||
|
|||
public get displayTimewindowConfig(): boolean { |
|||
const datasources = this.entitiesTableWidgetConfigForm.get('datasources').value; |
|||
return datasourcesHasAggregation(datasources); |
|||
} |
|||
|
|||
public onlyHistoryTimewindow(): boolean { |
|||
const datasources = this.entitiesTableWidgetConfigForm.get('datasources').value; |
|||
return datasourcesHasOnlyComparisonAggregation(datasources); |
|||
} |
|||
|
|||
public get datasource(): Datasource { |
|||
const datasources: Datasource[] = this.entitiesTableWidgetConfigForm.get('datasources').value; |
|||
if (datasources && datasources.length) { |
|||
return datasources[0]; |
|||
} else { |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
entitiesTableWidgetConfigForm: UntypedFormGroup; |
|||
|
|||
constructor(protected store: Store<AppState>, |
|||
private fb: UntypedFormBuilder) { |
|||
super(store); |
|||
} |
|||
|
|||
protected configForm(): UntypedFormGroup { |
|||
return this.entitiesTableWidgetConfigForm; |
|||
} |
|||
|
|||
protected onConfigSet(configData: WidgetConfigComponentData) { |
|||
this.entitiesTableWidgetConfigForm = this.fb.group({ |
|||
timewindowConfig: [{ |
|||
useDashboardTimewindow: configData.config.useDashboardTimewindow, |
|||
displayTimewindow: configData.config.useDashboardTimewindow, |
|||
timewindow: configData.config.timewindow |
|||
}, []], |
|||
datasources: [configData.config.datasources, []], |
|||
columns: [this.getColumns(configData.config.datasources), []], |
|||
color: [configData.config.color, []], |
|||
backgroundColor: [configData.config.backgroundColor, []], |
|||
actions: [configData.config.actions || {}, []] |
|||
}); |
|||
} |
|||
|
|||
protected prepareOutputConfig(config: any): WidgetConfigComponentData { |
|||
this.widgetConfig.config.useDashboardTimewindow = config.timewindowConfig.useDashboardTimewindow; |
|||
this.widgetConfig.config.displayTimewindow = config.timewindowConfig.displayTimewindow; |
|||
this.widgetConfig.config.timewindow = config.timewindowConfig.timewindow; |
|||
this.widgetConfig.config.datasources = config.datasources; |
|||
this.setColumns(config.columns, this.widgetConfig.config.datasources); |
|||
this.widgetConfig.config.actions = config.actions; |
|||
this.widgetConfig.config.color = config.color; |
|||
this.widgetConfig.config.backgroundColor = config.backgroundColor; |
|||
return this.widgetConfig; |
|||
} |
|||
|
|||
private getColumns(datasources?: Datasource[]): DataKey[] { |
|||
if (datasources && datasources.length) { |
|||
return datasources[0].dataKeys || []; |
|||
} |
|||
return []; |
|||
} |
|||
|
|||
private setColumns(columns: DataKey[], datasources?: Datasource[]) { |
|||
if (datasources && datasources.length) { |
|||
datasources[0].dataKeys = columns; |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,155 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2023 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]="keyRowFormGroup" class="tb-data-key-row"> |
|||
<mat-form-field fxFlex class="tb-inline-field tb-key-field" subscriptSizing="dynamic"> |
|||
<mat-chip-grid #chipList> |
|||
<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"> |
|||
<ng-container *ngIf="isEntityDatasource"> |
|||
<mat-icon class="tb-mat-18 tb-datakey-icon" *ngIf="modelValue.type === dataKeyTypes.alarm" |
|||
matTooltip="{{'datakey.alarm' | translate }}" |
|||
matTooltipPosition="above">notifications</mat-icon> |
|||
<mat-icon class="tb-mat-18 tb-datakey-icon" *ngIf="modelValue.type === dataKeyTypes.attribute" |
|||
matTooltip="{{'datakey.attributes' | translate }}" |
|||
matTooltipPosition="above" svgIcon="mdi:alpha-a-circle-outline"></mat-icon> |
|||
<mat-icon class="tb-mat-18 tb-datakey-icon" *ngIf="modelValue.type === dataKeyTypes.entityField" |
|||
matTooltip="{{'datakey.entity-field' | translate }}" |
|||
matTooltipPosition="above" svgIcon="mdi:alpha-e-circle-outline"></mat-icon> |
|||
<mat-icon class="tb-mat-18 tb-datakey-icon" *ngIf="modelValue.type === dataKeyTypes.timeseries" |
|||
matTooltip="{{'datakey.timeseries' | translate }}" |
|||
matTooltipPosition="above">timeline</mat-icon> |
|||
</ng-container> |
|||
</div> |
|||
<div class="tb-chip-label"> |
|||
<strong> |
|||
<ng-container *ngTemplateOutlet="keyName"></ng-container> |
|||
</strong> |
|||
</div> |
|||
</div> |
|||
<button type="button" |
|||
(click)="editKey()" mat-icon-button class="tb-mat-24"> |
|||
<mat-icon class="tb-mat-18">edit</mat-icon> |
|||
</button> |
|||
<button matChipRemove |
|||
type="button" |
|||
mat-icon-button class="tb-mat-24"> |
|||
<mat-icon class="tb-mat-18">close</mat-icon> |
|||
</button> |
|||
</div> |
|||
</mat-chip-row> |
|||
<input matInput |
|||
type="text" |
|||
placeholder="{{ 'widget-config.set' | translate }}" |
|||
#keyInput |
|||
[formControl]="keyFormControl" |
|||
matAutocompleteOrigin |
|||
[fxHide]="!!modelValue.type" |
|||
[readonly]="!!modelValue.type" |
|||
#origin="matAutocompleteOrigin" |
|||
[matAutocompleteConnectedTo]="origin" |
|||
(focusin)="onKeyInputFocus()" |
|||
(drop)="$event.preventDefault();" |
|||
[matAutocomplete]="keyAutocomplete" |
|||
[matChipInputFor]="chipList" |
|||
[matChipInputSeparatorKeyCodes]="separatorKeysCodes" |
|||
(matChipInputTokenEnd)="addKey($event)" |
|||
/> |
|||
</mat-chip-grid> |
|||
<mat-autocomplete #keyAutocomplete="matAutocomplete" |
|||
class="tb-autocomplete" |
|||
panelWidth="fit-content" |
|||
[displayWith]="displayKeyFn"> |
|||
<mat-option *ngFor="let key of filteredKeys | async" [value]="key"> |
|||
<span style="white-space: nowrap;"> |
|||
<ng-container *ngIf="isEntityDatasource"> |
|||
<mat-icon class="tb-datakey-icon" *ngIf="key.type === dataKeyTypes.alarm" |
|||
matTooltip="{{'datakey.alarm' | translate }}" |
|||
matTooltipPosition="above">notifications</mat-icon> |
|||
<mat-icon class="tb-datakey-icon" *ngIf="key.type === dataKeyTypes.attribute" |
|||
matTooltip="{{'datakey.attributes' | translate }}" |
|||
matTooltipPosition="above" svgIcon="mdi:alpha-a-circle-outline"></mat-icon> |
|||
<mat-icon class="tb-datakey-icon" *ngIf="key.type === dataKeyTypes.entityField" |
|||
matTooltip="{{'datakey.entity-field' | translate }}" |
|||
matTooltipPosition="above" svgIcon="mdi:alpha-e-circle-outline"></mat-icon> |
|||
<mat-icon class="tb-datakey-icon" *ngIf="key.type === dataKeyTypes.timeseries" |
|||
matTooltip="{{'datakey.timeseries' | translate }}" |
|||
matTooltipPosition="above">timeline</mat-icon> |
|||
</ng-container> |
|||
<span [innerHTML]="key.name | highlight:keySearchText"></span> |
|||
</span> |
|||
</mat-option> |
|||
<mat-option *ngIf="!(filteredKeys | async)?.length" [value]="null" class="tb-not-found"> |
|||
<div class="tb-not-found-content" (click)="$event.stopPropagation()"> |
|||
<div *ngIf="!textIsNotEmpty(keySearchText); else searchNotEmpty"> |
|||
<span translate>entity.no-keys-found</span> |
|||
</div> |
|||
<ng-template #searchNotEmpty> |
|||
<span> |
|||
{{ translate.get('entity.no-key-matching', |
|||
{key: truncate.transform(keySearchText, true, 6, '...')}) | async }} |
|||
</span> |
|||
<span *ngIf="!isEntityDatasource; else createEntityKey"> |
|||
<a translate (click)="createKey(keySearchText)">entity.create-new-key</a> |
|||
</span> |
|||
<ng-template #createEntityKey> |
|||
<span>{{'entity.create-new-key' | translate }} </span> |
|||
<mat-icon class="tb-datakey-icon new-key" *ngIf="widgetType === widgetTypes.alarm" |
|||
matTooltip="{{'datakey.alarm' | translate }}" |
|||
matTooltipPosition="above" |
|||
(click)="createKey(keySearchText, dataKeyTypes.alarm)">notifications</mat-icon> |
|||
<mat-icon class="tb-datakey-icon new-key" *ngIf="widgetType === widgetTypes.latest || widgetType === widgetTypes.alarm" |
|||
matTooltip="{{'datakey.attributes' | translate }}" |
|||
matTooltipPosition="above" svgIcon="mdi:alpha-a-circle-outline" |
|||
(click)="createKey(keySearchText, dataKeyTypes.attribute)"></mat-icon> |
|||
<mat-icon class="tb-datakey-icon new-key" *ngIf="widgetType === widgetTypes.latest || widgetType === widgetTypes.alarm" |
|||
matTooltip="{{'datakey.entity-field' | translate }}" |
|||
matTooltipPosition="above" svgIcon="mdi:alpha-e-circle-outline" |
|||
(click)="createKey(keySearchText, dataKeyTypes.entityField)"></mat-icon> |
|||
<mat-icon class="tb-datakey-icon new-key" |
|||
matTooltip="{{'datakey.timeseries' | translate }}" |
|||
matTooltipPosition="above" |
|||
(click)="createKey(keySearchText, dataKeyTypes.timeseries)">timeline</mat-icon> |
|||
</ng-template> |
|||
</ng-template> |
|||
</div> |
|||
</mat-option> |
|||
</mat-autocomplete> |
|||
</mat-form-field> |
|||
<mat-form-field fxFlex class="tb-inline-field" appearance="outline" subscriptSizing="dynamic"> |
|||
<input matInput formControlName="label" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
</mat-form-field> |
|||
</div> |
|||
<ng-template #keyName> |
|||
<ng-container *ngIf="dataKeyHasPostprocessing(); else keyName"> |
|||
<span>f(</span><ng-container *ngTemplateOutlet="keyNameTemplate"></ng-container><span>)</span> |
|||
</ng-container> |
|||
<ng-template #keyName> |
|||
<ng-container *ngTemplateOutlet="keyNameTemplate"></ng-container> |
|||
</ng-template> |
|||
</ng-template> |
|||
<ng-template #keyNameTemplate> |
|||
<ng-container *ngIf="dataKeyHasAggregation(); else keyName;"> |
|||
<span class="tb-agg-func">{{ modelValue?.aggregationType }}</span><span>({{ modelValue?.name }})</span> |
|||
</ng-container> |
|||
<ng-template #keyName> |
|||
<span>{{modelValue?.name}}</span> |
|||
</ng-template> |
|||
</ng-template> |
|||
@ -0,0 +1,45 @@ |
|||
/** |
|||
* Copyright © 2016-2023 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-row { |
|||
height: 38px; |
|||
display: flex; |
|||
flex-direction: row; |
|||
gap: 12px; |
|||
padding-left: 12px; |
|||
|
|||
.mat-mdc-form-field.tb-inline-field.tb-key-field { |
|||
.mat-mdc-text-field-wrapper:not(.mdc-text-field--outlined) { |
|||
.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,365 @@ |
|||
///
|
|||
/// Copyright © 2016-2023 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, |
|||
Input, |
|||
OnChanges, |
|||
OnInit, |
|||
SimpleChanges, |
|||
ViewChild, |
|||
ViewEncapsulation |
|||
} from '@angular/core'; |
|||
import { |
|||
AbstractControl, |
|||
ControlValueAccessor, |
|||
NG_VALUE_ACCESSOR, |
|||
UntypedFormBuilder, |
|||
UntypedFormControl, |
|||
UntypedFormGroup, |
|||
ValidationErrors |
|||
} from '@angular/forms'; |
|||
import { MatDialog } from '@angular/material/dialog'; |
|||
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; |
|||
import { DataKey, DatasourceType, JsonSettingsSchema, widgetType } from '@shared/models/widget.models'; |
|||
import { DataKeysPanelComponent } from '@home/components/widget/config/basic/common/data-keys-panel.component'; |
|||
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 } 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'; |
|||
|
|||
export const dataKeyRowValidator = (control: AbstractControl): ValidationErrors | null => { |
|||
const dataKey: DataKey = control.value; |
|||
if (!dataKey || !dataKey.type || !dataKey.name) { |
|||
return { |
|||
dataKey: true |
|||
}; |
|||
} |
|||
return null; |
|||
}; |
|||
|
|||
@Component({ |
|||
selector: 'tb-data-key-row', |
|||
templateUrl: './data-key-row.component.html', |
|||
styleUrls: ['./data-key-row.component.scss', '../../data-keys.component.scss', '../../widget-config.scss'], |
|||
providers: [ |
|||
{ |
|||
provide: NG_VALUE_ACCESSOR, |
|||
useExisting: forwardRef(() => DataKeyRowComponent), |
|||
multi: true |
|||
} |
|||
], |
|||
encapsulation: ViewEncapsulation.None |
|||
}) |
|||
export class DataKeyRowComponent implements ControlValueAccessor, OnInit, OnChanges { |
|||
|
|||
dataKeyTypes = DataKeyType; |
|||
widgetTypes = widgetType; |
|||
|
|||
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() |
|||
datasourceType: DatasourceType; |
|||
|
|||
@Input() |
|||
entityAliasId: string; |
|||
|
|||
@Input() |
|||
deviceId: string; |
|||
|
|||
keyFormControl: UntypedFormControl; |
|||
|
|||
keyRowFormGroup: UntypedFormGroup; |
|||
|
|||
modelValue: DataKey; |
|||
|
|||
filteredKeys: Observable<Array<DataKey>>; |
|||
|
|||
keySearchText = ''; |
|||
|
|||
private latestKeySearchTextResult: Array<DataKey> = null; |
|||
private keyFetchObservable$: Observable<Array<DataKey>> = null; |
|||
|
|||
get dataKeyType(): DataKeyType { |
|||
return this.dataKeysPanelComponent.dataKeyType; |
|||
} |
|||
|
|||
get alarmKeys(): Array<DataKey> { |
|||
return this.dataKeysPanelComponent.alarmKeys; |
|||
} |
|||
|
|||
get functionTypeKeys(): Array<DataKey> { |
|||
return this.dataKeysPanelComponent.functionTypeKeys; |
|||
} |
|||
|
|||
get widgetType(): widgetType { |
|||
return this.widgetConfigComponent.widgetType; |
|||
} |
|||
|
|||
get callbacks(): DataKeysCallbacks { |
|||
return this.widgetConfigComponent.widgetConfigCallbacks; |
|||
} |
|||
|
|||
get datakeySettingsSchema(): JsonSettingsSchema { |
|||
return this.widgetConfigComponent.modelValue?.dataKeySettingsSchema; |
|||
} |
|||
|
|||
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 dataKeysPanelComponent: DataKeysPanelComponent, |
|||
private widgetConfigComponent: WidgetConfigComponent) { |
|||
} |
|||
|
|||
ngOnInit() { |
|||
this.keyFormControl = this.fb.control(''); |
|||
this.keyRowFormGroup = this.fb.group({ |
|||
label: [null, []], |
|||
color: [null, []], |
|||
units: [null, []], |
|||
decimals: [null, []], |
|||
}); |
|||
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 { |
|||
this.propagateChange = fn; |
|||
} |
|||
|
|||
registerOnTouched(fn: any): void { |
|||
} |
|||
|
|||
setDisabledState(isDisabled: boolean): void { |
|||
this.disabled = isDisabled; |
|||
if (isDisabled) { |
|||
this.keyRowFormGroup.disable({emitEvent: false}); |
|||
} else { |
|||
this.keyRowFormGroup.enable({emitEvent: false}); |
|||
} |
|||
} |
|||
|
|||
writeValue(value: DataKey): void { |
|||
this.modelValue = value || {} as DataKey; |
|||
this.keyRowFormGroup.patchValue( |
|||
{ |
|||
label: value?.label, |
|||
color: value?.color, |
|||
units: value?.units, |
|||
decimals: value?.decimals |
|||
}, {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() { |
|||
|
|||
} |
|||
|
|||
removeKey() { |
|||
this.modelValue = {} as DataKey; |
|||
this.updateModel(); |
|||
this.clearKeyChip(); |
|||
} |
|||
|
|||
textIsNotEmpty(text: string): boolean { |
|||
return text && text.length > 0; |
|||
} |
|||
|
|||
clearKeyChip(value: string = '', focus = true) { |
|||
this.autocomplete.closePanel(); |
|||
this.keyInput.nativeElement.value = value; |
|||
this.keyFormControl.patchValue(value, {emitEvent: focus}); |
|||
if (focus) { |
|||
setTimeout(() => { |
|||
this.keyInput.nativeElement.blur(); |
|||
this.keyInput.nativeElement.focus(); |
|||
}, 0); |
|||
} |
|||
} |
|||
|
|||
onKeyInputFocus() { |
|||
if (!this.modelValue.type) { |
|||
this.keyFormControl.updateValueAndValidity({onlySelf: true, emitEvent: true}); |
|||
} |
|||
} |
|||
|
|||
private fetchKeys(searchText?: string): Observable<Array<DataKey>> { |
|||
if (this.keySearchText !== searchText || this.latestKeySearchTextResult === null) { |
|||
this.keySearchText = searchText; |
|||
const dataKeyFilter = this.createDataKeyFilter(this.keySearchText); |
|||
return this.getKeys().pipe( |
|||
map(name => name.filter(dataKeyFilter)), |
|||
tap(res => this.latestKeySearchTextResult = res) |
|||
); |
|||
} |
|||
return of(this.latestKeySearchTextResult); |
|||
} |
|||
|
|||
private getKeys(): Observable<Array<DataKey>> { |
|||
if (this.keyFetchObservable$ === null) { |
|||
let fetchObservable: Observable<Array<DataKey>>; |
|||
if (this.datasourceType === DatasourceType.function) { |
|||
const targetKeysList = this.widgetType === widgetType.alarm ? this.alarmKeys : this.functionTypeKeys; |
|||
fetchObservable = of(targetKeysList); |
|||
} else if (this.datasourceType === DatasourceType.entity && this.entityAliasId || |
|||
this.datasourceType === DatasourceType.device && this.deviceId) { |
|||
const dataKeyTypes = [DataKeyType.timeseries]; |
|||
if (this.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); |
|||
if (!this.keyRowFormGroup.get('label').value) { |
|||
this.keyRowFormGroup.get('label').patchValue(this.modelValue.label, {emitEvent: false}); |
|||
} |
|||
this.updateModel(); |
|||
this.clearKeyChip('', false); |
|||
} |
|||
|
|||
private clearKeySearchCache() { |
|||
this.keySearchText = ''; |
|||
this.keyFetchObservable$ = null; |
|||
this.latestKeySearchTextResult = null; |
|||
} |
|||
|
|||
private updateModel() { |
|||
const value: DataKey = this.keyRowFormGroup.value; |
|||
this.modelValue = {...this.modelValue, ...value}; |
|||
this.propagateChange(this.modelValue); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,66 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2023 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-widget-config-panel"> |
|||
<div class="tb-widget-config-panel-title">{{ panelTitle }}</div> |
|||
<div class="tb-data-keys-table"> |
|||
<div class="tb-data-keys-header"> |
|||
<div class="tb-data-keys-header-cell" fxFlex translate>datakey.key</div> |
|||
<div class="tb-data-keys-header-cell" fxFlex translate>datakey.label</div> |
|||
<div class="tb-data-keys-header-cell" translate>datakey.color</div> |
|||
<div class="tb-data-keys-header-cell" translate>widget-config.units-short</div> |
|||
<div class="tb-data-keys-header-cell" translate>widget-config.decimals-short</div> |
|||
</div> |
|||
<div *ngIf="keysFormArray().controls.length; else noKeys" class="tb-data-keys-body tb-drop-list" cdkDropList cdkDropListOrientation="vertical" |
|||
(cdkDropListDropped)="keyDrop($event)"> |
|||
<div cdkDrag class="tb-data-keys-table-row tb-draggable" *ngFor="let keyControl of keysFormArray().controls; trackBy: trackByKey; |
|||
let $index = index;"> |
|||
<tb-data-key-row fxFlex |
|||
[formControl]="keyControl" |
|||
[datasourceType]="datasourceType" |
|||
[deviceId]="deviceId" |
|||
[entityAliasId]="entityAliasId"> |
|||
</tb-data-key-row> |
|||
<div class="tb-data-keys-table-row-buttons"> |
|||
<button type="button" |
|||
mat-icon-button |
|||
(click)="removeKey($index)" |
|||
[matTooltip]="removeKeyTitle" |
|||
matTooltipPosition="above"> |
|||
<mat-icon>delete</mat-icon> |
|||
</button> |
|||
<button mat-icon-button |
|||
type="button" |
|||
cdkDragHandle |
|||
matTooltip="{{ 'action.drag' | translate }}" |
|||
matTooltipPosition="above"> |
|||
<mat-icon>drag_indicator</mat-icon> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div> |
|||
<button type="button" mat-stroked-button color="primary" (click)="addKey()"> |
|||
{{ addKeyTitle }} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
<ng-template #noKeys> |
|||
<span fxLayoutAlign="center center" |
|||
class="tb-prompt">{{ noKeysText }}</span> |
|||
</ng-template> |
|||
@ -0,0 +1,71 @@ |
|||
/** |
|||
* Copyright © 2016-2023 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-keys-table { |
|||
border: 1px solid rgba(0, 0, 0, 0.12); |
|||
border-radius: 6px; |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 12px; |
|||
padding-bottom: 12px; |
|||
.tb-data-keys-header { |
|||
height: 48px; |
|||
border-bottom: 1px solid rgba(0, 0, 0, 0.12); |
|||
display: flex; |
|||
flex-direction: row; |
|||
place-content: center flex-start; |
|||
align-items: center; |
|||
gap: 12px; |
|||
padding-left: 12px; |
|||
padding-right: 12px; |
|||
.tb-data-keys-header-cell { |
|||
font-weight: 400; |
|||
font-size: 14px; |
|||
line-height: 20px; |
|||
letter-spacing: 0.2px; |
|||
color: rgba(0, 0, 0, 0.54); |
|||
} |
|||
} |
|||
.tb-data-keys-body { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 12px; |
|||
} |
|||
.tb-prompt { |
|||
height: 38px; |
|||
} |
|||
} |
|||
|
|||
.tb-data-keys-table-row { |
|||
height: 38px; |
|||
display: flex; |
|||
flex-direction: row; |
|||
gap: 12px; |
|||
background: #fff; |
|||
|
|||
.tb-data-keys-table-row-buttons { |
|||
display: flex; |
|||
flex-direction: row; |
|||
button.mat-mdc-icon-button.mat-mdc-button-base { |
|||
padding: 7px; |
|||
width: 38px; |
|||
height: 38px; |
|||
.mat-icon { |
|||
color: rgba(0, 0, 0, 0.38); |
|||
} |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,234 @@ |
|||
///
|
|||
/// Copyright © 2016-2023 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, |
|||
forwardRef, |
|||
Input, |
|||
OnChanges, |
|||
OnInit, |
|||
SimpleChanges, |
|||
ViewEncapsulation |
|||
} from '@angular/core'; |
|||
import { |
|||
AbstractControl, |
|||
ControlValueAccessor, |
|||
NG_VALIDATORS, |
|||
NG_VALUE_ACCESSOR, |
|||
UntypedFormArray, |
|||
UntypedFormBuilder, |
|||
UntypedFormControl, |
|||
UntypedFormGroup, |
|||
Validator |
|||
} from '@angular/forms'; |
|||
import { MatDialog } from '@angular/material/dialog'; |
|||
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; |
|||
import { DataKey, DatasourceType, JsonSettingsSchema, widgetType } from '@shared/models/widget.models'; |
|||
import { dataKeyRowValidator } from '@home/components/widget/config/basic/common/data-key-row.component'; |
|||
import { CdkDragDrop } from '@angular/cdk/drag-drop'; |
|||
import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; |
|||
import { alarmFields } from '@shared/models/alarm.models'; |
|||
import { UtilsService } from '@core/services/utils.service'; |
|||
import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-data-keys-panel', |
|||
templateUrl: './data-keys-panel.component.html', |
|||
styleUrls: ['./data-keys-panel.component.scss', '../../widget-config.scss'], |
|||
providers: [ |
|||
{ |
|||
provide: NG_VALUE_ACCESSOR, |
|||
useExisting: forwardRef(() => DataKeysPanelComponent), |
|||
multi: true |
|||
}, |
|||
{ |
|||
provide: NG_VALIDATORS, |
|||
useExisting: forwardRef(() => DataKeysPanelComponent), |
|||
multi: true |
|||
} |
|||
], |
|||
encapsulation: ViewEncapsulation.None |
|||
}) |
|||
export class DataKeysPanelComponent implements ControlValueAccessor, OnInit, OnChanges, Validator { |
|||
|
|||
@Input() |
|||
disabled: boolean; |
|||
|
|||
@Input() |
|||
panelTitle: string; |
|||
|
|||
@Input() |
|||
addKeyTitle: string; |
|||
|
|||
@Input() |
|||
removeKeyTitle: string; |
|||
|
|||
@Input() |
|||
noKeysText: string; |
|||
|
|||
@Input() |
|||
datasourceType: DatasourceType; |
|||
|
|||
@Input() |
|||
entityAliasId: string; |
|||
|
|||
@Input() |
|||
deviceId: string; |
|||
|
|||
dataKeyType: DataKeyType; |
|||
alarmKeys: Array<DataKey>; |
|||
functionTypeKeys: Array<DataKey>; |
|||
|
|||
keysListFormGroup: UntypedFormGroup; |
|||
|
|||
get widgetType(): widgetType { |
|||
return this.widgetConfigComponent.widgetType; |
|||
} |
|||
|
|||
get callbacks(): DataKeysCallbacks { |
|||
return this.widgetConfigComponent.widgetConfigCallbacks; |
|||
} |
|||
|
|||
get datakeySettingsSchema(): JsonSettingsSchema { |
|||
return this.widgetConfigComponent.modelValue?.dataKeySettingsSchema; |
|||
} |
|||
|
|||
private propagateChange = (_val: any) => {}; |
|||
|
|||
constructor(private fb: UntypedFormBuilder, |
|||
private dialog: MatDialog, |
|||
private cd: ChangeDetectorRef, |
|||
private utils: UtilsService, |
|||
private widgetConfigComponent: WidgetConfigComponent) { |
|||
} |
|||
|
|||
ngOnInit() { |
|||
this.keysListFormGroup = this.fb.group({ |
|||
keys: [this.fb.array([]), []] |
|||
}); |
|||
this.keysListFormGroup.valueChanges.subscribe( |
|||
(val) => this.propagateChange(this.keysListFormGroup.get('keys').value) |
|||
); |
|||
this.alarmKeys = []; |
|||
for (const name of Object.keys(alarmFields)) { |
|||
this.alarmKeys.push({ |
|||
name, |
|||
type: DataKeyType.alarm |
|||
}); |
|||
} |
|||
this.functionTypeKeys = []; |
|||
for (const type of this.utils.getPredefinedFunctionsList()) { |
|||
this.functionTypeKeys.push({ |
|||
name: type, |
|||
type: DataKeyType.function |
|||
}); |
|||
} |
|||
this.updateParams(); |
|||
} |
|||
|
|||
ngOnChanges(changes: SimpleChanges): void { |
|||
for (const propName of Object.keys(changes)) { |
|||
const change = changes[propName]; |
|||
if (!change.firstChange && change.currentValue !== change.previousValue) { |
|||
if (['datasourceType'].includes(propName)) { |
|||
this.updateParams(); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
private updateParams() { |
|||
if (this.datasourceType === DatasourceType.function) { |
|||
this.dataKeyType = DataKeyType.function; |
|||
} else { |
|||
if (this.widgetType !== widgetType.latest && this.widgetType !== widgetType.alarm) { |
|||
this.dataKeyType = DataKeyType.timeseries; |
|||
} else { |
|||
this.dataKeyType = null; |
|||
} |
|||
} |
|||
} |
|||
|
|||
registerOnChange(fn: any): void { |
|||
this.propagateChange = fn; |
|||
} |
|||
|
|||
registerOnTouched(fn: any): void { |
|||
} |
|||
|
|||
setDisabledState(isDisabled: boolean): void { |
|||
this.disabled = isDisabled; |
|||
if (isDisabled) { |
|||
this.keysListFormGroup.disable({emitEvent: false}); |
|||
} else { |
|||
this.keysListFormGroup.enable({emitEvent: false}); |
|||
} |
|||
} |
|||
|
|||
writeValue(value: DataKey[] | undefined): void { |
|||
this.keysListFormGroup.setControl('keys', this.prepareKeysFormArray(value), {emitEvent: false}); |
|||
} |
|||
|
|||
public validate(c: UntypedFormControl) { |
|||
return this.keysListFormGroup.valid ? null : { |
|||
dataKeyRows: { |
|||
valid: false, |
|||
}, |
|||
}; |
|||
} |
|||
|
|||
keyDrop(event: CdkDragDrop<string[]>) { |
|||
const keysArray = this.keysListFormGroup.get('keys') as UntypedFormArray; |
|||
const key = keysArray.at(event.previousIndex); |
|||
keysArray.removeAt(event.previousIndex); |
|||
keysArray.insert(event.currentIndex, key); |
|||
} |
|||
|
|||
keysFormArray(): UntypedFormArray { |
|||
return this.keysListFormGroup.get('keys') as UntypedFormArray; |
|||
} |
|||
|
|||
trackByKey(index: number, keyControl: AbstractControl): any { |
|||
return keyControl; |
|||
} |
|||
|
|||
removeKey(index: number) { |
|||
(this.keysListFormGroup.get('keys') as UntypedFormArray).removeAt(index); |
|||
} |
|||
|
|||
addKey() { |
|||
const dataKey = this.callbacks.generateDataKey('', null, this.datakeySettingsSchema); |
|||
const keysArray = this.keysListFormGroup.get('keys') as UntypedFormArray; |
|||
const keyControl = this.fb.control(dataKey, [dataKeyRowValidator]); |
|||
keysArray.push(keyControl); |
|||
this.keysListFormGroup.updateValueAndValidity(); |
|||
if (!this.keysListFormGroup.valid) { |
|||
this.propagateChange(this.keysListFormGroup.get('keys').value); |
|||
} |
|||
} |
|||
|
|||
private prepareKeysFormArray(keys: DataKey[] | undefined): UntypedFormArray { |
|||
const keysControls: Array<AbstractControl> = []; |
|||
if (keys) { |
|||
keys.forEach((key) => { |
|||
keysControls.push(this.fb.control(key, [dataKeyRowValidator])); |
|||
}); |
|||
} |
|||
return this.fb.array(keysControls); |
|||
} |
|||
|
|||
} |
|||
Loading…
Reference in new issue