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