Browse Source

UI: Add bacis config RELATED_ENTITIES_AGGREGATION cf

pull/14141/head
Vladyslav_Prykhodko 1 year ago
parent
commit
59d2fe8c7b
  1. 4
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts
  2. 54
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html
  3. 11
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts
  4. 9
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module.ts
  5. 70
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/related-aggregation-arguments-table.component.ts
  6. 7
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html
  7. 3
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss
  8. 85
      ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html
  9. 13
      ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts
  10. 75
      ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html
  11. 154
      ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.ts
  12. 45
      ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.module.ts
  13. 6
      ui-ngx/src/app/shared/components/time-unit-input.component.html
  14. 24
      ui-ngx/src/app/shared/models/calculated-field.models.ts
  15. 15
      ui-ngx/src/assets/locale/locale.constant-en_US.json

4
ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts

@ -38,6 +38,9 @@ import {
import { import {
PropagationConfigurationModule PropagationConfigurationModule
} from '@home/components/calculated-fields/components/propagation-configuration/propagation-configuration.module'; } from '@home/components/calculated-fields/components/propagation-configuration/propagation-configuration.module';
import {
RelatedEntitiesAggregationComponentModule
} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.module';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -52,6 +55,7 @@ import {
EntityDebugSettingsButtonComponent, EntityDebugSettingsButtonComponent,
SimpleConfigurationModule, SimpleConfigurationModule,
PropagationConfigurationModule, PropagationConfigurationModule,
RelatedEntitiesAggregationComponentModule,
], ],
exports: [ exports: [
CalculatedFieldDialogComponent, CalculatedFieldDialogComponent,

54
ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html

@ -18,6 +18,11 @@
<div class="w-full max-w-xl" [formGroup]="argumentFormGroup"> <div class="w-full max-w-xl" [formGroup]="argumentFormGroup">
<div class="tb-form-panel no-border no-padding mb-2"> <div class="tb-form-panel no-border no-padding mb-2">
<div class="tb-form-panel-title">{{ 'calculated-fields.argument-settings' | translate }}</div> <div class="tb-form-panel-title">{{ 'calculated-fields.argument-settings' | translate }}</div>
@if (hint) {
<div class="tb-form-hint tb-primary-fill hint-container">
{{ hint | translate }}
</div>
}
<div class="tb-form-panel no-border no-padding"> <div class="tb-form-panel no-border no-padding">
@if (!isOutputKey) { @if (!isOutputKey) {
<ng-container *ngTemplateOutlet="argumentNameTemplate; context: { <ng-container *ngTemplateOutlet="argumentNameTemplate; context: {
@ -30,25 +35,27 @@
}"></ng-container> }"></ng-container>
} }
<ng-container> <ng-container>
<div class="tb-form-row"> @if (!hiddenEntityTypes) {
<div class="fixed-title-width">{{ 'entity.entity-type' | translate }}</div> <div class="tb-form-row">
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic"> <div class="fixed-title-width">{{ 'entity.entity-type' | translate }}</div>
<mat-select [formControl]="argumentType"> <mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
@for (type of argumentEntityTypes; track type) { <mat-select [formControl]="argumentType">
<mat-option [value]="type">{{ ArgumentEntityTypeTranslations.get(type) | translate }}</mat-option> @for (type of argumentEntityTypes; track type) {
<mat-option [value]="type">{{ ArgumentEntityTypeTranslations.get(type) | translate }}</mat-option>
}
</mat-select>
@if (argumentType.touched && argumentType.hasError('required')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.entity-type-required' | translate"
class="tb-error">
warning
</mat-icon>
} }
</mat-select> </mat-form-field>
@if (argumentType.touched && argumentType.hasError('required')) { </div>
<mat-icon matSuffix }
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.entity-type-required' | translate"
class="tb-error">
warning
</mat-icon>
}
</mat-form-field>
</div>
@if (ArgumentEntityTypeParamsMap.has(entityType)) { @if (ArgumentEntityTypeParamsMap.has(entityType)) {
<div class="tb-form-row"> <div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ ArgumentEntityTypeParamsMap.get(entityType).title | translate }}</div> <div class="fixed-title-width tb-required">{{ ArgumentEntityTypeParamsMap.get(entityType).title | translate }}</div>
@ -143,9 +150,18 @@
} }
@if (refEntityKeyFormGroup.get('type').value !== ArgumentType.Rolling) { @if (refEntityKeyFormGroup.get('type').value !== ArgumentType.Rolling) {
<div class="tb-form-row"> <div class="tb-form-row">
<div class="fixed-title-width">{{ 'calculated-fields.default-value' | translate }}</div> <div class="fixed-title-width" [class.tb-required]="defaultValueRequired">{{ 'calculated-fields.default-value' | translate }}</div>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic"> <mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<input matInput autocomplete="off" name="value" formControlName="defaultValue" placeholder="{{ 'action.set' | translate }}"/> <input matInput autocomplete="off" name="value" formControlName="defaultValue" placeholder="{{ 'action.set' | translate }}"/>
@if (argumentFormGroup.get('defaultValue').touched && argumentFormGroup.get('defaultValue').hasError('required')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.default-value-required' | translate"
class="tb-error">
warning
</mat-icon>
}
</mat-form-field> </mat-form-field>
</div> </div>
} @else { } @else {

11
ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts

@ -52,6 +52,7 @@ import { Store } from '@ngrx/store';
import { EntityAutocompleteComponent } from '@shared/components/entity/entity-autocomplete.component'; import { EntityAutocompleteComponent } from '@shared/components/entity/entity-autocomplete.component';
import { NULL_UUID } from '@shared/models/id/has-uuid'; import { NULL_UUID } from '@shared/models/id/has-uuid';
import { TenantId } from '@shared/models/id/tenant-id'; import { TenantId } from '@shared/models/id/tenant-id';
import { deduplicationStrategiesHintTranslations } from '@home/components/rule-node/rule-node-config.models';
@Component({ @Component({
selector: 'tb-calculated-field-argument-panel', selector: 'tb-calculated-field-argument-panel',
@ -68,6 +69,9 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
@Input() isScript: boolean; @Input() isScript: boolean;
@Input() usedArgumentNames: string[]; @Input() usedArgumentNames: string[];
@Input() isOutputKey = false; @Input() isOutputKey = false;
@Input() hiddenEntityTypes = false;
@Input() defaultValueRequired = false;
@Input() hint: string;
@Input() argumentEntityTypes = Object.values(ArgumentEntityType).filter(value => value !== ArgumentEntityType.RelationQuery) as ArgumentEntityType[]; @Input() argumentEntityTypes = Object.values(ArgumentEntityType).filter(value => value !== ArgumentEntityType.RelationQuery) as ArgumentEntityType[];
@ViewChild('entityAutocomplete') entityAutocomplete: EntityAutocompleteComponent; @ViewChild('entityAutocomplete') entityAutocomplete: EntityAutocompleteComponent;
@ -146,6 +150,11 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
this.setInitialEntityType(); this.setInitialEntityType();
this.setWatchKeyChange(); this.setWatchKeyChange();
if (this.defaultValueRequired) {
this.argumentFormGroup.get('defaultValue').addValidators(Validators.required);
this.argumentFormGroup.get('defaultValue').updateValueAndValidity({onlySelf: true});
}
this.argumentTypes = Object.values(ArgumentType) this.argumentTypes = Object.values(ArgumentType)
.filter(type => type !== ArgumentType.Rolling || this.isScript); .filter(type => type !== ArgumentType.Rolling || this.isScript);
} }
@ -311,4 +320,6 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
this.entityNameSubject.next(null); this.entityNameSubject.next(null);
} }
} }
protected readonly deduplicationStrategiesHintTranslations = deduplicationStrategiesHintTranslations;
} }

