Browse Source

UI: Add metrics config to RELATED_ENTITIES_AGGREGATION

pull/14141/head
Vladyslav_Prykhodko 10 months ago
parent
commit
aab06c465d
  1. 25
      ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html
  2. 168
      ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.html
  3. 31
      ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.scss
  4. 176
      ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.ts
  5. 111
      ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component.html
  6. 76
      ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component.scss
  7. 242
      ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component.ts
  8. 7
      ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html
  9. 5
      ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.ts
  10. 8
      ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.module.ts
  11. 49
      ui-ngx/src/app/shared/models/calculated-field.models.ts
  12. 30
      ui-ngx/src/assets/locale/locale.constant-en_US.json

25
ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html

@ -44,13 +44,7 @@
@if (simpleMode) {
@if (hiddenName) {
<div class="flex items-start gap-3">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'calculated-fields.decimals-by-default' | translate }}</mat-label>
<input matInput type="number" formControlName="decimalsByDefault">
@if (outputForm.get('decimalsByDefault').errors && outputForm.get('decimalsByDefault').touched) {
<mat-error>{{ 'calculated-fields.hint.decimals-range' | translate }}</mat-error>
}
</mat-form-field>
<ng-container *ngTemplateOutlet="decimalsByDefaultField"></ng-container>
<ng-content select=".simpleMode"></ng-content>
</div>
} @else {
@ -77,15 +71,18 @@
</mat-error>
}
</mat-form-field>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'calculated-fields.decimals-by-default' | translate }}</mat-label>
<input matInput type="number" formControlName="decimalsByDefault">
@if (outputForm.get('decimalsByDefault').errors && outputForm.get('decimalsByDefault').touched) {
<mat-error>{{ 'calculated-fields.hint.decimals-range' | translate }}</mat-error>
}
</mat-form-field>
<ng-container *ngTemplateOutlet="decimalsByDefaultField"></ng-container>
</div>
<ng-content select=".simpleMode"></ng-content>
}
}
</div>
<ng-template #decimalsByDefaultField>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic" [formGroup]="outputForm">
<mat-label>{{ 'calculated-fields.decimals-by-default' | translate }}</mat-label>
<input matInput type="number" formControlName="decimalsByDefault">
@if (outputForm.get('decimalsByDefault').errors && outputForm.get('decimalsByDefault').touched) {
<mat-error>{{ 'calculated-fields.hint.decimals-range' | translate }}</mat-error>
}
</mat-form-field>
</ng-template>

168
ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.html

@ -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>

31
ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.scss

@ -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;
}
}
}

176
ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.ts

@ -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());
}
}

111
ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component.html

@ -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>

76
ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component.scss

@ -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;
}
}
}

242
ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component.ts

@ -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();
}
}

7
ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html

@ -51,8 +51,13 @@
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title" tb-hint-tooltip-icon="{{ 'calculated-fields.hint.metrics' | translate }}">
{{ 'calculated-fields.metrics' | translate }}
{{ 'calculated-fields.metrics.metrics' | translate }}
</div>
<tb-calculated-field-metrics-table formControlName="metrics"
[arguments]="arguments$ | async"
[highlightRules]="argumentsHighlightRules$ | async"
[editorCompleter]="argumentsEditorCompleter$ | async"
></tb-calculated-field-metrics-table>
<tb-time-unit-input required
appearance="outline"
subscriptSizing="dynamic"

5
ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.ts

