12 changed files with 909 additions and 19 deletions
@ -0,0 +1,168 @@ |
|||||
|
<!-- |
||||
|
|
||||
|
Copyright © 2016-2025 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="w-full max-w-xl" [formGroup]="metricForm"> |
||||
|
<div class="tb-form-panel no-border no-padding mb-2"> |
||||
|
<div class="tb-form-panel-title">{{ 'calculated-fields.metrics.metric-settings' | translate }}</div> |
||||
|
<div class="tb-form-panel no-border no-padding"> |
||||
|
<div class="tb-form-row"> |
||||
|
<div class="fixed-title-width tb-required">{{ 'calculated-fields.metrics.metric-name' | translate }}</div> |
||||
|
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic"> |
||||
|
<input matInput autocomplete="new-name" name="value" formControlName="name" maxlength="255" placeholder="{{ 'action.set' | translate }}"/> |
||||
|
@if (metricForm.get('name').touched && metricForm.get('name').hasError('required')) { |
||||
|
<mat-icon matSuffix |
||||
|
matTooltipPosition="above" |
||||
|
matTooltipClass="tb-error-tooltip" |
||||
|
[matTooltip]="'calculated-fields.hint.name-required' | translate" |
||||
|
class="tb-error"> |
||||
|
warning |
||||
|
</mat-icon> |
||||
|
} @else if (metricForm.get('name').touched && metricForm.get('name').hasError('duplicateName')) { |
||||
|
<mat-icon matSuffix |
||||
|
matTooltipPosition="above" |
||||
|
matTooltipClass="tb-error-tooltip" |
||||
|
[matTooltip]="'calculated-fields.hint.name-duplicate' | translate" |
||||
|
class="tb-error"> |
||||
|
warning |
||||
|
</mat-icon> |
||||
|
} @else if (metricForm.get('name').touched && metricForm.get('name').hasError('pattern')) { |
||||
|
<mat-icon matSuffix |
||||
|
matTooltipPosition="above" |
||||
|
matTooltipClass="tb-error-tooltip" |
||||
|
[matTooltip]="'calculated-fields.hint.name-pattern' | translate" |
||||
|
class="tb-error"> |
||||
|
warning |
||||
|
</mat-icon> |
||||
|
} @else if (metricForm.get('name').touched && metricForm.get('name').hasError('maxlength')) { |
||||
|
<mat-icon matSuffix |
||||
|
matTooltipPosition="above" |
||||
|
matTooltipClass="tb-error-tooltip" |
||||
|
[matTooltip]="'calculated-fields.hint.name-max-length' | translate" |
||||
|
class="tb-error"> |
||||
|
warning |
||||
|
</mat-icon> |
||||
|
} @else if (metricForm.get('name').touched && metricForm.get('name').hasError('forbiddenName')) { |
||||
|
<mat-icon matSuffix |
||||
|
matTooltipPosition="above" |
||||
|
matTooltipClass="tb-error-tooltip" |
||||
|
[matTooltip]="'calculated-fields.hint.name-forbidden' | translate" |
||||
|
class="tb-error"> |
||||
|
warning |
||||
|
</mat-icon> |
||||
|
} |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
<div class="tb-form-row"> |
||||
|
<div class="fixed-title-width">{{ 'calculated-fields.metrics.aggregation' | translate }}</div> |
||||
|
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic"> |
||||
|
<mat-select formControlName="function"> |
||||
|
@for (aggFunction of AggFunctions; track aggFunction) { |
||||
|
<mat-option [value]="aggFunction">{{ AggFunctionTranslations.get(aggFunction) | translate }}</mat-option> |
||||
|
} |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
|
||||
|
<div class="tb-form-panel stroked tb-slide-toggle"> |
||||
|
<mat-expansion-panel class="tb-settings" [(expanded)]="filterExpanded" |
||||
|
[disabled]="!metricForm.get('allowFilter').value"> |
||||
|
<mat-expansion-panel-header class="flex flex-row flex-wrap"> |
||||
|
<mat-panel-title> |
||||
|
<mat-slide-toggle class="mat-slide flex items-stretch justify-center" formControlName="allowFilter" (click)="$event.stopPropagation()"> |
||||
|
<div tb-hint-tooltip-icon="{{ 'calculated-fields.metrics.filter-hint' | translate }}"> |
||||
|
{{ 'calculated-fields.metrics.filter' | translate }} |
||||
|
</div> |
||||
|
</mat-slide-toggle> |
||||
|
</mat-panel-title> |
||||
|
</mat-expansion-panel-header> |
||||
|
<ng-template matExpansionPanelContent> |
||||
|
<tb-js-func required |
||||
|
formControlName="filter" |
||||
|
functionName="filter" |
||||
|
[functionArgs]="functionArgs" |
||||
|
[disableUndefinedCheck]="true" |
||||
|
[scriptLanguage]="ScriptLanguage.TBEL" |
||||
|
[highlightRules]="highlightRules" |
||||
|
[editorCompleter]="editorCompleter" |
||||
|
[helpPopupStyle]="{ width: '1200px' }" |
||||
|
helpId="calculated-field/expression_fn"> |
||||
|
<div toolbarPrefixButton |
||||
|
class="tb-primary-background tbel-script-lang-chip">{{ 'api-usage.tbel' | translate }} |
||||
|
</div> |
||||
|
</tb-js-func> |
||||
|
</ng-template> |
||||
|
</mat-expansion-panel> |
||||
|
</div> |
||||
|
<ng-container formGroupName="input"> |
||||
|
<div class="tb-form-row"> |
||||
|
<div class="fixed-title-width">{{ 'calculated-fields.metrics.value-source' | translate }}</div> |
||||
|
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic"> |
||||
|
<mat-select formControlName="type"> |
||||
|
@for (inputType of AggInputTypes; track inputType) { |
||||
|
<mat-option [value]="inputType">{{ AggInputTypeTranslations.get(inputType) | translate }}</mat-option> |
||||
|
} |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
@if (this.metricForm.get('input.type').value === AggInputType.key) { |
||||
|
<div class="tb-form-row"> |
||||
|
<div class="fixed-title-width tb-required">{{ 'calculated-fields.argument-name' | translate }}</div> |
||||
|
<tb-string-autocomplete [fetchOptionsFn]="fetchOptions.bind(this)" |
||||
|
additionalClass="tb-suffix-show-on-hover" |
||||
|
class="flex-1" |
||||
|
appearance="outline" |
||||
|
panelWidth="" |
||||
|
required |
||||
|
[errorText]="'calculated-fields.hint.argument-name-required' | translate" |
||||
|
formControlName="key"> |
||||
|
</tb-string-autocomplete> |
||||
|
</div> |
||||
|
} @else { |
||||
|
<tb-js-func required |
||||
|
formControlName="function" |
||||
|
functionName="filter" |
||||
|
[functionArgs]="functionArgs" |
||||
|
[disableUndefinedCheck]="true" |
||||
|
[scriptLanguage]="ScriptLanguage.TBEL" |
||||
|
[highlightRules]="highlightRules" |
||||
|
[editorCompleter]="editorCompleter" |
||||
|
[helpPopupStyle]="{ width: '1200px' }" |
||||
|
helpId="calculated-field/expression_fn"> |
||||
|
<div toolbarPrefixButton |
||||
|
class="tb-primary-background tbel-script-lang-chip">{{ 'api-usage.tbel' | translate }} |
||||
|
</div> |
||||
|
</tb-js-func> |
||||
|
} |
||||
|
</ng-container> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="flex justify-end gap-2"> |
||||
|
<button mat-button |
||||
|
color="primary" |
||||
|
type="button" |
||||
|
(click)="cancel()"> |
||||
|
{{ 'action.cancel' | translate }} |
||||
|
</button> |
||||
|
<button mat-raised-button |
||||
|
color="primary" |
||||
|
type="button" |
||||
|
(click)="saveZone()" |
||||
|
[disabled]="metricForm.invalid || !metricForm.dirty"> |
||||
|
{{ buttonTitle | translate }} |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
@ -0,0 +1,31 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2025 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'; |
||||
|
|
||||
|
$panel-width: 520px; |
||||
|
|
||||
|
:host { |
||||
|
display: flex; |
||||
|
width: $panel-width; |
||||
|
max-width: 100%; |
||||
|
max-height: 80vh; |
||||
|
|
||||
|
.fixed-title-width { |
||||
|
@media #{$mat-xs} { |
||||
|
min-width: 120px; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,176 @@ |
|||||
|
///
|
||||
|
/// Copyright © 2016-2025 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, Input, OnInit, output } from '@angular/core'; |
||||
|
import { TbPopoverComponent } from '@shared/components/popover.component'; |
||||
|
import { FormBuilder, FormControl, ValidatorFn, Validators } from '@angular/forms'; |
||||
|
import { charsWithNumRegex } from '@shared/models/regex.constants'; |
||||
|
import { |
||||
|
AggFunction, |
||||
|
AggFunctionTranslations, |
||||
|
AggInputType, |
||||
|
AggInputTypeTranslations, |
||||
|
CalculatedFieldAggMetricValue |
||||
|
} from '@shared/models/calculated-field.models'; |
||||
|
import { delay, map } from 'rxjs/operators'; |
||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
||||
|
import { EntityFilter } from '@shared/models/query/query.models'; |
||||
|
import { merge, Observable, of } from 'rxjs'; |
||||
|
import { ScriptLanguage } from '@shared/models/rule-node.models'; |
||||
|
import { TbEditorCompleter } from '@shared/models/ace/completion.models'; |
||||
|
import { AceHighlightRules } from '@shared/models/ace/ace.models'; |
||||
|
|
||||
|
interface CalculatedFieldAggMetricValuePanel extends CalculatedFieldAggMetricValue { |
||||
|
allowFilter: boolean; |
||||
|
} |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'tb-calculated-field-metrics-panel', |
||||
|
templateUrl: './calculated-field-metrics-panel.component.html', |
||||
|
styleUrls: ['./calculated-field-metrics-panel.component.scss'] |
||||
|
}) |
||||
|
export class CalculatedFieldMetricsPanelComponent implements OnInit { |
||||
|
|
||||
|
@Input() buttonTitle: string; |
||||
|
@Input() metric: CalculatedFieldAggMetricValue; |
||||
|
@Input() usedNames: string[]; |
||||
|
@Input() arguments: Array<string>; |
||||
|
@Input() editorCompleter: TbEditorCompleter; |
||||
|
@Input() highlightRules: AceHighlightRules; |
||||
|
|
||||
|
metricDataApplied = output<CalculatedFieldAggMetricValue>(); |
||||
|
filterExpanded = false; |
||||
|
functionArgs: Array<string> |
||||
|
|
||||
|
metricForm = this.fb.group({ |
||||
|
name: ['', [Validators.required, this.uniqNameRequired(), this.forbiddenNameValidator(), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]], |
||||
|
function: [AggFunction.AVG], |
||||
|
allowFilter: [false], |
||||
|
filter: ['', Validators.required], |
||||
|
input: this.fb.group({ |
||||
|
type: [AggInputType.key], |
||||
|
key: ['', Validators.required], |
||||
|
function: ['', Validators.required], |
||||
|
}) |
||||
|
}); |
||||
|
|
||||
|
entityFilter: EntityFilter; |
||||
|
|
||||
|
readonly AggFunctions = Object.values(AggFunction) as AggFunction[]; |
||||
|
readonly AggFunctionTranslations = AggFunctionTranslations; |
||||
|
readonly ScriptLanguage = ScriptLanguage; |
||||
|
readonly AggInputType = AggInputType; |
||||
|
readonly AggInputTypes = Object.values(AggInputType) as AggInputType[]; |
||||
|
readonly AggInputTypeTranslations = AggInputTypeTranslations; |
||||
|
|
||||
|
constructor( |
||||
|
private fb: FormBuilder, |
||||
|
private popover: TbPopoverComponent<CalculatedFieldMetricsPanelComponent> |
||||
|
) { |
||||
|
this.observeUpdatePosition(); |
||||
|
this.observeFilterAllowChange(); |
||||
|
this.observeInputTypeChange(); |
||||
|
} |
||||
|
|
||||
|
ngOnInit(): void { |
||||
|
const data: CalculatedFieldAggMetricValuePanel = { |
||||
|
...this.metric, |
||||
|
allowFilter: !!this.metric.filter, |
||||
|
} |
||||
|
this.metricForm.patchValue(data, {emitEvent: false}); |
||||
|
|
||||
|
this.validateFilter(data.allowFilter); |
||||
|
this.validateInputTypeFilter(data.input?.type ?? AggInputType.key); |
||||
|
|
||||
|
this.functionArgs = ['ctx', ...this.arguments]; |
||||
|
} |
||||
|
|
||||
|
fetchOptions(searchText: string): Observable<Array<string>> { |
||||
|
const search = searchText ? searchText?.toLowerCase() : ''; |
||||
|
return of(this.arguments).pipe(map(name => name?.filter(option => option.toLowerCase().includes(search)))); |
||||
|
} |
||||
|
|
||||
|
private observeFilterAllowChange(): void { |
||||
|
this.metricForm.get('allowFilter').valueChanges |
||||
|
.pipe(takeUntilDestroyed()) |
||||
|
.subscribe(value => this.validateFilter(value)); |
||||
|
} |
||||
|
|
||||
|
private observeInputTypeChange(): void { |
||||
|
this.metricForm.get('input.type').valueChanges |
||||
|
.pipe(takeUntilDestroyed()) |
||||
|
.subscribe(value => this.validateInputTypeFilter(value)); |
||||
|
} |
||||
|
|
||||
|
private validateFilter(allowFilter = false): void { |
||||
|
if (allowFilter) { |
||||
|
this.metricForm.get('filter').enable({emitEvent: false}); |
||||
|
} else { |
||||
|
this.metricForm.get('filter').disable({emitEvent: false}); |
||||
|
} |
||||
|
this.filterExpanded = allowFilter; |
||||
|
} |
||||
|
|
||||
|
private validateInputTypeFilter(value: AggInputType): void { |
||||
|
const inputForm = this.metricForm.get('input'); |
||||
|
if (value === AggInputType.key) { |
||||
|
inputForm.get('key').enable({emitEvent: false}); |
||||
|
inputForm.get('function').disable({emitEvent: false}); |
||||
|
} else { |
||||
|
inputForm.get('key').disable({emitEvent: false}); |
||||
|
inputForm.get('function').enable({emitEvent: false}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
saveZone(): void { |
||||
|
const value = this.metricForm.value as CalculatedFieldAggMetricValuePanel; |
||||
|
if (!value.allowFilter) { |
||||
|
delete value.filter; |
||||
|
} |
||||
|
delete value.allowFilter; |
||||
|
this.metricDataApplied.emit(value); |
||||
|
} |
||||
|
|
||||
|
cancel(): void { |
||||
|
this.popover.hide(); |
||||
|
} |
||||
|
|
||||
|
private uniqNameRequired(): ValidatorFn { |
||||
|
return (control: FormControl) => { |
||||
|
const newName = control.value.trim().toLowerCase(); |
||||
|
const isDuplicate = this.usedNames?.some(name => name.toLowerCase() === newName); |
||||
|
|
||||
|
return isDuplicate ? { duplicateName: true } : null; |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private forbiddenNameValidator(): ValidatorFn { |
||||
|
return (control: FormControl) => { |
||||
|
const trimmedValue = control.value.trim().toLowerCase(); |
||||
|
const forbiddenNames = ['ctx', 'e', 'pi']; |
||||
|
return forbiddenNames.includes(trimmedValue) ? { forbiddenName: true } : null; |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private observeUpdatePosition(): void { |
||||
|
merge( |
||||
|
this.metricForm.get('allowFilter').valueChanges, |
||||
|
this.metricForm.get('input.type').valueChanges |
||||
|
) |
||||
|
.pipe(delay(50), takeUntilDestroyed()) |
||||
|
.subscribe(() => this.popover.updatePosition()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,111 @@ |
|||||
|
<!-- |
||||
|
|
||||
|
Copyright © 2016-2025 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="flex flex-col gap-3"> |
||||
|
<div class="tb-form-panel stroked no-padding no-gap arguments-table flex flex-col" [class.arguments-table-with-error]="errorText"> |
||||
|
<table mat-table [dataSource]="dataSource" class="overflow-hidden bg-transparent" matSort |
||||
|
[matSortActive]="sortOrder.property" [matSortDirection]="sortOrder.direction" matSortDisableClear> |
||||
|
<ng-container [matColumnDef]="'name'"> |
||||
|
<mat-header-cell mat-sort-header *matHeaderCellDef class="!w-1/3 xs:!w-1/2"> |
||||
|
<div tbTruncateWithTooltip>{{ 'calculated-fields.metrics.metric-name' | translate }}</div> |
||||
|
</mat-header-cell> |
||||
|
<mat-cell *matCellDef="let metric" class="argument-name-cell w-1/3 xs:w-1/2"> |
||||
|
<div class="flex items-center"> |
||||
|
<div tbTruncateWithTooltip class="flex-1">{{ metric.name }}</div> |
||||
|
<tb-copy-button class="copy-argument-name" |
||||
|
[copyText]="metric.name" |
||||
|
tooltipText="{{ 'calculated-fields.metrics.copy-metric-name' | translate }}" |
||||
|
tooltipPosition="above" |
||||
|
icon="content_copy"/> |
||||
|
</div> |
||||
|
</mat-cell> |
||||
|
</ng-container> |
||||
|
<ng-container [matColumnDef]="'function'"> |
||||
|
<mat-header-cell mat-sort-header *matHeaderCellDef class="entity-type-header w-1/5 xs:hidden"> |
||||
|
{{ 'calculated-fields.metrics.aggregation' | translate }} |
||||
|
</mat-header-cell> |
||||
|
<mat-cell *matCellDef="let metric" class="w-1/5 xs:hidden"> |
||||
|
<div tbTruncateWithTooltip>{{ AggFunctionTranslations.get(metric.function) | translate }}</div> |
||||
|
</mat-cell> |
||||
|
</ng-container> |
||||
|
<ng-container [matColumnDef]="'filter'"> |
||||
|
<mat-header-cell mat-sort-header *matHeaderCellDef class="entity-type-header w-1/5 xs:hidden"> |
||||
|
{{ 'calculated-fields.metrics.filtered' | translate }} |
||||
|
</mat-header-cell> |
||||
|
<mat-cell *matCellDef="let metric" class="w-1/5 xs:hidden"> |
||||
|
<mat-icon>{{ metric.filter ? 'check_box' : 'check_box_outline_blank' }}</mat-icon> |
||||
|
</mat-cell> |
||||
|
</ng-container> |
||||
|
<ng-container [matColumnDef]="'valueSource'"> |
||||
|
<mat-header-cell *matHeaderCellDef class="w-1/4 xs:hidden"> |
||||
|
{{ 'calculated-fields.metrics.value-source' | translate }} |
||||
|
</mat-header-cell> |
||||
|
<mat-cell *matCellDef="let metric" class="w-1/4 xs:hidden"> |
||||
|
<div tbTruncateWithTooltip>{{ AggInputTypeTranslations.get(metric.input.type) | translate }}</div> |
||||
|
</mat-cell> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="actions" stickyEnd> |
||||
|
<mat-header-cell *matHeaderCellDef class="w-20 min-w-20"/> |
||||
|
<mat-cell *matCellDef="let metric;"> |
||||
|
<div class="tb-form-table-row-cell-buttons flex w-20 min-w-20"> |
||||
|
<button type="button" |
||||
|
mat-icon-button |
||||
|
#button |
||||
|
(click)="manageMetrics($event, button, metric)" |
||||
|
[matTooltip]="'action.edit' | translate" |
||||
|
matTooltipPosition="above"> |
||||
|
<mat-icon>edit</mat-icon> |
||||
|
</button> |
||||
|
<button type="button" |
||||
|
mat-icon-button |
||||
|
(click)="onDelete($event, metric)" |
||||
|
[matTooltip]="'action.delete' | translate" |
||||
|
matTooltipPosition="above"> |
||||
|
<mat-icon>delete</mat-icon> |
||||
|
</button> |
||||
|
</div> |
||||
|
</mat-cell> |
||||
|
</ng-container> |
||||
|
<mat-header-row class="mat-row-select" *matHeaderRowDef=displayColumns></mat-header-row> |
||||
|
<mat-row *matRowDef="let argument; columns: displayColumns"></mat-row> |
||||
|
</table> |
||||
|
<div [class.!hidden]="(dataSource.isEmpty() | async) === false" |
||||
|
class="tb-prompt flex flex-1 items-end justify-center"> |
||||
|
{{ 'calculated-fields.metrics.no-metrics-configured' | translate }} |
||||
|
</div> |
||||
|
@if (errorText) { |
||||
|
<tb-error noMargin [error]="errorText | translate" class="flex h-9 items-center pl-3"/> |
||||
|
} |
||||
|
</div> |
||||
|
<div class="flex h-9 justify-between"> |
||||
|
<button type="button" |
||||
|
mat-stroked-button |
||||
|
color="primary" |
||||
|
#button |
||||
|
(click)="manageMetrics($event, button)" |
||||
|
[disabled]="maxArgumentsPerCF > 0 && metricsFormArray.length >= maxArgumentsPerCF"> |
||||
|
{{ 'calculated-fields.metrics.add-metric' | translate }} |
||||
|
</button> |
||||
|
@if (maxArgumentsPerCF && metricsFormArray.length >= maxArgumentsPerCF) { |
||||
|
<div class="tb-form-hint tb-primary-fill max-args-warning flex items-center gap-2"> |
||||
|
<mat-icon>warning</mat-icon> |
||||
|
<span>{{ 'calculated-fields.metrics.max-metrics' | translate }}</span> |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
@ -0,0 +1,76 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2025 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
:host { |
||||
|
.arguments-table { |
||||
|
min-height: 108px; |
||||
|
|
||||
|
&-with-error { |
||||
|
min-height: 150px; |
||||
|
} |
||||
|
|
||||
|
.mat-mdc-table { |
||||
|
table-layout: fixed; |
||||
|
} |
||||
|
|
||||
|
.key-text { |
||||
|
font-size: 13px; |
||||
|
} |
||||
|
|
||||
|
.copy-argument-name { |
||||
|
visibility: hidden; |
||||
|
transition: visibility 0.1s; |
||||
|
} |
||||
|
|
||||
|
.argument-name-cell:hover { |
||||
|
.copy-argument-name { |
||||
|
visibility: visible; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.max-args-warning { |
||||
|
.mat-icon { |
||||
|
color: #FAA405; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.tb-form-table-row-cell-buttons { |
||||
|
--mat-badge-legacy-small-size-container-size: 8px; |
||||
|
--mat-badge-small-size-container-overlap-offset: -5px; |
||||
|
--mat-badge-small-size-text-size: 0; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
:host ::ng-deep { |
||||
|
.arguments-table:not(.arguments-table-with-error) { |
||||
|
.mdc-data-table__row:last-child .mat-mdc-cell { |
||||
|
border-bottom: none; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.arguments-table { |
||||
|
.mat-mdc-header-row.mat-row-select .mat-mdc-header-cell.entity-type-header { |
||||
|
padding: 0 28px 0 0; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.copy-argument-name { |
||||
|
.mat-icon { |
||||
|
font-size: 16px; |
||||
|
padding: 4px; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,242 @@ |
|||||
|
///
|
||||
|
/// Copyright © 2016-2025 The Thingsboard Authors
|
||||
|
///
|
||||
|
/// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
/// you may not use this file except in compliance with the License.
|
||||
|
/// You may obtain a copy of the License at
|
||||
|
///
|
||||
|
/// http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
///
|
||||
|
/// Unless required by applicable law or agreed to in writing, software
|
||||
|
/// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
|
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
|
/// See the License for the specific language governing permissions and
|
||||
|
/// limitations under the License.
|
||||
|
///
|
||||
|
|
||||
|
import { |
||||
|
AfterViewInit, |
||||
|
ChangeDetectorRef, |
||||
|
Component, |
||||
|
DestroyRef, |
||||
|
forwardRef, |
||||
|
Input, |
||||
|
Renderer2, |
||||
|
ViewChild, |
||||
|
ViewContainerRef, |
||||
|
} from '@angular/core'; |
||||
|
import { |
||||
|
ControlValueAccessor, |
||||
|
FormBuilder, |
||||
|
NG_VALIDATORS, |
||||
|
NG_VALUE_ACCESSOR, |
||||
|
ValidationErrors, |
||||
|
Validator, |
||||
|
} from '@angular/forms'; |
||||
|
import { |
||||
|
AggFunctionTranslations, |
||||
|
AggInputTypeTranslations, |
||||
|
CalculatedFieldAggMetric, |
||||
|
CalculatedFieldAggMetricValue, |
||||
|
} from '@shared/models/calculated-field.models'; |
||||
|
import { MatButton } from '@angular/material/button'; |
||||
|
import { TbPopoverService } from '@shared/components/popover.service'; |
||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
||||
|
import { isDefinedAndNotNull, isEqual } from '@core/utils'; |
||||
|
import { TbPopoverComponent } from '@shared/components/popover.component'; |
||||
|
import { TbTableDatasource } from '@shared/components/table/table-datasource.abstract'; |
||||
|
import { MatSort, SortDirection } from '@angular/material/sort'; |
||||
|
import { getCurrentAuthState } from '@core/auth/auth.selectors'; |
||||
|
import { Store } from '@ngrx/store'; |
||||
|
import { AppState } from '@core/core.state'; |
||||
|
import { |
||||
|
CalculatedFieldMetricsPanelComponent |
||||
|
} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component'; |
||||
|
import { TbEditorCompleter } from '@shared/models/ace/completion.models'; |
||||
|
import { AceHighlightRules } from '@shared/models/ace/ace.models'; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'tb-calculated-field-metrics-table', |
||||
|
templateUrl: './calculated-field-metrics-table.component.html', |
||||
|
styleUrls: [`calculated-field-metrics-table.component.scss`], |
||||
|
providers: [ |
||||
|
{ |
||||
|
provide: NG_VALUE_ACCESSOR, |
||||
|
useExisting: forwardRef(() => CalculatedFieldMetricsTableComponent), |
||||
|
multi: true |
||||
|
}, |
||||
|
{ |
||||
|
provide: NG_VALIDATORS, |
||||
|
useExisting: forwardRef(() => CalculatedFieldMetricsTableComponent), |
||||
|
multi: true |
||||
|
} |
||||
|
], |
||||
|
}) |
||||
|
export class CalculatedFieldMetricsTableComponent implements ControlValueAccessor, Validator, AfterViewInit { |
||||
|
|
||||
|
@Input() arguments: Array<string>; |
||||
|
@Input() editorCompleter: TbEditorCompleter; |
||||
|
@Input() highlightRules: AceHighlightRules; |
||||
|
|
||||
|
@ViewChild(MatSort, { static: true }) sort: MatSort; |
||||
|
|
||||
|
errorText = ''; |
||||
|
metricsFormArray = this.fb.array<CalculatedFieldAggMetricValue>([]); |
||||
|
sortOrder = { direction: 'asc' as SortDirection, property: '' }; |
||||
|
dataSource = new CalculatedFieldMetricsDatasource(); |
||||
|
|
||||
|
displayColumns = ['name', 'function', 'filter', 'valueSource', 'actions'] |
||||
|
|
||||
|
readonly AggFunctionTranslations = AggFunctionTranslations; |
||||
|
readonly AggInputTypeTranslations = AggInputTypeTranslations; |
||||
|
readonly maxArgumentsPerCF = getCurrentAuthState(this.store).maxArgumentsPerCF - 2; |
||||
|
|
||||
|
private popoverComponent: TbPopoverComponent<CalculatedFieldMetricsPanelComponent>; |
||||
|
private propagateChange: (zonesObj: Record<string, CalculatedFieldAggMetric>) => void = () => {}; |
||||
|
|
||||
|
constructor( |
||||
|
private fb: FormBuilder, |
||||
|
private popoverService: TbPopoverService, |
||||
|
private viewContainerRef: ViewContainerRef, |
||||
|
private cd: ChangeDetectorRef, |
||||
|
private renderer: Renderer2, |
||||
|
private destroyRef: DestroyRef, |
||||
|
private store: Store<AppState> |
||||
|
) { |
||||
|
this.metricsFormArray.valueChanges.pipe(takeUntilDestroyed()).subscribe(value => { |
||||
|
this.updateDataSource(value); |
||||
|
this.propagateChange(this.getMetricsObject(value)); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
ngAfterViewInit(): void { |
||||
|
this.sort.sortChange.asObservable().pipe( |
||||
|
takeUntilDestroyed(this.destroyRef) |
||||
|
).subscribe(() => { |
||||
|
this.sortOrder.property = this.sort.active; |
||||
|
this.sortOrder.direction = this.sort.direction; |
||||
|
this.updateDataSource(this.metricsFormArray.value); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
registerOnChange(fn: (zonesObj: Record<string, CalculatedFieldAggMetric>) => void): void { |
||||
|
this.propagateChange = fn; |
||||
|
} |
||||
|
|
||||
|
registerOnTouched(_fn: any): void {} |
||||
|
|
||||
|
validate(): ValidationErrors | null { |
||||
|
this.updateErrorText(); |
||||
|
return this.errorText ? { metricsFormArray: false } : null; |
||||
|
} |
||||
|
|
||||
|
onDelete($event: Event, metric: CalculatedFieldAggMetricValue): void { |
||||
|
$event.stopPropagation(); |
||||
|
const index = this.metricsFormArray.controls.findIndex(control => isEqual(control.value, metric)); |
||||
|
this.metricsFormArray.removeAt(index); |
||||
|
this.metricsFormArray.markAsDirty(); |
||||
|
} |
||||
|
|
||||
|
manageMetrics($event: Event, matButton: MatButton, metric = {} as CalculatedFieldAggMetricValue): void { |
||||
|
$event?.stopPropagation(); |
||||
|
if (this.popoverComponent && !this.popoverComponent.tbHidden) { |
||||
|
this.popoverComponent.hide(); |
||||
|
} |
||||
|
const trigger = matButton._elementRef.nativeElement; |
||||
|
if (this.popoverService.hasPopover(trigger)) { |
||||
|
this.popoverService.hidePopover(trigger); |
||||
|
} else { |
||||
|
const index = this.metricsFormArray.controls.findIndex(control => isEqual(control.value, metric)); |
||||
|
const isExists = index !== -1; |
||||
|
const ctx = { |
||||
|
index, |
||||
|
metric, |
||||
|
buttonTitle: isExists ? 'action.apply' : 'action.add', |
||||
|
usedNames: this.metricsFormArray.value.map(({ name }) => name).filter(name => name !== metric.name), |
||||
|
arguments: this.arguments, |
||||
|
editorCompleter: this.editorCompleter, |
||||
|
highlightRules: this.highlightRules |
||||
|
}; |
||||
|
this.popoverComponent = this.popoverService.displayPopover({ |
||||
|
trigger, |
||||
|
renderer: this.renderer, |
||||
|
componentType: CalculatedFieldMetricsPanelComponent, |
||||
|
hostView: this.viewContainerRef, |
||||
|
preferredPlacement: isExists ? ['leftOnly', 'leftTopOnly', 'leftBottomOnly'] : ['rightOnly', 'rightTopOnly', 'rightBottomOnly'], |
||||
|
context: ctx, |
||||
|
isModal: true |
||||
|
}); |
||||
|
this.popoverComponent.tbComponentRef.instance.metricDataApplied.subscribe((value) => { |
||||
|
this.popoverComponent.hide(); |
||||
|
if (isExists) { |
||||
|
this.metricsFormArray.at(index).setValue(value); |
||||
|
} else { |
||||
|
this.metricsFormArray.push(this.fb.control(value)); |
||||
|
} |
||||
|
this.cd.markForCheck(); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private updateDataSource(value: CalculatedFieldAggMetricValue[]): void { |
||||
|
const sortedValue = this.sortData(value); |
||||
|
this.dataSource.loadData(sortedValue); |
||||
|
} |
||||
|
|
||||
|
private updateErrorText(): void { |
||||
|
if (!this.metricsFormArray.controls.length) { |
||||
|
this.errorText = 'calculated-fields.metrics.metrics-empty'; |
||||
|
} else { |
||||
|
this.errorText = ''; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private getMetricsObject(value: CalculatedFieldAggMetricValue[]): Record<string, CalculatedFieldAggMetric> { |
||||
|
return value.reduce((acc, metricValue) => { |
||||
|
const { name, ...metric } = metricValue; |
||||
|
acc[name] = metric; |
||||
|
return acc; |
||||
|
}, {} as Record<string, CalculatedFieldAggMetric>); |
||||
|
} |
||||
|
|
||||
|
writeValue(metrics: Record<string, CalculatedFieldAggMetric>): void { |
||||
|
this.metricsFormArray.clear(); |
||||
|
this.populateZonesFormArray(metrics); |
||||
|
} |
||||
|
|
||||
|
private populateZonesFormArray(metrics: Record<string, CalculatedFieldAggMetric>): void { |
||||
|
Object.keys(metrics).forEach(key => { |
||||
|
const value: CalculatedFieldAggMetricValue = { |
||||
|
...metrics[key], |
||||
|
name: key |
||||
|
}; |
||||
|
this.metricsFormArray.push(this.fb.control(value), { emitEvent: false }); |
||||
|
}); |
||||
|
this.metricsFormArray.updateValueAndValidity(); |
||||
|
} |
||||
|
|
||||
|
private getSortValue(metric: CalculatedFieldAggMetricValue, column: string): string { |
||||
|
switch (column) { |
||||
|
case 'function': |
||||
|
return metric.function; |
||||
|
case 'filter': |
||||
|
return isDefinedAndNotNull(metric.filter).toString(); |
||||
|
default: |
||||
|
return metric.name; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private sortData(data: CalculatedFieldAggMetricValue[]): CalculatedFieldAggMetricValue[] { |
||||
|
return data.sort((a, b) => { |
||||
|
const valA = this.getSortValue(a, this.sortOrder.property) ?? ''; |
||||
|
const valB = this.getSortValue(b, this.sortOrder.property) ?? ''; |
||||
|
return (this.sortOrder.direction === 'asc' ? 1 : -1) * valA.localeCompare(valB); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
class CalculatedFieldMetricsDatasource extends TbTableDatasource<CalculatedFieldAggMetricValue> { |
||||
|
constructor() { |
||||
|
super(); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue