Browse Source

UI: Add bacis config RELATED_ENTITIES_AGGREGATION cf

pull/14141/head
Vladyslav_Prykhodko 10 months 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 {
PropagationConfigurationModule
} 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({
declarations: [
@ -52,6 +55,7 @@ import {
EntityDebugSettingsButtonComponent,
SimpleConfigurationModule,
PropagationConfigurationModule,
RelatedEntitiesAggregationComponentModule,
],
exports: [
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="tb-form-panel no-border no-padding mb-2">
<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">
@if (!isOutputKey) {
<ng-container *ngTemplateOutlet="argumentNameTemplate; context: {
@ -30,25 +35,27 @@
}"></ng-container>
}
<ng-container>
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'entity.entity-type' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select [formControl]="argumentType">
@for (type of argumentEntityTypes; track type) {
<mat-option [value]="type">{{ ArgumentEntityTypeTranslations.get(type) | translate }}</mat-option>
@if (!hiddenEntityTypes) {
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'entity.entity-type' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select [formControl]="argumentType">
@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>
@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-form-field>
</div>
</mat-form-field>
</div>
}
@if (ArgumentEntityTypeParamsMap.has(entityType)) {
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ ArgumentEntityTypeParamsMap.get(entityType).title | translate }}</div>
@ -143,9 +150,18 @@
}
@if (refEntityKeyFormGroup.get('type').value !== ArgumentType.Rolling) {
<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">
<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>
</div>
} @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 { NULL_UUID } from '@shared/models/id/has-uuid';
import { TenantId } from '@shared/models/id/tenant-id';
import { deduplicationStrategiesHintTranslations } from '@home/components/rule-node/rule-node-config.models';
@Component({
selector: 'tb-calculated-field-argument-panel',
@ -68,6 +69,9 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
@Input() isScript: boolean;
@Input() usedArgumentNames: string[];
@Input() isOutputKey = false;
@Input() hiddenEntityTypes = false;
@Input() defaultValueRequired = false;
@Input() hint: string;
@Input() argumentEntityTypes = Object.values(ArgumentEntityType).filter(value => value !== ArgumentEntityType.RelationQuery) as ArgumentEntityType[];
@ViewChild('entityAutocomplete') entityAutocomplete: EntityAutocompleteComponent;
@ -146,6 +150,11 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
this.setInitialEntityType();
this.setWatchKeyChange();
if (this.defaultValueRequired) {
this.argumentFormGroup.get('defaultValue').addValidators(Validators.required);
this.argumentFormGroup.get('defaultValue').updateValueAndValidity({onlySelf: true});
}
this.argumentTypes = Object.values(ArgumentType)
.filter(type => type !== ArgumentType.Rolling || this.isScript);
}
@ -311,4 +320,6 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
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 {
PropagateArgumentsTableComponent
} 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({
imports: [
@ -35,11 +38,13 @@ import {
declarations: [
CalculatedFieldArgumentPanelComponent,
CalculatedFieldArgumentsTableComponent,
PropagateArgumentsTableComponent
PropagateArgumentsTableComponent,
RelatedAggregationArgumentsTableComponent
],
exports: [
CalculatedFieldArgumentsTableComponent,
PropagateArgumentsTableComponent
PropagateArgumentsTableComponent,
RelatedAggregationArgumentsTableComponent
]
})
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)">
</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 {
<tb-simple-configuration formControlName="configuration"
[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 {
width: 869px;
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 {

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

@ -42,45 +42,50 @@
}
</div>
@if (simpleMode) {
<div class="flex items-start gap-3">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>
{{
(outputForm.get('type').value === OutputType.Timeseries
? 'calculated-fields.timeseries-key'
: 'calculated-fields.attribute-key')
| translate
}}
</mat-label>
<input matInput formControlName="name" required>
@if (outputForm.get('name').errors && outputForm.get('name').touched) {
<mat-error>
@if (outputForm.get('name').hasError('required')) {
{{ 'common.hint.key-required' | translate }}
} @else if (outputForm.get('name').hasError('pattern')) {
{{ 'common.hint.key-pattern' | translate }}
} @else if (outputForm.get('name').hasError('maxlength')) {
{{ 'common.hint.key-max-length' | translate }}
}
</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>
</div>
<ng-content select=".simpleMode"></ng-content>
<!-- <div class="tb-form-row" [formGroup]="configFormGroup"-->
<!-- *ngIf="outputFormGroup.get('type').value === OutputType.Timeseries">-->
<!-- <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>-->
@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-content select=".simpleMode"></ng-content>
</div>
} @else {
<div class="flex items-start gap-3">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>
{{
(outputForm.get('type').value === OutputType.Timeseries
? 'calculated-fields.timeseries-key'
: 'calculated-fields.attribute-key')
| translate
}}
</mat-label>
<input matInput formControlName="name" required>
@if (outputForm.get('name').errors && outputForm.get('name').touched) {
<mat-error>
@if (outputForm.get('name').hasError('required')) {
{{ 'common.hint.key-required' | translate }}
} @else if (outputForm.get('name').hasError('pattern')) {
{{ 'common.hint.key-pattern' | translate }}
} @else if (outputForm.get('name').hasError('maxlength')) {
{{ 'common.hint.key-max-length' | translate }}
}
</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>
</div>
<ng-content select=".simpleMode"></ng-content>
}
}
</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 { EntityId } from '@shared/models/id/entity-id';
import { EntityType } from '@shared/models/entity-type.models';
import { coerceBoolean } from '@shared/decorators/coercion';
@Component({
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 {
@Input()
@coerceBoolean()
simpleMode = false;
@Input()
@coerceBoolean()
hiddenName = false;
@Input({required: true})
entityId: EntityId;
@ -137,11 +143,14 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val
}
private updatedFormWithMode(): void {
if (this.simpleMode) {
if (this.simpleMode && !this.hiddenName) {
this.outputForm.get('name').enable({emitEvent: false});
this.outputForm.get('decimalsByDefault').enable({emitEvent: false});
} else {
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});
}
}

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">
<mat-form-field [class]="{'number': inlineField, 'max-w-66%': !inlineField, 'flex-full': !inlineField}"
[appearance]="inlineField ? 'outline' : appearance"
[subscriptSizing]="inlineField ? 'dynamic' : subscriptSizing">
subscriptSizing="dynamic">
@if (labelText && !inlineField) {
<mat-label>{{ labelText }}</mat-label>
}
@ -41,7 +41,9 @@
{{ hasError }}
</mat-error>
</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"
[subscriptSizing]="inlineField ? 'dynamic' : subscriptSizing">
@if (!inlineField) {

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

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

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

@ -1054,7 +1054,8 @@
"simple": "Simple",
"script": "Script",
"geofencing" : "Geofencing",
"propagation": "Propagation"
"propagation": "Propagation",
"related-entities-aggregation": "Related entities aggregation"
},
"arguments": "Arguments",
"decimals-by-default": "Decimals by default",
@ -1088,6 +1089,7 @@
"shared-attributes": "Shared attributes",
"attribute-key": "Attribute key",
"default-value": "Default value",
"default-value-required": "Default value is required.",
"limit": "Max values",
"time-window": "Time window",
"customer-name": "Customer name",
@ -1156,6 +1158,11 @@
"data-propagate": "Data to propagate",
"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.",
"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.",
@ -1202,7 +1209,11 @@
"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.",
"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": {

Loading…
Cancel
Save