@ -77,6 +77,7 @@ export class RelatedEntitiesAggregationComponentComponent implements ControlValu
relationType: ['Contains', Validators.required],
}),
arguments: this.fb.control({}),
metrics: this.fb.control({}),
deduplicationIntervalInSec: [],
output: this.fb.control<CalculatedFieldOutput>({
scope: AttributeScope.SERVER_SCOPE,
@ -93,8 +94,8 @@ export class RelatedEntitiesAggregationComponentComponent implements ControlValu
readonly minAllowedDeduplicationIntervalInSecForCF = getCurrentAuthState(this.store).minAllowedDeduplicationIntervalInSecForCF;
functionArgs$ = this.relatedAggregationConfiguration.get('arguments').valueChanges.pipe(
map(argumentsObj => ['ctx', ...Object.keys(argumentsObj)])
arguments$ = this.relatedAggregationConfiguration.get('arguments').valueChanges.pipe(
map(argumentsObj => Object.keys(argumentsObj))
);
argumentsEditorCompleter$ = this.relatedAggregationConfiguration.get('arguments').valueChanges.pipe(

8
ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.module.ts

@ -26,6 +26,12 @@ import {
import {
RelatedEntitiesAggregationComponentComponent
} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component';
import {
CalculatedFieldMetricsTableComponent
} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component';
import {
CalculatedFieldMetricsPanelComponent
} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component';
@NgModule({
imports: [
@ -36,6 +42,8 @@ import {
],
declarations: [
RelatedEntitiesAggregationComponentComponent,
CalculatedFieldMetricsTableComponent,
CalculatedFieldMetricsPanelComponent
],
exports: [
RelatedEntitiesAggregationComponentComponent,

49
ui-ngx/src/app/shared/models/calculated-field.models.ts

@ -113,6 +113,7 @@ export interface CalculatedFieldRelatedAggregationConfiguration {
type: CalculatedFieldType.RELATED_ENTITIES_AGGREGATION;
relation: RelationPathLevel;
arguments: Record<string, CalculatedFieldArgument>;
metrics: Record<string, CalculatedFieldAggMetric>;
deduplicationIntervalInSec: number;
useLatestTs: boolean;
output: Omit<CalculatedFieldSimpleOutput, 'name'>;
@ -251,6 +252,54 @@ export interface CalculatedFieldArgument {
timeWindow?: number;
}
export enum AggFunction {
AVG='AVG',
MIN='MIN',
MAX='MAX',
SUM='SUM',
COUNT='COUNT',
COUNT_UNIQUE='COUNT_UNIQUE'
}
export const AggFunctionTranslations = new Map<AggFunction, string>([
[AggFunction.AVG, 'calculated-fields.metrics.aggregation-type.avg'],
[AggFunction.MIN, 'calculated-fields.metrics.aggregation-type.min'],
[AggFunction.MAX, 'calculated-fields.metrics.aggregation-type.max'],
[AggFunction.SUM, 'calculated-fields.metrics.aggregation-type.sum'],
[AggFunction.COUNT, 'calculated-fields.metrics.aggregation-type.count'],
[AggFunction.COUNT_UNIQUE, 'calculated-fields.metrics.aggregation-type.count-unique'],
])
export interface CalculatedFieldAggMetric {
function: AggFunction;
filter?: string;
input: AggKeyInput | AggFunctionInput;
}
export interface CalculatedFieldAggMetricValue extends CalculatedFieldAggMetric {
name: string;
}
export enum AggInputType {
key = 'key',
function = 'function'
}
export const AggInputTypeTranslations = new Map<AggInputType, string>([
[AggInputType.key, 'calculated-fields.metrics.value-source-type.key'],
[AggInputType.function, 'calculated-fields.metrics.value-source-type.function'],
])
export interface AggKeyInput {
type: AggInputType.key;
key: string;
}
export interface AggFunctionInput {
type: AggInputType.function;
function: string;
}
export interface CalculatedFieldGeofencing {
perimeterKeyName: string;
reportStrategy: GeofencingReportStrategy;

30
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -1159,10 +1159,36 @@
"output-key": "Output key",
"copy-output-key": "Copy output key",
"aggregation-path-related-entities": "Aggregation path to related entities",
"metrics": "Metrics",
"deduplication-interval": "Deduplication interval",
"deduplication-interval-min": "Deduplication interval should be at least {{ sec }} second.",
"deduplication-interval-required": "Deduplication interval is required.",
"metrics": {
"metrics": "Metrics",
"metrics-empty": "At least one metric must be configured.",
"metric-name": "Metric name",
"copy-metric-name": "Copy metric name",
"aggregation": "Aggregation",
"aggregation-type": {
"avg": "Average",
"min": "Minimum",
"max": "Maximum",
"sum": "Sum",
"count": "Count",
"count-unique": "Count unique"
},
"filtered": "Filtered",
"value-source": "Value source",
"value-source-type": {
"key": "Key",
"function": "Function"
},
"no-metrics-configured": "No metrics configured",
"add-metric": "Add metric",
"max-metrics": "Maximum number of metrics reached.",
"metric-settings": "Metric settings",
"filter": "Filter",
"filter-hint": "Enables filtering of entities during aggregation. The filter function must return a boolean value and can use all configured arguments."
},
"hint": {
"arguments-simple-with-rolling": "Simple type calculated field should not contain keys with time series rolling type.",
"arguments-propagate-arguments-with-rolling": "'Time series rolling' type is incompatible with 'Arguments only' propagation.",
@ -1182,7 +1208,7 @@
"output-key-max-length": "Output key should be less than 256 characters.",
"output-key-forbidden": "Output key is reserved and cannot be used.",
"entity-type-required": "Entity type is required",
"name-required": "Mame is required.",
"name-required": "Name is required.",
"name-pattern": "Name is invalid.",
"name-duplicate": "Name with such name already exists.",
"name-max-length": "Name should be less than 256 characters.",

Loading…
Cancel
Save