9
ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module.ts

@ -26,6 +26,9 @@ import {
import { import {
PropagateArgumentsTableComponent PropagateArgumentsTableComponent
} from '@home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component'; } from '@home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component';
import {
RelatedAggregationArgumentsTableComponent
} from '@home/components/calculated-fields/components/calculated-field-arguments/related-aggregation-arguments-table.component';
@NgModule({ @NgModule({
imports: [ imports: [
@ -35,11 +38,13 @@ import {
declarations: [ declarations: [
CalculatedFieldArgumentPanelComponent, CalculatedFieldArgumentPanelComponent,
CalculatedFieldArgumentsTableComponent, CalculatedFieldArgumentsTableComponent,
PropagateArgumentsTableComponent PropagateArgumentsTableComponent,
RelatedAggregationArgumentsTableComponent
], ],
exports: [ exports: [
CalculatedFieldArgumentsTableComponent, CalculatedFieldArgumentsTableComponent,
PropagateArgumentsTableComponent PropagateArgumentsTableComponent,
RelatedAggregationArgumentsTableComponent
] ]
}) })
export class CalculatedFieldArgumentsTableModule {} export class CalculatedFieldArgumentsTableModule {}

70
ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/related-aggregation-arguments-table.component.ts

@ -0,0 +1,70 @@
///
/// 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 { ChangeDetectorRef, Component, DestroyRef, forwardRef, Renderer2, ViewContainerRef, } from '@angular/core';
import { FormBuilder, NG_VALIDATORS, NG_VALUE_ACCESSOR, } from '@angular/forms';
import { TbPopoverService } from '@shared/components/popover.service';
import { EntityService } from '@core/http/entity.service';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import {
CalculatedFieldArgumentsTableComponent
} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component';
import { ArgumentEntityType } from '@shared/models/calculated-field.models';
@Component({
selector: 'tb-related-aggregation-arguments-table',
templateUrl: './calculated-field-arguments-table.component.html',
styleUrls: [`calculated-field-arguments-table.component.scss`],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => RelatedAggregationArgumentsTableComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => RelatedAggregationArgumentsTableComponent),
multi: true
}
],
})
export class RelatedAggregationArgumentsTableComponent extends CalculatedFieldArgumentsTableComponent {
constructor(
protected fb: FormBuilder,
protected popoverService: TbPopoverService,
protected viewContainerRef: ViewContainerRef,
protected cd: ChangeDetectorRef,
protected renderer: Renderer2,
protected entityService: EntityService,
protected destroyRef: DestroyRef,
protected store: Store<AppState>
) {
super(fb, popoverService, viewContainerRef, cd, renderer, entityService, destroyRef, store);
this.argumentNameColumn = 'calculated-fields.argument-name';
this.displayColumns = ['name', 'type', 'key', 'actions'];
this.panelAdditionalCtx = {
hiddenEntityTypes: true,
defaultValueRequired: true,
argumentEntityTypes: [ArgumentEntityType.Current],
hint: 'calculated-fields.hint.setting-arguments-aggregation'
};
this.isScript = false;
}
}

7
ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html

@ -75,6 +75,13 @@
[testScript]="onTestScript.bind(this)"> [testScript]="onTestScript.bind(this)">
</tb-propagation-configuration> </tb-propagation-configuration>
} }
@case (CalculatedFieldType.RELATED_ENTITIES_AGGREGATION) {
<tb-related-entities-aggregation-component formControlName="configuration"
[entityId]="data.entityId"
[entityName]="data.entityName"
[tenantId]="data.tenantId">
</tb-related-entities-aggregation-component>
}
@default { @default {
<tb-simple-configuration formControlName="configuration" <tb-simple-configuration formControlName="configuration"
[entityId]="data.entityId" [entityId]="data.entityId"

3
ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss

@ -17,6 +17,9 @@
.calculated-field-dialog-container { .calculated-field-dialog-container {
width: 869px; width: 869px;
max-width: 100%; max-width: 100%;
--mdc-outlined-text-field-outline-color: rgba(0,0,0,0.12);
--mdc-outlined-text-field-container-shape: 6px;
--mat-form-field-trailing-icon-color: rgba(0, 0, 0, 0.56);
} }
.tbel-script-lang-chip { .tbel-script-lang-chip {

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

@ -42,45 +42,50 @@
} }
</div> </div>
@if (simpleMode) { @if (simpleMode) {
<div class="flex items-start gap-3"> @if (hiddenName) {
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic"> <div class="flex items-start gap-3">
<mat-label> <mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
{{ <mat-label>{{ 'calculated-fields.decimals-by-default' | translate }}</mat-label>
(outputForm.get('type').value === OutputType.Timeseries <input matInput type="number" formControlName="decimalsByDefault">
? 'calculated-fields.timeseries-key' @if (outputForm.get('decimalsByDefault').errors && outputForm.get('decimalsByDefault').touched) {
: 'calculated-fields.attribute-key') <mat-error>{{ 'calculated-fields.hint.decimals-range' | translate }}</mat-error>
| translate }
}} </mat-form-field>
</mat-label> <ng-content select=".simpleMode"></ng-content>
<input matInput formControlName="name" required> </div>
@if (outputForm.get('name').errors && outputForm.get('name').touched) { } @else {
<mat-error> <div class="flex items-start gap-3">
@if (outputForm.get('name').hasError('required')) { <mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
{{ 'common.hint.key-required' | translate }} <mat-label>
} @else if (outputForm.get('name').hasError('pattern')) { {{
{{ 'common.hint.key-pattern' | translate }} (outputForm.get('type').value === OutputType.Timeseries
} @else if (outputForm.get('name').hasError('maxlength')) { ? 'calculated-fields.timeseries-key'
{{ 'common.hint.key-max-length' | translate }} : 'calculated-fields.attribute-key')
} | translate
</mat-error> }}
} </mat-label>
</mat-form-field> <input matInput formControlName="name" required>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic"> @if (outputForm.get('name').errors && outputForm.get('name').touched) {
<mat-label>{{ 'calculated-fields.decimals-by-default' | translate }}</mat-label> <mat-error>
<input matInput type="number" formControlName="decimalsByDefault"> @if (outputForm.get('name').hasError('required')) {
@if (outputForm.get('decimalsByDefault').errors && outputForm.get('decimalsByDefault').touched) { {{ 'common.hint.key-required' | translate }}
<mat-error>{{ 'calculated-fields.hint.decimals-range' | translate }}</mat-error> } @else if (outputForm.get('name').hasError('pattern')) {
} {{ 'common.hint.key-pattern' | translate }}
</mat-form-field> } @else if (outputForm.get('name').hasError('maxlength')) {
</div> {{ 'common.hint.key-max-length' | translate }}
<ng-content select=".simpleMode"></ng-content> }
<!-- <div class="tb-form-row" [formGroup]="configFormGroup"--> </mat-error>
<!-- *ngIf="outputFormGroup.get('type').value === OutputType.Timeseries">--> }
<!-- <mat-slide-toggle class="mat-slide" formControlName="useLatestTs">--> </mat-form-field>
<!-- <div tb-hint-tooltip-icon="{{ 'calculated-fields.hint.use-latest-timestamp' | translate }}" translate>--> <mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<!-- calculated-fields.use-latest-timestamp--> <mat-label>{{ 'calculated-fields.decimals-by-default' | translate }}</mat-label>
<!-- </div>--> <input matInput type="number" formControlName="decimalsByDefault">
<!-- </mat-slide-toggle>--> @if (outputForm.get('decimalsByDefault').errors && outputForm.get('decimalsByDefault').touched) {
<!-- </div>--> <mat-error>{{ 'calculated-fields.hint.decimals-range' | translate }}</mat-error>
}
</mat-form-field>
</div>
<ng-content select=".simpleMode"></ng-content>
}
} }
</div> </div>

13
ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts

@ -35,6 +35,7 @@ import { digitsRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EntityId } from '@shared/models/id/entity-id'; import { EntityId } from '@shared/models/id/entity-id';
import { EntityType } from '@shared/models/entity-type.models'; import { EntityType } from '@shared/models/entity-type.models';
import { coerceBoolean } from '@shared/decorators/coercion';
@Component({ @Component({
selector: 'tb-calculate-field-output', selector: 'tb-calculate-field-output',
@ -55,8 +56,13 @@ import { EntityType } from '@shared/models/entity-type.models';
export class CalculatedFieldOutputComponent implements ControlValueAccessor, Validator, OnInit, OnChanges { export class CalculatedFieldOutputComponent implements ControlValueAccessor, Validator, OnInit, OnChanges {
@Input() @Input()
@coerceBoolean()
simpleMode = false; simpleMode = false;
@Input()
@coerceBoolean()
hiddenName = false;
@Input({required: true}) @Input({required: true})
entityId: EntityId; entityId: EntityId;
@ -137,11 +143,14 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val
} }
private updatedFormWithMode(): void { private updatedFormWithMode(): void {
if (this.simpleMode) { if (this.simpleMode && !this.hiddenName) {
this.outputForm.get('name').enable({emitEvent: false}); this.outputForm.get('name').enable({emitEvent: false});
this.outputForm.get('decimalsByDefault').enable({emitEvent: false});
} else { } else {
this.outputForm.get('name').disable({emitEvent: false}); this.outputForm.get('name').disable({emitEvent: false});
}
if (this.simpleMode) {
this.outputForm.get('decimalsByDefault').enable({emitEvent: false});
} else {
this.outputForm.get('decimalsByDefault').disable({emitEvent: false}); this.outputForm.get('decimalsByDefault').disable({emitEvent: false});
} }
} }

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

@ -0,0 +1,75 @@
<!--
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 [formGroup]="relatedAggregationConfiguration" class="tb-form-panel no-border no-padding">
<div class="tb-form-panel">
<div class="tb-form-panel-title" tbTruncateWithTooltip tb-hint-tooltip-icon="{{ 'calculated-fields.hint.aggregation-path-related-entities' | translate }}">
{{ 'calculated-fields.aggregation-path-related-entities' | translate }}
</div>
<div class="flex gap-3 xs:flex-col" formGroupName="relation">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic" hideRequiredMarker>
<mat-label>{{ 'calculated-fields.direction' | translate }}</mat-label>
<mat-select formControlName="direction">
@for (direction of Directions; track direction) {
<mat-option [value]="direction">{{ PropagationDirectionTranslations.get(direction) | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
<tb-string-autocomplete [fetchOptionsFn]="fetchOptions.bind(this)"
class="flex-1"
panelWidth=""
additionalClass=""
required
[label]="'calculated-fields.relation-type' | translate"
[errorText]="'calculated-fields.hint.relation-type-required' | translate"
formControlName="relationType">
</tb-string-autocomplete>
</div>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title" tb-hint-tooltip-icon="{{ 'calculated-fields.hint.arguments-aggregation' | translate }}">
{{ 'calculated-fields.arguments' | translate }}
</div>
<tb-related-aggregation-arguments-table formControlName="arguments"
[entityId]="entityId"
[tenantId]="tenantId"
[entityName]="entityName"/>
</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 }}
</div>
<tb-time-unit-input required
appearance="outline"
subscriptSizing="dynamic"
labelText="{{ 'calculated-fields.deduplication-interval' | translate }}"
requiredText="{{ 'calculated-fields.deduplication-interval-required' | translate }}"
minErrorText="{{ 'calculated-fields.deduplication-interval-min' | translate: {sec: minAllowedDeduplicationIntervalInSecForCF} }}"
[minTime]="minAllowedDeduplicationIntervalInSecForCF"
formControlName="deduplicationIntervalInSec">
</tb-time-unit-input>
</div>
<tb-calculate-field-output formControlName="output" [entityId]="entityId" simpleMode hiddenName>
<div class="tb-form-row simpleMode flex-1">
<mat-slide-toggle class="mat-slide" formControlName="useLatestTs">
<div tb-hint-tooltip-icon="{{ 'calculated-fields.hint.use-latest-timestamp' | translate }}" translate>
calculated-fields.use-latest-timestamp
</div>
</mat-slide-toggle>
</div>
</tb-calculate-field-output>
</div>

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

@ -0,0 +1,154 @@
///
/// 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, forwardRef, Input } from '@angular/core';
import {
ControlValueAccessor,
FormBuilder,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
Validators
} from '@angular/forms';
import { EntityId } from '@shared/models/id/entity-id';
import { Observable, of } from 'rxjs';
import {
CalculatedFieldOutput,
CalculatedFieldRelatedAggregationConfiguration,
CalculatedFieldType,
getCalculatedFieldArgumentsEditorCompleter,
getCalculatedFieldArgumentsHighlights,
OutputType,
PropagationDirectionTranslations
} from '@shared/models/calculated-field.models';
import { AttributeScope } from '@shared/models/telemetry/telemetry.models';
import { map } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ScriptLanguage } from '@app/shared/models/rule-node.models';
import { EntitySearchDirection } from '@shared/models/relation.models';
import { getCurrentAuthState } from '@core/auth/auth.selectors';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
@Component({
selector: 'tb-related-entities-aggregation-component',
templateUrl: './related-entities-aggregation-component.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => RelatedEntitiesAggregationComponentComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => RelatedEntitiesAggregationComponentComponent),
multi: true
}
],
})
export class RelatedEntitiesAggregationComponentComponent implements ControlValueAccessor, Validator {
@Input({required: true})
entityId: EntityId;
@Input({required: true})
tenantId: string;
@Input({required: true})
entityName: string;
relatedAggregationConfiguration = this.fb.group({
relation: this.fb.group({
direction: [EntitySearchDirection.FROM, Validators.required],
relationType: ['Contains', Validators.required],
}),
arguments: this.fb.control({}),
deduplicationIntervalInSec: [],
output: this.fb.control<CalculatedFieldOutput>({
scope: AttributeScope.SERVER_SCOPE,
type: OutputType.Timeseries,
}),
useLatestTs: [false]
});
readonly ScriptLanguage = ScriptLanguage;
readonly CalculatedFieldType = CalculatedFieldType;
readonly OutputType = OutputType;
readonly Directions = Object.values(EntitySearchDirection) as Array<EntitySearchDirection>;
readonly PropagationDirectionTranslations = PropagationDirectionTranslations;
readonly minAllowedDeduplicationIntervalInSecForCF = getCurrentAuthState(this.store).minAllowedDeduplicationIntervalInSecForCF;
functionArgs$ = this.relatedAggregationConfiguration.get('arguments').valueChanges.pipe(
map(argumentsObj => ['ctx', ...Object.keys(argumentsObj)])
);
argumentsEditorCompleter$ = this.relatedAggregationConfiguration.get('arguments').valueChanges.pipe(
map(argumentsObj => getCalculatedFieldArgumentsEditorCompleter(argumentsObj ?? {}))
);
argumentsHighlightRules$ = this.relatedAggregationConfiguration.get('arguments').valueChanges.pipe(
map(argumentsObj => getCalculatedFieldArgumentsHighlights(argumentsObj))
);
private propagateChange: (config: CalculatedFieldRelatedAggregationConfiguration) => void = () => { };
constructor(private fb: FormBuilder,
private store: Store<AppState>) {
this.relatedAggregationConfiguration.valueChanges.pipe(
takeUntilDestroyed()
).subscribe((value: CalculatedFieldRelatedAggregationConfiguration) => {
this.updatedModel(value);
})
}
validate(): ValidationErrors | null {
return this.relatedAggregationConfiguration.valid || this.relatedAggregationConfiguration.status === "DISABLED" ? null : {invalidPropagateConfig: false};
}
writeValue(value: CalculatedFieldRelatedAggregationConfiguration): void {
this.relatedAggregationConfiguration.patchValue(value, {emitEvent: false});
setTimeout(() => {
this.relatedAggregationConfiguration.get('arguments').updateValueAndValidity({onlySelf: true});
});
}
registerOnChange(fn: (config: CalculatedFieldRelatedAggregationConfiguration) => void): void {
this.propagateChange = fn;
}
registerOnTouched(_: any): void { }
setDisabledState(isDisabled: boolean): void {
if (isDisabled) {
this.relatedAggregationConfiguration.disable({emitEvent: false});
} else {
this.relatedAggregationConfiguration.enable({emitEvent: false});
}
}
fetchOptions(searchText: string): Observable<Array<string>> {
const search = searchText ? searchText?.toLowerCase() : '';
return of(['Contains', 'Manages']).pipe(map(name => name?.filter(option => option.toLowerCase().includes(search))));
}
private updatedModel(value: CalculatedFieldRelatedAggregationConfiguration): void {
value.type = CalculatedFieldType.RELATED_ENTITIES_AGGREGATION;
this.propagateChange(value);
}
}

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

@ -0,0 +1,45 @@
///
/// 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 { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SharedModule } from '@shared/shared.module';
import {
CalculatedFieldOutputModule
} from '@home/components/calculated-fields/components/output/calculated-field-output.module';
import {
CalculatedFieldArgumentsTableModule
} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module';
import {
RelatedEntitiesAggregationComponentComponent
} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component';
@NgModule({
imports: [
CommonModule,
SharedModule,
CalculatedFieldOutputModule,
CalculatedFieldArgumentsTableModule,
],
declarations: [
RelatedEntitiesAggregationComponentComponent,
],
exports: [
RelatedEntitiesAggregationComponentComponent,
]
})
export class RelatedEntitiesAggregationComponentModule {
}

6
ui-ngx/src/app/shared/components/time-unit-input.component.html

@ -18,7 +18,7 @@
<section [formGroup]="timeInputForm" class="flex gap-4"> <section [formGroup]="timeInputForm" class="flex gap-4">
<mat-form-field [class]="{'number': inlineField, 'max-w-66%': !inlineField, 'flex-full': !inlineField}" <mat-form-field [class]="{'number': inlineField, 'max-w-66%': !inlineField, 'flex-full': !inlineField}"
[appearance]="inlineField ? 'outline' : appearance" [appearance]="inlineField ? 'outline' : appearance"
[subscriptSizing]="inlineField ? 'dynamic' : subscriptSizing"> subscriptSizing="dynamic">
@if (labelText && !inlineField) { @if (labelText && !inlineField) {
<mat-label>{{ labelText }}</mat-label> <mat-label>{{ labelText }}</mat-label>
} }
@ -41,7 +41,9 @@
{{ hasError }} {{ hasError }}
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
<mat-form-field [class.h-fit.max-w-33%.flex-full]="!inlineField" <mat-form-field [class.h-fit]="!inlineField"
[class.max-w-33%]="!inlineField"
[class.flex-full]="!inlineField"
[appearance]="inlineField ? 'outline' : appearance" [appearance]="inlineField ? 'outline' : appearance"
[subscriptSizing]="inlineField ? 'dynamic' : subscriptSizing"> [subscriptSizing]="inlineField ? 'dynamic' : subscriptSizing">
@if (!inlineField) { @if (!inlineField) {

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

@ -65,7 +65,8 @@ export enum CalculatedFieldType {
SIMPLE = 'SIMPLE', SIMPLE = 'SIMPLE',
SCRIPT = 'SCRIPT', SCRIPT = 'SCRIPT',
GEOFENCING = 'GEOFENCING', GEOFENCING = 'GEOFENCING',
PROPAGATION = 'PROPAGATION' PROPAGATION = 'PROPAGATION',
RELATED_ENTITIES_AGGREGATION = 'RELATED_ENTITIES_AGGREGATION'
} }
export const CalculatedFieldTypeTranslations = new Map<CalculatedFieldType, string>( export const CalculatedFieldTypeTranslations = new Map<CalculatedFieldType, string>(
@ -74,6 +75,7 @@ export const CalculatedFieldTypeTranslations = new Map<CalculatedFieldType, stri
[CalculatedFieldType.SCRIPT, 'calculated-fields.type.script'], [CalculatedFieldType.SCRIPT, 'calculated-fields.type.script'],
[CalculatedFieldType.GEOFENCING, 'calculated-fields.type.geofencing'], [CalculatedFieldType.GEOFENCING, 'calculated-fields.type.geofencing'],
[CalculatedFieldType.PROPAGATION, 'calculated-fields.type.propagation'], [CalculatedFieldType.PROPAGATION, 'calculated-fields.type.propagation'],
[CalculatedFieldType.RELATED_ENTITIES_AGGREGATION, 'calculated-fields.type.related-entities-aggregation'],
] ]
) )
@ -81,12 +83,14 @@ export type CalculatedFieldConfiguration =
| CalculatedFieldSimpleConfiguration | CalculatedFieldSimpleConfiguration
| CalculatedFieldScriptConfiguration | CalculatedFieldScriptConfiguration
| CalculatedFieldGeofencingConfiguration | CalculatedFieldGeofencingConfiguration
| CalculatedFieldPropagationConfiguration; | CalculatedFieldPropagationConfiguration
| CalculatedFieldRelatedAggregationConfiguration;
export interface CalculatedFieldSimpleConfiguration { export interface CalculatedFieldSimpleConfiguration {
type: CalculatedFieldType.SIMPLE; type: CalculatedFieldType.SIMPLE;
expression: string; expression: string;
arguments: Record<string, CalculatedFieldArgument>; arguments: Record<string, CalculatedFieldArgument>;
useLatestTs: boolean;
output: CalculatedFieldSimpleOutput; output: CalculatedFieldSimpleOutput;
} }
@ -105,6 +109,15 @@ export interface CalculatedFieldGeofencingConfiguration {
output: CalculatedFieldOutput; output: CalculatedFieldOutput;
} }
export interface CalculatedFieldRelatedAggregationConfiguration {
type: CalculatedFieldType.RELATED_ENTITIES_AGGREGATION;
relation: RelationPathLevel;
arguments: Record<string, CalculatedFieldArgument>;
deduplicationIntervalInSec: number;
useLatestTs: boolean;
output: Omit<CalculatedFieldSimpleOutput, 'name'>;
}
interface BasePropagationConfiguration { interface BasePropagationConfiguration {
type: CalculatedFieldType.PROPAGATION; type: CalculatedFieldType.PROPAGATION;
direction: EntitySearchDirection; direction: EntitySearchDirection;
@ -250,7 +263,7 @@ export interface CalculatedFieldGeofencing {
export interface RefDynamicSourceConfiguration { export interface RefDynamicSourceConfiguration {
type?: ArgumentEntityType.RelationQuery; type?: ArgumentEntityType.RelationQuery;
levels?: Array<{direction: EntitySearchDirection; relationType: string;}>; levels?: Array<RelationPathLevel>;
} }
export interface CalculatedFieldGeofencingValue extends CalculatedFieldGeofencing { export interface CalculatedFieldGeofencingValue extends CalculatedFieldGeofencing {
@ -317,6 +330,11 @@ export interface CalculatedFieldArgumentValueBase {
type: ArgumentType; type: ArgumentType;
} }
export interface RelationPathLevel {
direction: EntitySearchDirection;
relationType: string;
}
export interface CalculatedFieldAttributeArgumentValue<ValueType = unknown> extends CalculatedFieldArgumentValueBase { export interface CalculatedFieldAttributeArgumentValue<ValueType = unknown> extends CalculatedFieldArgumentValueBase {
ts: number; ts: number;
value: ValueType; value: ValueType;

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

@ -1054,7 +1054,8 @@
"simple": "Simple", "simple": "Simple",
"script": "Script", "script": "Script",
"geofencing" : "Geofencing", "geofencing" : "Geofencing",
"propagation": "Propagation" "propagation": "Propagation",
"related-entities-aggregation": "Related entities aggregation"
}, },
"arguments": "Arguments", "arguments": "Arguments",
"decimals-by-default": "Decimals by default", "decimals-by-default": "Decimals by default",
@ -1088,6 +1089,7 @@
"shared-attributes": "Shared attributes", "shared-attributes": "Shared attributes",
"attribute-key": "Attribute key", "attribute-key": "Attribute key",
"default-value": "Default value", "default-value": "Default value",
"default-value-required": "Default value is required.",
"limit": "Max values", "limit": "Max values",
"time-window": "Time window", "time-window": "Time window",
"customer-name": "Customer name", "customer-name": "Customer name",
@ -1156,6 +1158,11 @@
"data-propagate": "Data to propagate", "data-propagate": "Data to propagate",
"output-key": "Output key", "output-key": "Output key",
"copy-output-key": "Copy 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.",
"hint": { "hint": {
"arguments-simple-with-rolling": "Simple type calculated field should not contain keys with time series rolling type.", "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.", "arguments-propagate-arguments-with-rolling": "'Time series rolling' type is incompatible with 'Arguments only' propagation.",
@ -1202,7 +1209,11 @@
"zone-group-refresh-interval-required": "Zone groups refresh interval is required.", "zone-group-refresh-interval-required": "Zone groups refresh interval is required.",
"zone-group-refresh-interval-min": "Zone group refresh interval should be at least {{ min }} second.", "zone-group-refresh-interval-min": "Zone group refresh interval should be at least {{ min }} second.",
"propagation-path-related-entities": "Defines a direct, single-level path to a related entity based on the selected direction and relation type.", "propagation-path-related-entities": "Defines a direct, single-level path to a related entity based on the selected direction and relation type.",
"data-propagate": "Defines the data to be propagated from the arguments configured below. 'Arguments only' uses the retrieved data directly, while 'Expression result' calculates a new value from that data." "data-propagate": "Defines the data to be propagated from the arguments configured below. 'Arguments only' uses the retrieved data directly, while 'Expression result' calculates a new value from that data.",
"aggregation-path-related-entities": "Defines a single-level aggregation path via direct relations with parent or child entities based on direction and relation type. Only relations between device, asset, customer, and tenant entities are supported.",
"arguments-aggregation": "Defines input parameters used for filtering and aggregation.",
"setting-arguments-aggregation": "Data will be fetched from related entities configured in aggregation path.",
"metrics": "Defines metrics aggregated based on the configured arguments."
} }
}, },
"ai-models": { "ai-models": {

Loading…
Cancel
Save