Browse Source

Merge branch 'feature/propagation-cf' of github.com:ShvaykaD/thingsboard into feature/propagation-cf

pull/14107/head
dshvaika 11 months ago
parent
commit
7260a2ce1d
  1. 68
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts
  2. 74
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts
  3. 134
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html
  4. 0
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.scss
  5. 113
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts
  6. 15
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.html
  7. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.scss
  8. 60
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts
  9. 45
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module.ts
  10. 116
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts
  11. 203
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html
  12. 237
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts
  13. 0
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html
  14. 0
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.scss
  15. 0
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts
  16. 0
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.html
  17. 0
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.scss
  18. 8
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.ts
  19. 68
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.html
  20. 157
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts
  21. 52
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.module.ts
  22. 86
      ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html
  23. 148
      ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts
  24. 36
      ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.module.ts
  25. 99
      ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.html
  26. 174
      ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts
  27. 44
      ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.module.ts
  28. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/public-api.ts
  29. 98
      ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.html
  30. 206
      ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts
  31. 44
      ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.module.ts
  32. 41
      ui-ngx/src/app/modules/home/components/home-components.module.ts
  33. 4
      ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile.module.ts
  34. 4
      ui-ngx/src/app/modules/home/pages/asset/asset.module.ts
  35. 2
      ui-ngx/src/app/modules/home/pages/device-profile/device-profile.module.ts
  36. 2
      ui-ngx/src/app/modules/home/pages/device/device.module.ts
  37. 2
      ui-ngx/src/app/shared/components/time-unit-input.component.ts
  38. 96
      ui-ngx/src/app/shared/models/calculated-field.models.ts
  39. 26
      ui-ngx/src/assets/locale/locale.constant-en_US.json

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

@ -0,0 +1,68 @@
///
/// 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 { CalculatedFieldsTableComponent } from '@home/components/calculated-fields/calculated-fields-table.component';
import {
CalculatedFieldDialogComponent
} from '@home/components/calculated-fields/components/dialog/calculated-field-dialog.component';
import {
CalculatedFieldDebugDialogComponent
} from '@home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component';
import {
CalculatedFieldScriptTestDialogComponent
} from '@home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component';
import {
CalculatedFieldTestArgumentsComponent
} from '@home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component';
import {
EntityDebugSettingsButtonComponent
} from '@home/components/entity/debug/entity-debug-settings-button.component';
import { HomeComponentsModule } from '@home/components/home-components.module';
import {
GeofencingConfigurationModule
} from '@home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.module';
import {
SimpleConfigurationModule
} from '@home/components/calculated-fields/components/simple-configuration/simple-configuration.module';
import {
PropagationConfigurationModule
} from '@home/components/calculated-fields/components/propagation-configuration/propagation-configuration.module';
@NgModule({
declarations: [
CalculatedFieldsTableComponent,
CalculatedFieldDialogComponent,
CalculatedFieldDebugDialogComponent,
CalculatedFieldScriptTestDialogComponent,
CalculatedFieldTestArgumentsComponent,
],
imports: [
CommonModule,
SharedModule,
GeofencingConfigurationModule,
EntityDebugSettingsButtonComponent,
HomeComponentsModule,
SimpleConfigurationModule,
PropagationConfigurationModule,
],
exports: [
CalculatedFieldsTableComponent,
]
})
export class CalculatedFieldsModule {}

74
ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts

@ -40,10 +40,12 @@ import {
ArgumentType, ArgumentType,
CalculatedField, CalculatedField,
CalculatedFieldEventArguments, CalculatedFieldEventArguments,
CalculatedFieldScriptConfiguration,
CalculatedFieldType, CalculatedFieldType,
CalculatedFieldTypeTranslations, CalculatedFieldTypeTranslations,
getCalculatedFieldArgumentsEditorCompleter, getCalculatedFieldArgumentsEditorCompleter,
getCalculatedFieldArgumentsHighlights, getCalculatedFieldArgumentsHighlights,
PropagationWithExpression,
} from '@shared/models/calculated-field.models'; } from '@shared/models/calculated-field.models';
import { import {
CalculatedFieldDebugDialogComponent, CalculatedFieldDebugDialogComponent,
@ -122,7 +124,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
this.columns.push(new DateEntityTableColumn<CalculatedField>('createdTime', 'common.created-time', this.datePipe, '150px')); this.columns.push(new DateEntityTableColumn<CalculatedField>('createdTime', 'common.created-time', this.datePipe, '150px'));
this.columns.push(new EntityTableColumn<CalculatedField>('name', 'common.name', '33%')); this.columns.push(new EntityTableColumn<CalculatedField>('name', 'common.name', '33%'));
this.columns.push(new EntityTableColumn<CalculatedField>('type', 'common.type', '70px', entity => this.translate.instant(CalculatedFieldTypeTranslations.get(entity.type)))); this.columns.push(new EntityTableColumn<CalculatedField>('type', 'common.type', '80px', entity => this.translate.instant(CalculatedFieldTypeTranslations.get(entity.type))));
this.columns.push(expressionColumn); this.columns.push(expressionColumn);
this.cellActionDescriptors.push( this.cellActionDescriptors.push(
@ -156,11 +158,13 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
} }
private getExpressionLabel(entity: CalculatedField): string { private getExpressionLabel(entity: CalculatedField): string {
if (entity.type === CalculatedFieldType.SCRIPT) { if (entity.type === CalculatedFieldType.SCRIPT ||
entity.type === CalculatedFieldType.PROPAGATION && entity.configuration.applyExpressionToResolvedArguments === true) {
return 'function calculate(ctx, ' + Object.keys(entity.configuration.arguments).join(', ') + ')'; return 'function calculate(ctx, ' + Object.keys(entity.configuration.arguments).join(', ') + ')';
} else { } else if (entity.type === CalculatedFieldType.SIMPLE) {
return entity.configuration?.expression ?? ''; return entity.configuration.expression ?? '';
} }
return '';
} }
fetchCalculatedFields(pageLink: PageLink): Observable<PageData<CalculatedField>> { fetchCalculatedFields(pageLink: PageLink): Observable<PageData<CalculatedField>> {
@ -287,32 +291,42 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
} }
private getTestScriptDialog(calculatedField: CalculatedField, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true): Observable<string> { private getTestScriptDialog(calculatedField: CalculatedField, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true): Observable<string> {
const resultArguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => { if (
const type = calculatedField.configuration.arguments[key].refEntityKey.type; calculatedField.type === CalculatedFieldType.SCRIPT ||
acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key) (calculatedField.type === CalculatedFieldType.PROPAGATION && calculatedField.configuration.applyExpressionToResolvedArguments === true)
? { ...argumentsObj[key], type } ) {
: type === ArgumentType.Rolling ? { values: [], type } : { value: '', type, ts: new Date().getTime() }; const resultArguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => {
return acc; const type = calculatedField.configuration.arguments[key].refEntityKey.type;
}, {}); acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key)
return this.dialog.open<CalculatedFieldScriptTestDialogComponent, CalculatedFieldTestScriptDialogData, string>(CalculatedFieldScriptTestDialogComponent, ? {...argumentsObj[key], type}
{ : type === ArgumentType.Rolling ? {values: [], type} : {value: '', type, ts: new Date().getTime()};
disableClose: true, return acc;
panelClass: ['tb-dialog', 'tb-fullscreen-dialog', 'tb-fullscreen-dialog-gt-xs'], }, {});
data: { return this.dialog.open<CalculatedFieldScriptTestDialogComponent, CalculatedFieldTestScriptDialogData, string>(CalculatedFieldScriptTestDialogComponent,
arguments: resultArguments, {
expression: calculatedField.configuration.expression, disableClose: true,
argumentsEditorCompleter: getCalculatedFieldArgumentsEditorCompleter(calculatedField.configuration.arguments), panelClass: ['tb-dialog', 'tb-fullscreen-dialog', 'tb-fullscreen-dialog-gt-xs'],
argumentsHighlightRules: getCalculatedFieldArgumentsHighlights(calculatedField.configuration.arguments), data: {
openCalculatedFieldEdit arguments: resultArguments,
} expression: (calculatedField.configuration as CalculatedFieldScriptConfiguration | PropagationWithExpression).expression,
}).afterClosed() argumentsEditorCompleter: getCalculatedFieldArgumentsEditorCompleter(calculatedField.configuration.arguments),
.pipe( argumentsHighlightRules: getCalculatedFieldArgumentsHighlights(calculatedField.configuration.arguments),
filter(Boolean), openCalculatedFieldEdit
tap(expression => {
if (openCalculatedFieldEdit) {
this.editCalculatedField({ entityId: this.entityId, ...calculatedField, configuration: {...calculatedField.configuration, expression } }, true)
} }
}), }).afterClosed()
); .pipe(
filter(Boolean),
tap(expression => {
if (openCalculatedFieldEdit) {
this.editCalculatedField({
entityId: this.entityId, ...calculatedField,
configuration: {...calculatedField.configuration, expression} as any
}, true)
}
}),
);
} else {
return of(null);
}
} }
} }

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

@ -19,62 +19,34 @@
<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>
<div class="tb-form-panel no-border no-padding"> <div class="tb-form-panel no-border no-padding">
<div class="tb-form-row"> @if (!isOutputKey) {
<div class="fixed-title-width tb-required">{{ 'calculated-fields.argument-name' | translate }}</div> <ng-container *ngTemplateOutlet="argumentNameTemplate; context: {
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic"> label: 'calculated-fields.argument-name',
<input matInput autocomplete="off" name="value" formControlName="argumentName" maxlength="255" placeholder="{{ 'action.set' | translate }}"/> required: 'calculated-fields.hint.argument-name-required',
@if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('required')) { duplicate: 'calculated-fields.hint.argument-name-duplicate',
<mat-icon matSuffix pattern: 'calculated-fields.hint.argument-name-pattern',
matTooltipPosition="above" maxlength: 'calculated-fields.hint.argument-name-max-length',
matTooltipClass="tb-error-tooltip" forbidden: 'calculated-fields.hint.argument-name-forbidden'
[matTooltip]="'calculated-fields.hint.argument-name-required' | translate" }"></ng-container>
class="tb-error"> }
warning <ng-container>
</mat-icon>
} @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('duplicateName')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.argument-name-duplicate' | translate"
class="tb-error">
warning
</mat-icon>
} @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('pattern')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.argument-name-pattern' | translate"
class="tb-error">
warning
</mat-icon>
} @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('maxlength')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.argument-name-max-length' | translate"
class="tb-error">
warning
</mat-icon>
} @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('forbiddenName')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.argument-name-forbidden' | translate"
class="tb-error">
warning
</mat-icon>
}
</mat-form-field>
</div>
<ng-container [formGroup]="refEntityIdFormGroup">
<div class="tb-form-row"> <div class="tb-form-row">
<div class="fixed-title-width">{{ 'entity.entity-type' | translate }}</div> <div class="fixed-title-width">{{ 'entity.entity-type' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic"> <mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="entityType"> <mat-select [formControl]="argumentType">
@for (type of argumentEntityTypes; track type) { @for (type of argumentEntityTypes; track type) {
<mat-option [value]="type">{{ ArgumentEntityTypeTranslations.get(type) | translate }}</mat-option> <mat-option [value]="type">{{ ArgumentEntityTypeTranslations.get(type) | translate }}</mat-option>
} }
</mat-select> </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> </mat-form-field>
</div> </div>
@if (ArgumentEntityTypeParamsMap.has(entityType)) { @if (ArgumentEntityTypeParamsMap.has(entityType)) {
@ -83,7 +55,8 @@
<tb-entity-autocomplete <tb-entity-autocomplete
class="flex flex-1" class="flex flex-1"
#entityAutocomplete #entityAutocomplete
formControlName="id" formControlName="refEntityId"
useFullEntityId
inlineField inlineField
[placeholder]="'action.set' | translate" [placeholder]="'action.set' | translate"
[required]="true" [required]="true"
@ -158,6 +131,16 @@
} }
} }
</ng-container> </ng-container>
@if (isOutputKey) {
<ng-container *ngTemplateOutlet="argumentNameTemplate; context: {
label: 'calculated-fields.output-key',
required: 'calculated-fields.hint.output-key-required',
duplicate: 'calculated-fields.hint.output-key-duplicate',
pattern: 'calculated-fields.hint.output-key-pattern',
maxlength: 'calculated-fields.hint.output-key-max-length',
forbidden: 'calculated-fields.hint.output-key-forbidden'
}"></ng-container>
}
@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">{{ 'calculated-fields.default-value' | translate }}</div>
@ -207,3 +190,54 @@
</button> </button>
</div> </div>
</div> </div>
<ng-template #argumentNameTemplate let-label="label" let-required="required" let-duplicate="duplicate"
let-pattern="pattern" let-maxlength="maxlength" let-forbidden="forbidden">
<div class="tb-form-row" [formGroup]="argumentFormGroup">
<div class="fixed-title-width tb-required">{{ label | translate }}</div>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<input matInput autocomplete="off" name="value" formControlName="argumentName" maxlength="255" placeholder="{{ 'action.set' | translate }}"/>
@if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('required')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="required | translate"
class="tb-error">
warning
</mat-icon>
} @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('duplicateName')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="duplicate | translate"
class="tb-error">
warning
</mat-icon>
} @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('pattern')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="pattern | translate"
class="tb-error">
warning
</mat-icon>
} @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('maxlength')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="maxlength | translate"
class="tb-error">
warning
</mat-icon>
} @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('forbiddenName')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="forbidden | translate"
class="tb-error">
warning
</mat-icon>
}
</mat-form-field>
</div>
</ng-template>

0
ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.scss → ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.scss

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

@ -14,7 +14,16 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { AfterViewInit, ChangeDetectorRef, Component, Input, OnInit, output, ViewChild } from '@angular/core'; import {
AfterViewInit,
ChangeDetectorRef,
Component,
DestroyRef,
Input,
OnInit,
output,
ViewChild
} from '@angular/core';
import { TbPopoverComponent } from '@shared/components/popover.component'; import { TbPopoverComponent } from '@shared/components/popover.component';
import { FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'; import { FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms';
import { charsWithNumRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants'; import { charsWithNumRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants';
@ -25,7 +34,6 @@ import {
ArgumentType, ArgumentType,
ArgumentTypeTranslations, ArgumentTypeTranslations,
CalculatedFieldArgumentValue, CalculatedFieldArgumentValue,
CalculatedFieldType,
getCalculatedFieldCurrentEntityFilter getCalculatedFieldCurrentEntityFilter
} from '@shared/models/calculated-field.models'; } from '@shared/models/calculated-field.models';
import { debounceTime, delay, distinctUntilChanged, filter } from 'rxjs/operators'; import { debounceTime, delay, distinctUntilChanged, filter } from 'rxjs/operators';
@ -43,6 +51,7 @@ import { AppState } from '@core/core.state';
import { Store } from '@ngrx/store'; 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';
@Component({ @Component({
selector: 'tb-calculated-field-argument-panel', selector: 'tb-calculated-field-argument-panel',
@ -56,22 +65,23 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
@Input() entityId: EntityId; @Input() entityId: EntityId;
@Input() tenantId: string; @Input() tenantId: string;
@Input() entityName: string; @Input() entityName: string;
@Input() calculatedFieldType: CalculatedFieldType; @Input() isScript: boolean;
@Input() usedArgumentNames: string[]; @Input() usedArgumentNames: string[];
@Input() isOutputKey = false;
@Input() argumentEntityTypes = Object.values(ArgumentEntityType).filter(value => value !== ArgumentEntityType.RelationQuery) as ArgumentEntityType[];
@ViewChild('entityAutocomplete') entityAutocomplete: EntityAutocompleteComponent; @ViewChild('entityAutocomplete') entityAutocomplete: EntityAutocompleteComponent;
argumentsDataApplied = output<CalculatedFieldArgumentValue>(); argumentsDataApplied = output<CalculatedFieldArgumentValue>();
argumentType = this.fb.control(ArgumentEntityType.Current, Validators.required);
readonly maxDataPointsPerRollingArg = getCurrentAuthState(this.store).maxDataPointsPerRollingArg; readonly maxDataPointsPerRollingArg = getCurrentAuthState(this.store).maxDataPointsPerRollingArg;
readonly defaultLimit = Math.floor(this.maxDataPointsPerRollingArg / 10); readonly defaultLimit = Math.floor(this.maxDataPointsPerRollingArg / 10);
argumentFormGroup = this.fb.group({ argumentFormGroup = this.fb.group({
argumentName: ['', [Validators.required, this.uniqNameRequired(), this.forbiddenArgumentNameValidator(), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]], argumentName: ['', [Validators.required, this.uniqNameRequired(), this.forbiddenArgumentNameValidator(), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]],
refEntityId: this.fb.group({ refEntityId: [null],
entityType: [ArgumentEntityType.Current],
id: ['']
}),
refEntityKey: this.fb.group({ refEntityKey: this.fb.group({
type: [ArgumentType.LatestTelemetry, [Validators.required]], type: [ArgumentType.LatestTelemetry, [Validators.required]],
key: ['', [Validators.pattern(oneSpaceInsideRegex)]], key: ['', [Validators.pattern(oneSpaceInsideRegex)]],
@ -86,7 +96,6 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
entityFilter: EntityFilter; entityFilter: EntityFilter;
entityNameSubject = new BehaviorSubject<string>(null); entityNameSubject = new BehaviorSubject<string>(null);
readonly argumentEntityTypes = Object.values(ArgumentEntityType).filter(value => value !== ArgumentEntityType.RelationQuery) as ArgumentEntityType[];
readonly ArgumentEntityTypeTranslations = ArgumentEntityTypeTranslations; readonly ArgumentEntityTypeTranslations = ArgumentEntityTypeTranslations;
readonly ArgumentType = ArgumentType; readonly ArgumentType = ArgumentType;
readonly DataKeyType = DataKeyType; readonly DataKeyType = DataKeyType;
@ -103,20 +112,17 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
private fb: FormBuilder, private fb: FormBuilder,
private cd: ChangeDetectorRef, private cd: ChangeDetectorRef,
private popover: TbPopoverComponent<CalculatedFieldArgumentPanelComponent>, private popover: TbPopoverComponent<CalculatedFieldArgumentPanelComponent>,
private store: Store<AppState> private store: Store<AppState>,
private destroyRef: DestroyRef
) { ) {
this.observeEntityFilterChanges(); this.observeEntityFilterChanges();
this.observeEntityTypeChanges(); this.observeArgumentTypeChanges();
this.observeEntityKeyChanges(); this.observeEntityKeyChanges();
this.observeUpdatePosition(); this.observeUpdatePosition();
} }
get entityType(): ArgumentEntityType { get entityType(): ArgumentEntityType {
return this.argumentFormGroup.get('refEntityId').get('entityType').value; return this.argumentType.value;
}
get refEntityIdFormGroup(): FormGroup {
return this.argumentFormGroup.get('refEntityId') as FormGroup;
} }
get refEntityKeyFormGroup(): FormGroup { get refEntityKeyFormGroup(): FormGroup {
@ -130,14 +136,18 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
} }
ngOnInit(): void { ngOnInit(): void {
this.updatedArgumentType();
this.argumentFormGroup.patchValue(this.argument, {emitEvent: false}); this.argumentFormGroup.patchValue(this.argument, {emitEvent: false});
this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.entityName, this.entityId); this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.entityName, this.entityId);
this.updateEntityFilter(this.argument.refEntityId?.entityType, true); this.updateEntityFilter(this.entityType, true);
this.updatedRefEntityIdState(this.entityType);
this.toggleByEntityKeyType(this.argument.refEntityKey?.type); this.toggleByEntityKeyType(this.argument.refEntityKey?.type);
this.setInitialEntityKeyType(); this.setInitialEntityKeyType();
this.setInitialEntityType();
this.setWatchKeyChange();
this.argumentTypes = Object.values(ArgumentType) this.argumentTypes = Object.values(ArgumentType)
.filter(type => type !== ArgumentType.Rolling || this.calculatedFieldType === CalculatedFieldType.SCRIPT); .filter(type => type !== ArgumentType.Rolling || this.isScript);
} }
ngAfterViewInit(): void { ngAfterViewInit(): void {
@ -147,12 +157,11 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
} }
saveArgument(): void { saveArgument(): void {
const { refEntityId, ...restConfig } = this.argumentFormGroup.value; const value = this.argumentFormGroup.value as CalculatedFieldArgumentValue;
const value = (refEntityId.entityType === ArgumentEntityType.Current ? restConfig : { refEntityId, ...restConfig }) as CalculatedFieldArgumentValue; if (this.entityType === ArgumentEntityType.Tenant) {
if (refEntityId.entityType === ArgumentEntityType.Tenant) { value.refEntityId = new TenantId(this.tenantId) as any;
refEntityId.id = this.tenantId;
} }
if (refEntityId.entityType !== ArgumentEntityType.Current && refEntityId.entityType !== ArgumentEntityType.Tenant) { if (this.entityType !== ArgumentEntityType.Current && this.entityType !== ArgumentEntityType.Tenant) {
value.entityName = this.entityNameSubject.value; value.entityName = this.entityNameSubject.value;
} }
if (value.defaultValue) { if (value.defaultValue) {
@ -166,6 +175,14 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
this.popover.hide(); this.popover.hide();
} }
private updatedArgumentType(): void {
let argumentType = ArgumentEntityType.Current;
if (this.argument.refEntityId?.entityType) {
argumentType = this.argument.refEntityId.entityType;
}
this.argumentType.setValue(argumentType, {emitEvent: false});
}
private toggleByEntityKeyType(type: ArgumentType): void { private toggleByEntityKeyType(type: ArgumentType): void {
const isAttribute = type === ArgumentType.Attribute; const isAttribute = type === ArgumentType.Attribute;
const isRolling = type === ArgumentType.Rolling; const isRolling = type === ArgumentType.Rolling;
@ -205,26 +222,21 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
private observeEntityFilterChanges(): void { private observeEntityFilterChanges(): void {
merge( merge(
this.refEntityIdFormGroup.get('entityType').valueChanges, this.argumentType.valueChanges,
this.refEntityKeyFormGroup.get('type').valueChanges, this.refEntityKeyFormGroup.get('type').valueChanges,
this.refEntityIdFormGroup.get('id').valueChanges.pipe(filter(Boolean)), this.argumentFormGroup.get('refEntityId').valueChanges.pipe(filter(Boolean)),
this.refEntityKeyFormGroup.get('scope').valueChanges, this.refEntityKeyFormGroup.get('scope').valueChanges,
) )
.pipe(debounceTime(50), takeUntilDestroyed()) .pipe(debounceTime(50), takeUntilDestroyed())
.subscribe(() => this.updateEntityFilter(this.entityType)); .subscribe(() => this.updateEntityFilter(this.entityType));
} }
private observeEntityTypeChanges(): void { private observeArgumentTypeChanges(): void {
this.refEntityIdFormGroup.get('entityType').valueChanges this.argumentType.valueChanges
.pipe(distinctUntilChanged(), takeUntilDestroyed()) .pipe(distinctUntilChanged(), takeUntilDestroyed())
.subscribe(type => { .subscribe(type => {
this.argumentFormGroup.get('refEntityId').get('id').setValue(''); this.argumentFormGroup.get('refEntityId').setValue(null);
const isEntityWithId = type !== ArgumentEntityType.Tenant && type !== ArgumentEntityType.Current; this.updatedRefEntityIdState(type);
this.argumentFormGroup.get('refEntityId')
.get('id')[isEntityWithId ? 'enable' : 'disable']();
if (!isEntityWithId) {
this.entityNameSubject.next(null);
}
if (!this.enableAttributeScopeSelection) { if (!this.enableAttributeScopeSelection) {
this.refEntityKeyFormGroup.get('scope').setValue(AttributeScope.SERVER_SCOPE); this.refEntityKeyFormGroup.get('scope').setValue(AttributeScope.SERVER_SCOPE);
} }
@ -247,29 +259,56 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
} }
private setInitialEntityKeyType(): void { private setInitialEntityKeyType(): void {
if (this.calculatedFieldType === CalculatedFieldType.SIMPLE && this.argument.refEntityKey?.type === ArgumentType.Rolling) { if (!this.isScript && this.argument.refEntityKey?.type === ArgumentType.Rolling) {
const typeControl = this.argumentFormGroup.get('refEntityKey').get('type'); const typeControl = this.argumentFormGroup.get('refEntityKey').get('type');
typeControl.setValue(null); typeControl.setValue(null);
typeControl.markAsTouched(); typeControl.markAsTouched();
} }
} }
private setInitialEntityType() {
if (!this.argumentEntityTypes.includes(this.entityType)) {
this.argumentType.setValue(null);
this.argumentType.markAsTouched();
}
}
private setWatchKeyChange(): void {
if (this.isOutputKey) {
this.refEntityKeyFormGroup.get('key').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe((key) => {
if (this.argumentFormGroup.get('argumentName').pristine) {
this.argumentFormGroup.get('argumentName').setValue(key);
}
});
}
}
private forbiddenArgumentNameValidator(): ValidatorFn { private forbiddenArgumentNameValidator(): ValidatorFn {
return (control: FormControl) => { return (control: FormControl) => {
const trimmedValue = control.value.trim().toLowerCase(); const trimmedValue = control.value.trim().toLowerCase();
const forbiddenArgumentNames = ['ctx', 'e', 'pi']; const forbiddenArgumentNames = ['ctx', 'e', 'pi', 'propagationCtx'];
return forbiddenArgumentNames.includes(trimmedValue) ? { forbiddenName: true } : null; return forbiddenArgumentNames.includes(trimmedValue) ? { forbiddenName: true } : null;
}; };
} }
private observeUpdatePosition(): void { private observeUpdatePosition(): void {
merge( merge(
this.refEntityIdFormGroup.get('entityType').valueChanges, this.argumentType.valueChanges,
this.refEntityKeyFormGroup.get('type').valueChanges, this.refEntityKeyFormGroup.get('type').valueChanges,
this.argumentFormGroup.get('timeWindow').valueChanges, this.argumentFormGroup.get('timeWindow').valueChanges,
this.refEntityIdFormGroup.get('id').valueChanges.pipe(filter(Boolean)), this.argumentFormGroup.get('refEntityId').valueChanges.pipe(filter(Boolean)),
) )
.pipe(delay(50), takeUntilDestroyed()) .pipe(delay(50), takeUntilDestroyed())
.subscribe(() => this.popover.updatePosition()); .subscribe(() => this.popover.updatePosition());
} }
private updatedRefEntityIdState(type: ArgumentEntityType): void {
const isEntityWithId = !!type && type !== ArgumentEntityType.Tenant && type !== ArgumentEntityType.Current;
this.argumentFormGroup.get('refEntityId')[isEntityWithId ? 'enable' : 'disable']();
if (!isEntityWithId) {
this.entityNameSubject.next(null);
}
}
} }

15
ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html → ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.html

@ -21,7 +21,7 @@
[matSortActive]="sortOrder.property" [matSortDirection]="sortOrder.direction" matSortDisableClear> [matSortActive]="sortOrder.property" [matSortDirection]="sortOrder.direction" matSortDisableClear>
<ng-container [matColumnDef]="'name'"> <ng-container [matColumnDef]="'name'">
<mat-header-cell mat-sort-header *matHeaderCellDef class="!w-1/3 xs:!w-1/2"> <mat-header-cell mat-sort-header *matHeaderCellDef class="!w-1/3 xs:!w-1/2">
<div tbTruncateWithTooltip>{{ 'common.name' | translate }}</div> <div tbTruncateWithTooltip>{{ argumentNameColumn | translate }}</div>
</mat-header-cell> </mat-header-cell>
<mat-cell *matCellDef="let argument" class="argument-name-cell w-1/3 xs:w-1/2"> <mat-cell *matCellDef="let argument" class="argument-name-cell w-1/3 xs:w-1/2">
<div class="flex items-center"> <div class="flex items-center">
@ -29,7 +29,7 @@
<tb-copy-button <tb-copy-button
class="copy-argument-name" class="copy-argument-name"
[copyText]="argument.argumentName" [copyText]="argument.argumentName"
tooltipText="{{ 'calculated-fields.copy-argument-name' | translate }}" tooltipText="{{ argumentNameColumnCopy | translate }}"
tooltipPosition="above" tooltipPosition="above"
icon="content_copy" icon="content_copy"
/> />
@ -37,7 +37,7 @@
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<ng-container [matColumnDef]="'entityType'"> <ng-container [matColumnDef]="'entityType'">
<mat-header-cell mat-sort-header *matHeaderCellDef class="entity-type-header w-1/5 xs:hidden"> <mat-header-cell mat-sort-header *matHeaderCellDef class="w-1/5 xs:hidden">
{{ 'entity.entity-type' | translate }} {{ 'entity.entity-type' | translate }}
</mat-header-cell> </mat-header-cell>
<mat-cell *matCellDef="let argument" class="w-1/5 xs:hidden"> <mat-cell *matCellDef="let argument" class="w-1/5 xs:hidden">
@ -96,8 +96,7 @@
[matTooltip]="'action.edit' | translate" [matTooltip]="'action.edit' | translate"
matTooltipPosition="above"> matTooltipPosition="above">
<mat-icon <mat-icon
[matBadgeHidden]="!(argument.refEntityKey.type === ArgumentType.Rolling [matBadgeHidden]="isEditButtonShowBadge(argument)"
&& calculatedFieldType === CalculatedFieldType.SIMPLE) && argument.refEntityId?.id !== NULL_UUID"
matBadgeColor="warn" matBadgeColor="warn"
matBadgeSize="small" matBadgeSize="small"
matBadge="*" matBadge="*"
@ -115,10 +114,8 @@
</div> </div>
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<mat-header-row class="mat-row-select" <mat-header-row class="mat-row-select" *matHeaderRowDef="displayColumns"></mat-header-row>
*matHeaderRowDef="['name', 'entityType', 'target', 'type', 'key', 'actions']"></mat-header-row> <mat-row *matRowDef="let argument; columns: displayColumns"></mat-row>
<mat-row
*matRowDef="let argument; columns: ['name', 'entityType', 'target', 'type', 'key', 'actions']"></mat-row>
</table> </table>
<div [class.!hidden]="(dataSource.isEmpty() | async) === false" <div [class.!hidden]="(dataSource.isEmpty() | async) === false"
class="tb-prompt flex flex-1 items-end justify-center"> class="tb-prompt flex flex-1 items-end justify-center">

2
ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.scss → ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.scss

@ -62,7 +62,7 @@
} }
.arguments-table { .arguments-table {
.mat-mdc-header-row.mat-row-select .mat-mdc-header-cell.entity-type-header { .mat-mdc-header-row.mat-row-select .mat-mdc-header-cell:nth-child(2) {
padding: 0 28px 0 0; padding: 0 28px 0 0;
} }
} }

60
ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.ts → ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component.ts

@ -43,17 +43,19 @@ import {
CalculatedFieldArgumentValue, CalculatedFieldArgumentValue,
CalculatedFieldType, CalculatedFieldType,
} from '@shared/models/calculated-field.models'; } from '@shared/models/calculated-field.models';
import { CalculatedFieldArgumentPanelComponent } from '@home/components/calculated-fields/components/public-api'; import {
CalculatedFieldArgumentPanelComponent
} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component';
import { MatButton } from '@angular/material/button'; import { MatButton } from '@angular/material/button';
import { TbPopoverService } from '@shared/components/popover.service'; import { TbPopoverService } from '@shared/components/popover.service';
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, entityTypeTranslations } from '@shared/models/entity-type.models'; import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models';
import { getEntityDetailsPageURL, isEqual } from '@core/utils'; import { getEntityDetailsPageURL, isDefined, isEqual } from '@core/utils';
import { TbPopoverComponent } from '@shared/components/popover.component'; import { TbPopoverComponent } from '@shared/components/popover.component';
import { TbTableDatasource } from '@shared/components/table/table-datasource.abstract'; import { TbTableDatasource } from '@shared/components/table/table-datasource.abstract';
import { EntityService } from '@core/http/entity.service'; import { EntityService } from '@core/http/entity.service';
import { MatSort } from '@angular/material/sort'; import { MatSort, SortDirection } from '@angular/material/sort';
import { getCurrentAuthState } from '@core/auth/auth.selectors'; import { getCurrentAuthState } from '@core/auth/auth.selectors';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
@ -83,16 +85,22 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
@Input() entityId: EntityId; @Input() entityId: EntityId;
@Input() tenantId: string; @Input() tenantId: string;
@Input() entityName: string; @Input() entityName: string;
@Input() calculatedFieldType: CalculatedFieldType; @Input() isScript: boolean;
@ViewChild(MatSort, { static: true }) sort: MatSort; @ViewChild(MatSort, { static: true }) sort: MatSort;
errorText = ''; errorText = '';
argumentsFormArray = this.fb.array<CalculatedFieldArgumentValue>([]); argumentsFormArray = this.fb.array<CalculatedFieldArgumentValue>([]);
entityNameMap = new Map<string, string>(); entityNameMap = new Map<string, string>();
sortOrder = { direction: 'asc', property: '' }; sortOrder: { direction: SortDirection; property: string } = {direction: 'asc', property: ''};
dataSource = new CalculatedFieldArgumentDatasource(); dataSource = new CalculatedFieldArgumentDatasource();
argumentNameColumn = 'common.name';
argumentNameColumnCopy = 'calculated-fields.copy-argument-name';
displayColumns = ['name', 'entityType', 'target', 'type', 'key', 'actions'];
protected panelAdditionalCtx: Record<string, any>
readonly entityTypeTranslations = entityTypeTranslations; readonly entityTypeTranslations = entityTypeTranslations;
readonly ArgumentTypeTranslations = ArgumentTypeTranslations; readonly ArgumentTypeTranslations = ArgumentTypeTranslations;
readonly ArgumentEntityType = ArgumentEntityType; readonly ArgumentEntityType = ArgumentEntityType;
@ -105,14 +113,14 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
private propagateChange: (argumentsObj: Record<string, CalculatedFieldArgument>) => void = () => {}; private propagateChange: (argumentsObj: Record<string, CalculatedFieldArgument>) => void = () => {};
constructor( constructor(
private fb: FormBuilder, protected fb: FormBuilder,
private popoverService: TbPopoverService, protected popoverService: TbPopoverService,
private viewContainerRef: ViewContainerRef, protected viewContainerRef: ViewContainerRef,
private cd: ChangeDetectorRef, protected cd: ChangeDetectorRef,
private renderer: Renderer2, protected renderer: Renderer2,
private entityService: EntityService, protected entityService: EntityService,
private destroyRef: DestroyRef, protected destroyRef: DestroyRef,
private store: Store<AppState> protected store: Store<AppState>
) { ) {
this.argumentsFormArray.valueChanges.pipe(takeUntilDestroyed()).subscribe(value => { this.argumentsFormArray.valueChanges.pipe(takeUntilDestroyed()).subscribe(value => {
this.updateDataSource(value); this.updateDataSource(value);
@ -121,9 +129,8 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
} }
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
if (changes.calculatedFieldType?.previousValue if (isDefined(changes.isScript?.previousValue) && changes.isScript.currentValue !== changes.isScript.previousValue) {
&& changes.calculatedFieldType.currentValue !== changes.calculatedFieldType.previousValue) { this.changeIsScriptMode();
this.argumentsFormArray.updateValueAndValidity();
} }
} }
@ -139,7 +146,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
this.propagateChange = fn; this.propagateChange = fn;
} }
registerOnTouched(_): void {} registerOnTouched(_: any): void {}
validate(): ValidationErrors | null { validate(): ValidationErrors | null {
this.updateErrorText(); this.updateErrorText();
@ -168,7 +175,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
index, index,
argument, argument,
entityId: this.entityId, entityId: this.entityId,
calculatedFieldType: this.calculatedFieldType, isScript: this.isScript,
buttonTitle: isExists ? 'action.apply' : 'action.add', buttonTitle: isExists ? 'action.apply' : 'action.add',
tenantId: this.tenantId, tenantId: this.tenantId,
entityName: this.entityName, entityName: this.entityName,
@ -179,8 +186,8 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
renderer: this.renderer, renderer: this.renderer,
componentType: CalculatedFieldArgumentPanelComponent, componentType: CalculatedFieldArgumentPanelComponent,
hostView: this.viewContainerRef, hostView: this.viewContainerRef,
preferredPlacement: isExists ? ['left', 'leftTop', 'leftBottom'] : ['topRight', 'right', 'rightTop'], preferredPlacement: isExists ? ['leftOnly', 'leftTopOnly', 'leftBottomOnly'] : ['rightOnly', 'rightTopOnly', 'rightBottomOnly'],
context: ctx, context: Object.assign(ctx, this.panelAdditionalCtx),
isModal: true isModal: true
}); });
this.popoverComponent.tbComponentRef.instance.argumentsDataApplied.subscribe(({ entityName, ...value }) => { this.popoverComponent.tbComponentRef.instance.argumentsDataApplied.subscribe(({ entityName, ...value }) => {
@ -203,9 +210,8 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
this.dataSource.loadData(sortedValue); this.dataSource.loadData(sortedValue);
} }
private updateErrorText(): void { protected updateErrorText(): void {
if (this.calculatedFieldType === CalculatedFieldType.SIMPLE if (!this.isScript && this.argumentsFormArray.controls.some(control => control.value.refEntityKey.type === ArgumentType.Rolling)) {
&& this.argumentsFormArray.controls.some(control => control.value.refEntityKey.type === ArgumentType.Rolling)) {
this.errorText = 'calculated-fields.hint.arguments-simple-with-rolling'; this.errorText = 'calculated-fields.hint.arguments-simple-with-rolling';
} else if (this.argumentsFormArray.controls.some(control => control.value.refEntityId?.id === NULL_UUID)) { } else if (this.argumentsFormArray.controls.some(control => control.value.refEntityId?.id === NULL_UUID)) {
this.errorText = 'calculated-fields.hint.arguments-entity-not-found'; this.errorText = 'calculated-fields.hint.arguments-entity-not-found';
@ -234,6 +240,14 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
return getEntityDetailsPageURL(id, type); return getEntityDetailsPageURL(id, type);
} }
protected changeIsScriptMode(): void {
this.argumentsFormArray.updateValueAndValidity();
}
protected isEditButtonShowBadge(argument: CalculatedFieldArgumentValue): boolean {
return !(argument.refEntityKey.type === ArgumentType.Rolling && !this.isScript) && argument.refEntityId?.id !== NULL_UUID
}
private populateArgumentsFormArray(argumentsObj: Record<string, CalculatedFieldArgument>): void { private populateArgumentsFormArray(argumentsObj: Record<string, CalculatedFieldArgument>): void {
Object.keys(argumentsObj).forEach(key => { Object.keys(argumentsObj).forEach(key => {
const value: CalculatedFieldArgumentValue = { const value: CalculatedFieldArgumentValue = {

45
ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.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 {
CalculatedFieldArgumentPanelComponent
} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component';
import {
CalculatedFieldArgumentsTableComponent
} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component';
import {
PropagateArgumentsTableComponent
} from '@home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component';
@NgModule({
imports: [
CommonModule,
SharedModule,
],
declarations: [
CalculatedFieldArgumentPanelComponent,
CalculatedFieldArgumentsTableComponent,
PropagateArgumentsTableComponent
],
exports: [
CalculatedFieldArgumentsTableComponent,
PropagateArgumentsTableComponent
]
})
export class CalculatedFieldArgumentsTableModule {}

116
ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts

@ -0,0 +1,116 @@
///
/// 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,
OnInit,
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, ArgumentType, CalculatedFieldArgumentValue } from '@shared/models/calculated-field.models';
import { isDefined } from '@core/utils';
import { NULL_UUID } from '@shared/models/id/has-uuid';
@Component({
selector: 'tb-propagate-arguments-table',
templateUrl: './calculated-field-arguments-table.component.html',
styleUrls: [`calculated-field-arguments-table.component.scss`],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => PropagateArgumentsTableComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => PropagateArgumentsTableComponent),
multi: true
}
],
})
export class PropagateArgumentsTableComponent extends CalculatedFieldArgumentsTableComponent implements OnInit {
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)
}
ngOnInit() {
this.updatedValue();
}
protected changeIsScriptMode(): void {
this.updatedValue();
super.changeIsScriptMode();
}
private updatedValue() {
if (this.isScript) {
this.argumentNameColumn = 'common.name';
this.argumentNameColumnCopy = 'calculated-fields.copy-argument-name';
this.displayColumns = ['name', 'entityType', 'target', 'type', 'key', 'actions'];
this.panelAdditionalCtx = null;
} else {
this.argumentNameColumn = 'calculated-fields.output-key';
this.argumentNameColumnCopy = 'calculated-fields.copy-output-key';
this.displayColumns = ['name', 'type', 'key', 'actions'];
this.panelAdditionalCtx = {
argumentEntityTypes: [ArgumentEntityType.Current],
isOutputKey: true
};
}
}
protected isEditButtonShowBadge(argument: CalculatedFieldArgumentValue): boolean {
if (!this.isScript && isDefined(argument?.refEntityId)) {
return false;
}
return super.isEditButtonShowBadge(argument);
}
protected updateErrorText(): void {
if (!this.isScript && this.argumentsFormArray.controls.some(control => isDefined(control.value?.refEntityId))) {
this.errorText = 'calculated-fields.hint.arguments-propagate-argument-entity-type';
} else if (!this.isScript && this.argumentsFormArray.controls.some(control => control.value.refEntityKey.type === ArgumentType.Rolling)) {
this.errorText = 'calculated-fields.hint.arguments-propagate-arguments-with-rolling';
} else if (this.argumentsFormArray.controls.some(control => control.value.refEntityId?.id === NULL_UUID)) {
this.errorText = 'calculated-fields.hint.arguments-entity-not-found';
} else if (!this.argumentsFormArray.controls.length) {
this.errorText = 'calculated-fields.hint.arguments-empty';
} else {
this.errorText = '';
}
}
}

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

@ -62,187 +62,30 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
<ng-container [formGroup]="configFormGroup"> @switch (fieldFormGroup.get('type').value) {
@if (fieldFormGroup.get('type').value !== CalculatedFieldType.GEOFENCING) { @case (CalculatedFieldType.GEOFENCING) {
<div class="tb-form-panel"> <tb-geofencing-configuration formControlName="configuration" [entityId]="data.entityId" [entityName]="data.entityName" [tenantId]="data.tenantId">
<div class="tb-form-panel-title tb-required">{{ 'calculated-fields.arguments' | translate }}</div> </tb-geofencing-configuration>
<tb-calculated-field-arguments-table formControlName="arguments"
[entityId]="data.entityId"
[tenantId]="data.tenantId"
[entityName]="data.entityName"
[calculatedFieldType]="fieldFormGroup.get('type').value"/>
</div>
<div class="tb-form-panel no-gap">
<div class="tb-form-panel-title tb-required">
{{ (fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE ? 'calculated-fields.expression' : 'calculated-fields.type.script' ) | translate }}
</div>
<mat-form-field class="mt-3" appearance="outline" subscriptSizing="dynamic" [class.hidden]="fieldFormGroup.get('type').value !== CalculatedFieldType.SIMPLE">
<input matInput formControlName="expressionSIMPLE" maxlength="255" [placeholder]="'(temperature - 32) / 1.8'" required>
<div matSuffix
class="pr-2"
[tb-help-popup]="'math/math-methods_fn'"
tb-help-popup-placement="left"
[tb-help-popup-style]="{maxWidth: '970px'}">
</div>
@if (configFormGroup.get('expressionSIMPLE').errors && configFormGroup.get('expressionSIMPLE').touched) {
<mat-error>
@if (configFormGroup.get('expressionSIMPLE').hasError('required')) {
{{ 'calculated-fields.hint.expression-required' | translate }}
} @else if (configFormGroup.get('expressionSIMPLE').hasError('pattern')) {
{{ 'calculated-fields.hint.expression-invalid' | translate }}
} @else if (configFormGroup.get('expressionSIMPLE').hasError('maxLength')) {
{{ 'calculated-fields.hint.expression-max-length' | translate }}
}
</mat-error>
} @else {
<mat-hint>{{ 'calculated-fields.hint.expression' | translate }}</mat-hint>
}
</mat-form-field>
<div [class.hidden]="fieldFormGroup.get('type').value !== CalculatedFieldType.SCRIPT">
<tb-js-func required
formControlName="expressionSCRIPT"
functionName="calculate"
[functionArgs]="functionArgs$ | async"
[disableUndefinedCheck]="true"
[scriptLanguage]="ScriptLanguage.TBEL"
[highlightRules]="argumentsHighlightRules$ | async"
[editorCompleter]="argumentsEditorCompleter$ | async"
[helpPopupStyle]="{ width: '1200px' }"
helpId="calculated-field/expression_fn">
<div toolbarPrefixButton class="tb-primary-background tbel-script-lang-chip">{{ 'api-usage.tbel' | translate }}</div>
<button toolbarSuffixButton
mat-icon-button
matTooltip="{{ 'calculated-fields.test-script-function' | translate }}"
matTooltipPosition="above"
class="tb-mat-32"
[disabled]="configFormGroup.get('arguments').invalid"
(click)="onTestScript()">
<mat-icon class="material-icons" color="primary">bug_report</mat-icon>
</button>
</tb-js-func>
<div>
<button mat-button mat-raised-button color="primary"
type="button"
(click)="onTestScript()"
[disabled]="configFormGroup.get('arguments').invalid">
{{ 'calculated-fields.test-script-function' | translate }}
</button>
</div>
</div>
</div>
} @else {
<div class="tb-form-panel">
<div class="tb-form-panel-title tb-required" tb-hint-tooltip-icon="{{ 'calculated-fields.hint.entity-coordinates' | translate }}">
{{ 'calculated-fields.entity-coordinates' | translate }}
</div>
<div class="flex items-start gap-3" [formGroup]="coordinatesFormGroup">
<tb-entity-key-autocomplete class="flex-1"
placeholder="{{ 'calculated-fields.latitude-time-series-key' | translate }}"
requiredText="{{ 'calculated-fields.latitude-time-series-key-required' | translate }}"
formControlName="latitudeKeyName"
[dataKeyType]="DataKeyType.timeseries"
[entityFilter]="currentEntityFilter"/>
<tb-entity-key-autocomplete class="flex-1"
placeholder="{{ 'calculated-fields.longitude-time-series-key' | translate }}"
requiredText="{{ 'calculated-fields.longitude-time-series-key-required' | translate }}"
formControlName="longitudeKeyName"
[dataKeyType]="DataKeyType.timeseries"
[entityFilter]="currentEntityFilter"/>
</div>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title tb-required" tb-hint-tooltip-icon="{{ 'calculated-fields.hint.geofencing-zone-groups' | translate }}">
{{ 'calculated-fields.geofencing-zone-groups' | translate }}
</div>
<tb-calculated-field-geofencing-zone-groups-table formControlName="zoneGroups"
[entityId]="data.entityId"
[tenantId]="data.tenantId"
[entityName]="data.entityName"/>
<div class="tb-form-row space-between flex-1 columns-xs" [class.!hidden]="!isRelatedEntity">
<mat-slide-toggle class="mat-slide" formControlName="scheduledUpdateEnabled">
<div tb-hint-tooltip-icon="{{'calculated-fields.hint.zone-group-refresh-interval' | translate}}">
{{ 'calculated-fields.zone-group-refresh-interval' | translate }}
</div>
</mat-slide-toggle>
<div class="flex flex-row items-center justify-start gap-2">
<tb-time-unit-input required
inlineField
requiredText="{{ 'calculated-fields.hint.zone-group-refresh-interval-required' | translate }}"
minErrorText="{{ 'calculated-fields.hint.zone-group-refresh-interval-min' | translate: {min: minAllowedScheduledUpdateIntervalInSecForCF} }}"
[minTime]="minAllowedScheduledUpdateIntervalInSecForCF"
formControlName="scheduledUpdateInterval">
</tb-time-unit-input>
</div>
</div>
</div>
} }
<div class="tb-form-panel" [formGroup]="outputFormGroup"> @case (CalculatedFieldType.PROPAGATION) {
<div class="tb-form-panel-title">{{ 'calculated-fields.output' | translate }}</div> <tb-propagation-configuration formControlName="configuration"
<div class="flex items-center gap-3"> [entityId]="data.entityId"
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic"> [entityName]="data.entityName"
<mat-label>{{ 'calculated-fields.output-type' | translate }}</mat-label> [tenantId]="data.tenantId"
<mat-select formControlName="type"> [testScript]="onTestScript.bind(this)">
@for (type of outputTypes; track type) { </tb-propagation-configuration>
<mat-option [value]="type">{{ OutputTypeTranslations.get(type) | translate}}</mat-option> }
} @default {
</mat-select> <tb-simple-configuration formControlName="configuration"
</mat-form-field> [entityId]="data.entityId"
@if (outputFormGroup.get('type').value === OutputType.Attribute [entityName]="data.entityName"
&& (data.entityId.entityType === EntityType.DEVICE || data.entityId.entityType === EntityType.DEVICE_PROFILE)) { [tenantId]="data.tenantId"
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic"> [isScript]="fieldFormGroup.get('type').value === CalculatedFieldType.SCRIPT"
<mat-label>{{ 'calculated-fields.attribute-scope' | translate }}</mat-label> [testScript]="onTestScript.bind(this)"
<mat-select formControlName="scope" class="w-full"> >
<mat-option [value]="AttributeScope.SERVER_SCOPE"> </tb-simple-configuration>
{{ 'calculated-fields.server-attributes' | translate }} }
</mat-option> }
<mat-option [value]="AttributeScope.SHARED_SCOPE">
{{ 'calculated-fields.shared-attributes' | translate }}
</mat-option>
</mat-select>
</mat-form-field>
}
</div>
@if (fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE) {
<div class="flex items-start gap-3">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>
{{ (outputFormGroup.get('type').value === OutputType.Timeseries
? 'calculated-fields.timeseries-key'
: 'calculated-fields.attribute-key')
| translate }}
</mat-label>
<input matInput formControlName="name" required>
@if (outputFormGroup.get('name').errors && outputFormGroup.get('name').touched) {
<mat-error>
@if (outputFormGroup.get('name').hasError('required')) {
{{ 'common.hint.key-required' | translate }}
} @else if (outputFormGroup.get('name').hasError('pattern')) {
{{ 'common.hint.key-pattern' | translate }}
} @else if (outputFormGroup.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 (outputFormGroup.get('decimalsByDefault').errors && outputFormGroup.get('decimalsByDefault').touched) {
<mat-error>{{ 'calculated-fields.hint.decimals-range' | translate }}</mat-error>
}
</mat-form-field>
</div>
<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>
}
</div>
</ng-container>
</div> </div>
</div> </div>
<div mat-dialog-actions class="justify-end"> <div mat-dialog-actions class="justify-end">

237
ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts

@ -18,36 +18,25 @@ import { Component, DestroyRef, Inject, ViewEncapsulation } from '@angular/core'
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { DialogComponent } from '@shared/components/dialog.component'; import { DialogComponent } from '@shared/components/dialog.component';
import { import {
ArgumentEntityType,
CalculatedField, CalculatedField,
CalculatedFieldConfiguration, CalculatedFieldConfiguration,
calculatedFieldDefaultScript,
CalculatedFieldGeofencing,
CalculatedFieldTestScriptFn, CalculatedFieldTestScriptFn,
CalculatedFieldType, CalculatedFieldType,
CalculatedFieldTypeTranslations, CalculatedFieldTypeTranslations
getCalculatedFieldArgumentsEditorCompleter,
getCalculatedFieldArgumentsHighlights,
getCalculatedFieldCurrentEntityFilter,
OutputType,
OutputTypeTranslations
} from '@shared/models/calculated-field.models'; } from '@shared/models/calculated-field.models';
import { digitsRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants'; import { oneSpaceInsideRegex } from '@shared/models/regex.constants';
import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { EntityType } from '@shared/models/entity-type.models'; import { EntityType } from '@shared/models/entity-type.models';
import { map, startWith, switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ScriptLanguage } from '@shared/models/rule-node.models';
import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; import { CalculatedFieldsService } from '@core/http/calculated-fields.service';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { EntityId } from '@shared/models/id/entity-id'; import { EntityId } from '@shared/models/id/entity-id';
import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model'; import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model';
import { EntityFilter } from '@shared/models/query/query.models'; import { deepTrim } from '@core/utils';
import { getCurrentAuthState } from '@core/auth/auth.selectors';
export interface CalculatedFieldDialogData { export interface CalculatedFieldDialogData {
value?: CalculatedField; value?: CalculatedField;
@ -68,70 +57,22 @@ export interface CalculatedFieldDialogData {
}) })
export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFieldDialogComponent, CalculatedField> { export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFieldDialogComponent, CalculatedField> {
readonly minAllowedScheduledUpdateIntervalInSecForCF = getCurrentAuthState(this.store).minAllowedScheduledUpdateIntervalInSecForCF;
fieldFormGroup = this.fb.group({ fieldFormGroup = this.fb.group({
name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]],
type: [CalculatedFieldType.SIMPLE], type: [CalculatedFieldType.SIMPLE],
debugSettings: [], debugSettings: [],
configuration: this.fb.group({ configuration: this.fb.control<CalculatedFieldConfiguration>({} as CalculatedFieldConfiguration),
entityCoordinates: this.fb.group({
latitudeKeyName: [null, [Validators.required]],
longitudeKeyName: [null, [Validators.required]],
}),
arguments: this.fb.control({}),
zoneGroups: this.fb.control({}),
scheduledUpdateEnabled: [true],
scheduledUpdateInterval: [this.minAllowedScheduledUpdateIntervalInSecForCF],
expressionSIMPLE: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]],
expressionSCRIPT: [calculatedFieldDefaultScript],
output: this.fb.group({
name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]],
scope: [{ value: AttributeScope.SERVER_SCOPE, disabled: true }],
type: [OutputType.Timeseries],
decimalsByDefault: [null as number, [Validators.min(0), Validators.max(15), Validators.pattern(digitsRegex)]],
}),
useLatestTs: [false]
}),
}); });
functionArgs$ = this.configFormGroup.get('arguments').valueChanges
.pipe(
startWith(this.data.value?.configuration?.arguments ?? {}),
map(argumentsObj => ['ctx', ...Object.keys(argumentsObj)])
);
argumentsEditorCompleter$ = this.configFormGroup.get('arguments').valueChanges
.pipe(
startWith(this.data.value?.configuration?.arguments ?? {}),
map(argumentsObj => getCalculatedFieldArgumentsEditorCompleter(argumentsObj))
);
argumentsHighlightRules$ = this.configFormGroup.get('arguments').valueChanges
.pipe(
startWith(this.data.value?.configuration?.arguments ?? {}),
map(argumentsObj => getCalculatedFieldArgumentsHighlights(argumentsObj))
);
additionalDebugActionConfig = this.data.value?.id ? { additionalDebugActionConfig = this.data.value?.id ? {
...this.data.additionalDebugActionConfig, ...this.data.additionalDebugActionConfig,
action: () => this.data.additionalDebugActionConfig.action({ id: this.data.value.id, ...this.fromGroupValue }), action: () => this.data.additionalDebugActionConfig.action({ id: this.data.value.id, ...this.fromGroupValue }),
} : null; } : null;
currentEntityFilter: EntityFilter;
isRelatedEntity: boolean;
readonly OutputTypeTranslations = OutputTypeTranslations;
readonly OutputType = OutputType;
readonly AttributeScope = AttributeScope;
readonly EntityType = EntityType; readonly EntityType = EntityType;
readonly CalculatedFieldType = CalculatedFieldType; readonly CalculatedFieldType = CalculatedFieldType;
readonly ScriptLanguage = ScriptLanguage;
readonly fieldTypes = Object.values(CalculatedFieldType) as CalculatedFieldType[]; readonly fieldTypes = Object.values(CalculatedFieldType) as CalculatedFieldType[];
readonly outputTypes = Object.values(OutputType) as OutputType[];
readonly CalculatedFieldTypeTranslations = CalculatedFieldTypeTranslations; readonly CalculatedFieldTypeTranslations = CalculatedFieldTypeTranslations;
readonly DataKeyType = DataKeyType;
constructor(protected store: Store<AppState>, constructor(protected store: Store<AppState>,
protected router: Router, protected router: Router,
@ -142,49 +83,12 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
private fb: FormBuilder) { private fb: FormBuilder) {
super(store, router, dialogRef); super(store, router, dialogRef);
this.observeIsLoading(); this.observeIsLoading();
this.observeType();
this.applyDialogData(); this.applyDialogData();
this.observeTypeChanges();
this.observeZoneChanges();
this.observeScheduledUpdateEnabled();
this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.data.entityName, this.data.entityId);
}
get configFormGroup(): FormGroup {
return this.fieldFormGroup.get('configuration') as FormGroup;
}
get outputFormGroup(): FormGroup {
return this.fieldFormGroup.get('configuration').get('output') as FormGroup;
}
get coordinatesFormGroup(): FormGroup {
return this.fieldFormGroup.get('configuration').get('entityCoordinates') as FormGroup;
} }
get fromGroupValue(): CalculatedField { get fromGroupValue(): CalculatedField {
const { configuration, type, name, ...rest } = this.fieldFormGroup.value; return deepTrim(this.fieldFormGroup.value as CalculatedField);
const { expressionSIMPLE, expressionSCRIPT, output, ...restConfig } = configuration;
let cf: CalculatedField = {
name: name.trim(),
type,
...rest
} as CalculatedField;
if (type !== CalculatedFieldType.GEOFENCING) {
cf.configuration = {
...restConfig,
type,
expression: configuration['expression'+type].trim(),
output: { ...output, name: output.name?.trim() ?? '' }
} as CalculatedFieldConfiguration;
} else {
cf.configuration = {
...restConfig,
type,
output: { ...output, name: output.name?.trim() ?? '' }
} as CalculatedFieldConfiguration;
delete cf.configuration.arguments;
}
return cf;
} }
cancel(): void { cancel(): void {
@ -199,12 +103,10 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
} }
} }
onTestScript(): void { onTestScript(): Observable<string> {
const calculatedFieldId = this.data.value?.id?.id; const calculatedFieldId = this.data.value?.id?.id;
let testScriptDialogResult$: Observable<string>;
if (calculatedFieldId) { if (calculatedFieldId) {
testScriptDialogResult$ = this.calculatedFieldsService.getLatestCalculatedFieldDebugEvent(calculatedFieldId) return this.calculatedFieldsService.getLatestCalculatedFieldDebugEvent(calculatedFieldId)
.pipe( .pipe(
switchMap(event => { switchMap(event => {
const args = event?.arguments ? JSON.parse(event.arguments) : null; const args = event?.arguments ? JSON.parse(event.arguments) : null;
@ -212,114 +114,13 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
}), }),
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
) )
} else {
testScriptDialogResult$ = this.data.getTestScriptDialogFn(this.fromGroupValue, null, false);
} }
return this.data.getTestScriptDialogFn(this.fromGroupValue, null, false);
testScriptDialogResult$.subscribe(expression => {
this.configFormGroup.get('expressionSCRIPT').setValue(expression);
this.configFormGroup.get('expressionSCRIPT').markAsDirty();
});
} }
private applyDialogData(): void { private applyDialogData(): void {
const { configuration = {}, type = CalculatedFieldType.SIMPLE, debugSettings = { failuresEnabled: true, allEnabled: true }, ...value } = this.data.value ?? {}; const { configuration = {} as CalculatedFieldConfiguration, type = CalculatedFieldType.SIMPLE, debugSettings = { failuresEnabled: true, allEnabled: true }, ...value } = this.data.value ?? {};
const { expression, ...restConfig } = configuration as CalculatedFieldConfiguration; this.fieldFormGroup.patchValue({ configuration, type, debugSettings, ...value }, {emitEvent: false});
const updatedConfig = { ...restConfig , ['expression'+type]: expression };
this.fieldFormGroup.patchValue({ configuration: updatedConfig, type, debugSettings, ...value }, {emitEvent: false});
}
private observeTypeChanges(): void {
this.toggleKeyByCalculatedFieldType(this.fieldFormGroup.get('type').value);
this.toggleScopeByOutputType(this.outputFormGroup.get('type').value);
this.outputFormGroup.get('type').valueChanges
.pipe(takeUntilDestroyed())
.subscribe(type => this.toggleScopeByOutputType(type));
this.fieldFormGroup.get('type').valueChanges
.pipe(takeUntilDestroyed())
.subscribe(type => this.toggleKeyByCalculatedFieldType(type));
}
private observeZoneChanges(): void {
this.configFormGroup.get('zoneGroups').valueChanges
.pipe(takeUntilDestroyed())
.subscribe((zoneGroups: CalculatedFieldGeofencing) =>
this.checkRelatedEntity(zoneGroups)
);
this.checkRelatedEntity(this.configFormGroup.get('zoneGroups').value);
}
private observeScheduledUpdateEnabled(): void {
this.configFormGroup.get('scheduledUpdateEnabled').valueChanges
.pipe(takeUntilDestroyed())
.subscribe((value: boolean) =>
this.checkScheduledUpdateEnabled(value)
);
this.checkScheduledUpdateEnabled(this.configFormGroup.get('scheduledUpdateEnabled').value);
}
private checkScheduledUpdateEnabled(value: boolean) {
if (value) {
this.configFormGroup.get('scheduledUpdateInterval').enable({emitEvent: false});
} else {
this.configFormGroup.get('scheduledUpdateInterval').disable({emitEvent: false});
}
}
private checkRelatedEntity(zoneGroups: CalculatedFieldGeofencing) {
this.isRelatedEntity = Object.values(zoneGroups).some(zone => zone.refDynamicSourceConfiguration?.type === ArgumentEntityType.RelationQuery);
}
private toggleScopeByOutputType(type: OutputType): void {
if (type === OutputType.Attribute) {
this.outputFormGroup.get('scope').enable({emitEvent: false});
} else {
this.outputFormGroup.get('scope').disable({emitEvent: false});
}
if (this.fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE) {
if (type === OutputType.Attribute) {
this.configFormGroup.get('useLatestTs').disable({emitEvent: false});
} else {
this.configFormGroup.get('useLatestTs').enable({emitEvent: false});
}
} else {
this.configFormGroup.get('useLatestTs').disable({emitEvent: false});
}
}
private toggleKeyByCalculatedFieldType(type: CalculatedFieldType): void {
if (type === CalculatedFieldType.GEOFENCING) {
this.configFormGroup.get('entityCoordinates').enable({emitEvent: false});
this.configFormGroup.get('zoneGroups').enable({emitEvent: false});
this.configFormGroup.get('scheduledUpdateInterval').enable({emitEvent: false});
this.outputFormGroup.get('name').disable({emitEvent: false});
this.configFormGroup.get('useLatestTs').disable({emitEvent: false});
this.configFormGroup.get('expressionSIMPLE').disable({emitEvent: false});
this.configFormGroup.get('expressionSCRIPT').disable({emitEvent: false});
this.configFormGroup.get('arguments').disable({emitEvent: false});
} else {
this.configFormGroup.get('entityCoordinates').disable({emitEvent: false});
this.configFormGroup.get('zoneGroups').disable({emitEvent: false});
this.configFormGroup.get('scheduledUpdateInterval').disable({emitEvent: false});
if (type === CalculatedFieldType.SIMPLE) {
this.outputFormGroup.get('name').enable({emitEvent: false});
this.configFormGroup.get('expressionSIMPLE').enable({emitEvent: false});
this.configFormGroup.get('expressionSCRIPT').disable({emitEvent: false});
if (this.outputFormGroup.get('type').value === OutputType.Attribute) {
this.configFormGroup.get('useLatestTs').disable({emitEvent: false});
} else {
this.configFormGroup.get('useLatestTs').enable({emitEvent: false});
}
} else {
this.outputFormGroup.get('name').disable({emitEvent: false});
this.configFormGroup.get('useLatestTs').disable({emitEvent: false});
this.configFormGroup.get('expressionSIMPLE').disable({emitEvent: false});
this.configFormGroup.get('expressionSCRIPT').enable({emitEvent: false});
}
}
} }
private observeIsLoading(): void { private observeIsLoading(): void {
@ -328,12 +129,20 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
this.fieldFormGroup.disable({emitEvent: false}); this.fieldFormGroup.disable({emitEvent: false});
} else { } else {
this.fieldFormGroup.enable({emitEvent: false}); this.fieldFormGroup.enable({emitEvent: false});
this.toggleScopeByOutputType(this.outputFormGroup.get('type').value);
this.toggleKeyByCalculatedFieldType(this.fieldFormGroup.get('type').value);
if (this.data.isDirty) { if (this.data.isDirty) {
this.fieldFormGroup.markAsDirty(); this.fieldFormGroup.markAsDirty();
} }
} }
}); });
} }
private observeType(): void {
this.fieldFormGroup.get('type').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe((type) => {
if (type !== CalculatedFieldType.SIMPLE && type !== CalculatedFieldType.SCRIPT) {
this.fieldFormGroup.get('configuration').setValue(({} as CalculatedFieldConfiguration), {emitEvent: false});
}
});
}
} }

0
ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.html → ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html

0
ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.scss → ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.scss

0
ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.ts → ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts

0
ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.html → ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.html

0
ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.scss → ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.scss

8
ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.ts → ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.ts

@ -58,7 +58,7 @@ import { NULL_UUID } from '@shared/models/id/has-uuid';
import { BaseData } from '@shared/models/base-data'; import { BaseData } from '@shared/models/base-data';
import { import {
CalculatedFieldGeofencingZoneGroupsPanelComponent CalculatedFieldGeofencingZoneGroupsPanelComponent
} from '@home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component'; } from '@home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component';
@Component({ @Component({
selector: 'tb-calculated-field-geofencing-zone-groups-table', selector: 'tb-calculated-field-geofencing-zone-groups-table',
@ -79,9 +79,9 @@ import {
}) })
export class CalculatedFieldGeofencingZoneGroupsTableComponent implements ControlValueAccessor, Validator, AfterViewInit { export class CalculatedFieldGeofencingZoneGroupsTableComponent implements ControlValueAccessor, Validator, AfterViewInit {
@Input() entityId: EntityId; @Input({required: true}) entityId: EntityId;
@Input() tenantId: string; @Input({required: true}) tenantId: string;
@Input() entityName: string; @Input({required: true}) entityName: string;
@ViewChild(MatSort, { static: true }) sort: MatSort; @ViewChild(MatSort, { static: true }) sort: MatSort;

68
ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.html

@ -0,0 +1,68 @@
<!--
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]="geofencingConfiguration" class="tb-form-panel no-border no-padding">
<div class="tb-form-panel">
<div class="tb-form-panel-title tb-required" tb-hint-tooltip-icon="{{ 'calculated-fields.hint.entity-coordinates' | translate }}">
{{ 'calculated-fields.entity-coordinates' | translate }}
</div>
<div class="flex items-start gap-3" formGroupName="entityCoordinates">
<tb-entity-key-autocomplete class="flex-1"
placeholder="{{ 'calculated-fields.latitude-time-series-key' | translate }}"
requiredText="{{ 'calculated-fields.latitude-time-series-key-required' | translate }}"
formControlName="latitudeKeyName"
[dataKeyType]="DataKeyType.timeseries"
[entityFilter]="currentEntityFilter"/>
<tb-entity-key-autocomplete class="flex-1"
placeholder="{{ 'calculated-fields.longitude-time-series-key' | translate }}"
requiredText="{{ 'calculated-fields.longitude-time-series-key-required' | translate }}"
formControlName="longitudeKeyName"
[dataKeyType]="DataKeyType.timeseries"
[entityFilter]="currentEntityFilter"/>
</div>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title tb-required" tb-hint-tooltip-icon="{{ 'calculated-fields.hint.geofencing-zone-groups' | translate }}">
{{ 'calculated-fields.geofencing-zone-groups' | translate }}
</div>
<tb-calculated-field-geofencing-zone-groups-table formControlName="zoneGroups"
[entityId]="entityId"
[tenantId]="tenantId"
[entityName]="entityName"/>
<div class="tb-form-row space-between flex-1 columns-xs" [class.!hidden]="!isRelatedEntity">
<mat-slide-toggle class="mat-slide" formControlName="scheduledUpdateEnabled">
<div tb-hint-tooltip-icon="{{'calculated-fields.hint.zone-group-refresh-interval' | translate}}">
{{ 'calculated-fields.zone-group-refresh-interval' | translate }}
</div>
</mat-slide-toggle>
<div class="flex flex-row items-center justify-start gap-2">
<tb-time-unit-input required
inlineField
requiredText="{{ 'calculated-fields.hint.zone-group-refresh-interval-required' | translate }}"
minErrorText="{{ 'calculated-fields.hint.zone-group-refresh-interval-min' | translate: {min: minAllowedScheduledUpdateIntervalInSecForCF} }}"
[minTime]="minAllowedScheduledUpdateIntervalInSecForCF"
formControlName="scheduledUpdateInterval">
</tb-time-unit-input>
</div>
</div>
</div>
<tb-calculate-field-output
formControlName="output"
[entityId]="entityId">
</tb-calculate-field-output>
</div>

157
ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts

@ -0,0 +1,157 @@
///
/// 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, OnInit } from '@angular/core';
import {
ControlValueAccessor,
FormBuilder,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
Validators
} from '@angular/forms';
import {
ArgumentEntityType,
CalculatedFieldGeofencing,
CalculatedFieldGeofencingConfiguration,
CalculatedFieldOutput,
CalculatedFieldType,
getCalculatedFieldCurrentEntityFilter,
OutputType
} from '@shared/models/calculated-field.models';
import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { getCurrentAuthState } from '@core/auth/auth.selectors';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EntityFilter } from '@shared/models/query/query.models';
import { EntityId } from '@shared/models/id/entity-id';
@Component({
selector: 'tb-geofencing-configuration',
templateUrl: './geofencing-configuration.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => GeofencingConfigurationComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => GeofencingConfigurationComponent),
multi: true
}
],
})
export class GeofencingConfigurationComponent implements ControlValueAccessor, Validator, OnInit {
@Input({required: true})
entityId: EntityId;
@Input({required: true})
tenantId: string;
@Input({required: true})
entityName: string;
readonly minAllowedScheduledUpdateIntervalInSecForCF = getCurrentAuthState(this.store).minAllowedScheduledUpdateIntervalInSecForCF;
readonly DataKeyType = DataKeyType;
geofencingConfiguration = this.fb.group({
entityCoordinates: this.fb.group({
latitudeKeyName: [null, [Validators.required]],
longitudeKeyName: [null, [Validators.required]],
}),
zoneGroups: this.fb.control<Record<string, CalculatedFieldGeofencing>>({}),
scheduledUpdateEnabled: [true],
scheduledUpdateInterval: [this.minAllowedScheduledUpdateIntervalInSecForCF],
output: this.fb.control<CalculatedFieldOutput>({scope: AttributeScope.SERVER_SCOPE, type: OutputType.Timeseries})
});
currentEntityFilter: EntityFilter;
isRelatedEntity: boolean;
private propagateChange: (config: CalculatedFieldGeofencingConfiguration) => void = () => { };
constructor(private fb: FormBuilder,
private store: Store<AppState>) {
this.geofencingConfiguration.get('zoneGroups').valueChanges
.pipe(takeUntilDestroyed())
.subscribe((zoneGroups: Record<string, CalculatedFieldGeofencing>) =>
this.checkRelatedEntity(zoneGroups)
);
this.geofencingConfiguration.get('scheduledUpdateEnabled').valueChanges
.pipe(takeUntilDestroyed())
.subscribe((value: boolean) =>
this.checkScheduledUpdateEnabled(value)
);
this.geofencingConfiguration.valueChanges.pipe(
takeUntilDestroyed()
).subscribe(() => {
this.updatedModel(this.geofencingConfiguration.getRawValue() as any);
})
}
ngOnInit() {
this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.entityName, this.entityId);
}
validate(): ValidationErrors | null {
return this.geofencingConfiguration.valid || this.geofencingConfiguration.status === "DISABLED" ? null : { geofencingConfigError: false };
}
writeValue(config: CalculatedFieldGeofencingConfiguration): void {
this.geofencingConfiguration.patchValue(config, {emitEvent: false});
this.checkRelatedEntity(this.geofencingConfiguration.get('zoneGroups').value);
this.checkScheduledUpdateEnabled(this.geofencingConfiguration.get('scheduledUpdateEnabled').value);
}
registerOnChange(fn: (config: CalculatedFieldGeofencingConfiguration) => void): void {
this.propagateChange = fn;
}
registerOnTouched(_: any): void { }
setDisabledState(isDisabled: boolean): void {
if (isDisabled) {
this.geofencingConfiguration.disable({emitEvent: false});
} else {
this.geofencingConfiguration.enable({emitEvent: false});
this.checkScheduledUpdateEnabled(this.geofencingConfiguration.get('scheduledUpdateEnabled').value);
}
}
private updatedModel(value: CalculatedFieldGeofencingConfiguration) {
value.type = CalculatedFieldType.GEOFENCING;
this.propagateChange(value)
}
private checkScheduledUpdateEnabled(value: boolean) {
if (value) {
this.geofencingConfiguration.get('scheduledUpdateInterval').enable({emitEvent: false});
} else {
this.geofencingConfiguration.get('scheduledUpdateInterval').disable({emitEvent: false});
}
}
private checkRelatedEntity(zoneGroups: Record<string, CalculatedFieldGeofencing>) {
this.isRelatedEntity = Object.values(zoneGroups).some(zone => zone.refDynamicSourceConfiguration?.type === ArgumentEntityType.RelationQuery);
}
}

52
ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.module.ts

@ -0,0 +1,52 @@
///
/// 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 {
CalculatedFieldGeofencingZoneGroupsTableComponent
} from '@home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component';
import {
CalculatedFieldGeofencingZoneGroupsPanelComponent
} from '@home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component';
import { SharedModule } from '@shared/shared.module';
import {
GeofencingConfigurationComponent
} from '@home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component';
import {
CalculatedFieldOutputModule
} from '@home/components/calculated-fields/components/output/calculated-field-output.module';
@NgModule({
imports: [
CommonModule,
SharedModule,
CalculatedFieldOutputModule
],
declarations: [
CalculatedFieldGeofencingZoneGroupsTableComponent,
CalculatedFieldGeofencingZoneGroupsPanelComponent,
GeofencingConfigurationComponent
],
exports: [
CalculatedFieldGeofencingZoneGroupsTableComponent,
CalculatedFieldGeofencingZoneGroupsPanelComponent,
GeofencingConfigurationComponent
]
})
export class GeofencingConfigurationModule {
}

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

@ -0,0 +1,86 @@
<!--
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="tb-form-panel" [formGroup]="outputForm">
<div class="tb-form-panel-title">{{ 'calculated-fields.output' | translate }}</div>
<div class="flex items-center gap-3">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'calculated-fields.output-type' | translate }}</mat-label>
<mat-select formControlName="type">
@for (type of outputTypes; track type) {
<mat-option [value]="type">{{ OutputTypeTranslations.get(type) | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (outputForm.get('type').value === OutputType.Attribute
&& (entityId.entityType === EntityType.DEVICE || entityId.entityType === EntityType.DEVICE_PROFILE)) {
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'calculated-fields.attribute-scope' | translate }}</mat-label>
<mat-select formControlName="scope" class="w-full">
<mat-option [value]="AttributeScope.SERVER_SCOPE">
{{ 'calculated-fields.server-attributes' | translate }}
</mat-option>
<mat-option [value]="AttributeScope.SHARED_SCOPE">
{{ 'calculated-fields.shared-attributes' | translate }}
</mat-option>
</mat-select>
</mat-form-field>
}
</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>-->
}
</div>

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

@ -0,0 +1,148 @@
///
/// 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, DestroyRef, forwardRef, inject, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
import {
ControlValueAccessor,
FormBuilder,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
Validators
} from '@angular/forms';
import { AttributeScope } from '@shared/models/telemetry/telemetry.models';
import {
CalculatedFieldOutput,
CalculatedFieldSimpleOutput,
OutputType,
OutputTypeTranslations
} from '@shared/models/calculated-field.models';
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';
@Component({
selector: 'tb-calculate-field-output',
templateUrl: './calculated-field-output.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CalculatedFieldOutputComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => CalculatedFieldOutputComponent),
multi: true
}
],
})
export class CalculatedFieldOutputComponent implements ControlValueAccessor, Validator, OnInit, OnChanges {
@Input()
simpleMode = false;
@Input({required: true})
entityId: EntityId;
readonly outputTypes = Object.values(OutputType) as OutputType[];
readonly OutputType = OutputType;
readonly AttributeScope = AttributeScope;
readonly OutputTypeTranslations = OutputTypeTranslations;
readonly EntityType = EntityType;
private fb = inject(FormBuilder);
private destroyRef = inject(DestroyRef);
outputForm = this.fb.group({
name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]],
scope: [{value: AttributeScope.SERVER_SCOPE, disabled: true}],
type: [OutputType.Timeseries],
decimalsByDefault: [null as number, [Validators.min(0), Validators.max(15), Validators.pattern(digitsRegex)]],
});
private propagateChange: (config: CalculatedFieldOutput | CalculatedFieldSimpleOutput) => void = () => { };
ngOnInit() {
this.outputForm.get('type').valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(type => this.toggleScopeByOutputType(type));
this.updatedFormWithMode();
this.outputForm.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe((value: CalculatedFieldOutput | CalculatedFieldSimpleOutput) => {
this.updatedModel(value)
})
}
ngOnChanges(changes: SimpleChanges): void {
for (const propName of Object.keys(changes)) {
const change = changes[propName];
if (change.currentValue !== change.previousValue) {
if (propName === 'simpleMode') {
this.updatedFormWithMode();
if (!change.firstChange) {
this.outputForm.updateValueAndValidity();
}
}
}
}
}
validate(): ValidationErrors | null {
return this.outputForm.valid ? null : {outputConfig: false};
}
writeValue(value: CalculatedFieldOutput | CalculatedFieldSimpleOutput): void {
this.outputForm.patchValue(value, {emitEvent: false});
this.outputForm.get('type').updateValueAndValidity({onlySelf: true});
}
registerOnChange(fn: (config: CalculatedFieldOutput | CalculatedFieldSimpleOutput) => void): void {
this.propagateChange = fn;
}
registerOnTouched(_: any): void { }
private updatedModel(value: CalculatedFieldOutput | CalculatedFieldSimpleOutput) {
if (this.simpleMode && 'name' in value) {
value.name = value.name?.trim() ?? '';
}
this.propagateChange(value);
}
private toggleScopeByOutputType(type: OutputType): void {
if (type === OutputType.Attribute) {
this.outputForm.get('scope').enable({emitEvent: false});
} else {
this.outputForm.get('scope').disable({emitEvent: false});
}
}
private updatedFormWithMode(): void {
if (this.simpleMode) {
this.outputForm.get('name').enable({emitEvent: false});
this.outputForm.get('decimalsByDefault').enable({emitEvent: false});
} else {
this.outputForm.get('name').disable({emitEvent: false});
this.outputForm.get('decimalsByDefault').disable({emitEvent: false});
}
}
}

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

@ -0,0 +1,36 @@
///
/// 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 {
CalculatedFieldOutputComponent
} from '@home/components/calculated-fields/components/output/calculated-field-output.component';
@NgModule({
imports: [
CommonModule,
SharedModule,
],
declarations: [
CalculatedFieldOutputComponent
],
exports: [
CalculatedFieldOutputComponent
]
})
export class CalculatedFieldOutputModule { }

99
ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.html

@ -0,0 +1,99 @@
<!--
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]="propagateConfiguration" 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.propagation-path-related-entities' | translate }}">
{{ 'calculated-fields.propagation-path-related-entities' | translate }}
</div>
<div class="flex gap-3 xs:flex-col">
<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="flex flex-row items-center justify-between xs:flex-col xs:items-start xs:gap-3">
<div class="tb-form-panel-title" tb-hint-tooltip-icon="{{ 'calculated-fields.hint.data-propagate' | translate }}">
{{ 'calculated-fields.data-propagate' | translate }}
</div>
<tb-toggle-select formControlName="applyExpressionToResolvedArguments">
<tb-toggle-option [value]="false">{{ 'calculated-fields.propagate-type.arguments-only' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="true">{{ 'calculated-fields.propagate-type.expression-result' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<tb-propagate-arguments-table formControlName="arguments"
[entityId]="entityId"
[tenantId]="tenantId"
[entityName]="entityName"
[isScript]="this.propagateConfiguration.get('applyExpressionToResolvedArguments').value"/>
</div>
<div class="tb-form-panel no-gap" [class.!hidden]="!this.propagateConfiguration.get('applyExpressionToResolvedArguments').value">
<div class="tb-form-panel-title tb-required">
{{ 'calculated-fields.expression' | translate }}
</div>
<div>
<tb-js-func required
formControlName="expression"
functionName="calculate"
[functionArgs]="functionArgs$ | async"
[disableUndefinedCheck]="true"
[scriptLanguage]="ScriptLanguage.TBEL"
[highlightRules]="argumentsHighlightRules$ | async"
[editorCompleter]="argumentsEditorCompleter$ | async"
[helpPopupStyle]="{ width: '1200px' }"
helpId="calculated-field/expression_fn">
<div toolbarPrefixButton
class="tb-primary-background tbel-script-lang-chip">{{ 'api-usage.tbel' | translate }}
</div>
<button toolbarSuffixButton
mat-icon-button
matTooltip="{{ 'calculated-fields.test-expression-function' | translate }}"
matTooltipPosition="above"
class="tb-mat-32"
[disabled]="propagateConfiguration.get('arguments').invalid"
(click)="onTestScript()">
<mat-icon class="material-icons" color="primary">bug_report</mat-icon>
</button>
</tb-js-func>
<div>
<button mat-button mat-raised-button color="primary"
type="button"
(click)="onTestScript()"
[disabled]="propagateConfiguration.get('arguments').invalid">
{{ 'calculated-fields.test-expression-function' | translate }}
</button>
</div>
</div>
</div>
<tb-calculate-field-output formControlName="output" [entityId]="entityId">
</tb-calculate-field-output>
</div>

174
ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts

@ -0,0 +1,174 @@
///
/// 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 {
calculatedFieldDefaultScript,
CalculatedFieldOutput,
CalculatedFieldPropagationConfiguration,
CalculatedFieldType,
getCalculatedFieldArgumentsEditorCompleter,
getCalculatedFieldArgumentsHighlights,
OutputType,
PropagationDirectionTranslations,
PropagationWithExpression
} 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';
@Component({
selector: 'tb-propagation-configuration',
templateUrl: './propagation-configuration.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => PropagationConfigurationComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => PropagationConfigurationComponent),
multi: true
}
],
})
export class PropagationConfigurationComponent implements ControlValueAccessor, Validator {
@Input({required: true})
entityId: EntityId;
@Input({required: true})
tenantId: string;
@Input({required: true})
entityName: string;
@Input({required: true})
testScript: () => Observable<string>;
propagateConfiguration = this.fb.group({
arguments: this.fb.control({}),
applyExpressionToResolvedArguments: [false],
direction: [EntitySearchDirection.TO, Validators.required],
relationType: ['Contains', Validators.required],
expression: [calculatedFieldDefaultScript],
output: this.fb.control<CalculatedFieldOutput>({
scope: AttributeScope.SERVER_SCOPE,
type: OutputType.Timeseries,
}),
});
readonly ScriptLanguage = ScriptLanguage;
readonly CalculatedFieldType = CalculatedFieldType;
readonly OutputType = OutputType;
readonly Directions = Object.values(EntitySearchDirection) as Array<EntitySearchDirection>;
readonly PropagationDirectionTranslations = PropagationDirectionTranslations;
functionArgs$ = this.propagateConfiguration.get('arguments').valueChanges.pipe(
map(argumentsObj => ['ctx', ...Object.keys(argumentsObj)])
);
argumentsEditorCompleter$ = this.propagateConfiguration.get('arguments').valueChanges.pipe(
map(argumentsObj => getCalculatedFieldArgumentsEditorCompleter(argumentsObj ?? {}))
);
argumentsHighlightRules$ = this.propagateConfiguration.get('arguments').valueChanges.pipe(
map(argumentsObj => getCalculatedFieldArgumentsHighlights(argumentsObj))
);
private propagateChange: (config: CalculatedFieldPropagationConfiguration) => void = () => { };
constructor(private fb: FormBuilder) {
this.propagateConfiguration.get('applyExpressionToResolvedArguments').valueChanges.pipe(
takeUntilDestroyed()
).subscribe(() => {
this.updatedFormWithScript();
})
this.propagateConfiguration.valueChanges.pipe(
takeUntilDestroyed()
).subscribe((value: CalculatedFieldPropagationConfiguration) => {
this.updatedModel(value);
})
}
validate(): ValidationErrors | null {
return this.propagateConfiguration.valid || this.propagateConfiguration.status === "DISABLED" ? null : {invalidPropagateConfig: false};
}
writeValue(value: PropagationWithExpression): void {
value.expression = value.expression ?? calculatedFieldDefaultScript;
this.propagateConfiguration.patchValue(value, {emitEvent: false});
this.updatedFormWithScript();
setTimeout(() => {
this.propagateConfiguration.get('arguments').updateValueAndValidity({onlySelf: true});
});
}
registerOnChange(fn: (config: CalculatedFieldPropagationConfiguration) => void): void {
this.propagateChange = fn;
}
registerOnTouched(_: any): void { }
setDisabledState(isDisabled: boolean): void {
if (isDisabled) {
this.propagateConfiguration.disable({emitEvent: false});
} else {
this.propagateConfiguration.enable({emitEvent: false});
this.updatedFormWithScript();
}
}
onTestScript() {
this.testScript().subscribe((expression) => {
this.propagateConfiguration.get('expression').setValue(expression);
this.propagateConfiguration.get('expression').markAsDirty();
})
}
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: CalculatedFieldPropagationConfiguration): void {
value.type = CalculatedFieldType.PROPAGATION;
this.propagateChange(value);
}
private updatedFormWithScript() {
if (this.propagateConfiguration.get('applyExpressionToResolvedArguments').value) {
this.propagateConfiguration.get('expression').enable({emitEvent: false});
} else {
this.propagateConfiguration.get('expression').disable({emitEvent: false});
}
}
}

44
ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.module.ts

@ -0,0 +1,44 @@
///
/// 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 {
PropagationConfigurationComponent
} from '@home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component';
@NgModule({
imports: [
CommonModule,
SharedModule,
CalculatedFieldOutputModule,
CalculatedFieldArgumentsTableModule,
],
declarations: [
PropagationConfigurationComponent,
],
exports: [
PropagationConfigurationComponent,
]
})
export class PropagationConfigurationModule { }

2
ui-ngx/src/app/modules/home/components/calculated-fields/components/public-api.ts

@ -15,7 +15,5 @@
/// ///
export * from './dialog/calculated-field-dialog.component'; export * from './dialog/calculated-field-dialog.component';
export * from './arguments-table/calculated-field-arguments-table.component';
export * from './panel/calculated-field-argument-panel.component';
export * from './debug-dialog/calculated-field-debug-dialog.component'; export * from './debug-dialog/calculated-field-debug-dialog.component';
export * from './test-dialog/calculated-field-script-test-dialog.component'; export * from './test-dialog/calculated-field-script-test-dialog.component';

98
ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.html

@ -0,0 +1,98 @@
<!--
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]="simpleConfiguration" class="tb-form-panel no-border no-padding">
<div class="tb-form-panel">
<div class="tb-form-panel-title tb-required">{{ 'calculated-fields.arguments' | translate }}</div>
<tb-calculated-field-arguments-table formControlName="arguments"
[entityId]="entityId"
[tenantId]="tenantId"
[entityName]="entityName"
[isScript]="isScript" />
</div>
<div class="tb-form-panel no-gap">
<div class="tb-form-panel-title tb-required">
{{ (isScript ? 'calculated-fields.type.script' : 'calculated-fields.expression') | translate }}
</div>
<mat-form-field class="mt-3" appearance="outline" subscriptSizing="dynamic" [class.hidden]="isScript">
<input matInput formControlName="expressionSIMPLE" maxlength="255" [placeholder]="'(temperature - 32) / 1.8'"
required>
<div matSuffix
class="pr-2"
[tb-help-popup]="'math/math-methods_fn'"
tb-help-popup-placement="left"
[tb-help-popup-style]="{maxWidth: '970px'}">
</div>
@if (simpleConfiguration.get('expressionSIMPLE').errors && simpleConfiguration.get('expressionSIMPLE').touched) {
<mat-error>
@if (simpleConfiguration.get('expressionSIMPLE').hasError('required')) {
{{ 'calculated-fields.hint.expression-required' | translate }}
} @else if (simpleConfiguration.get('expressionSIMPLE').hasError('pattern')) {
{{ 'calculated-fields.hint.expression-invalid' | translate }}
} @else if (simpleConfiguration.get('expressionSIMPLE').hasError('maxLength')) {
{{ 'calculated-fields.hint.expression-max-length' | translate }}
}
</mat-error>
} @else {
<mat-hint>{{ 'calculated-fields.hint.expression' | translate }}</mat-hint>
}
</mat-form-field>
<div [class.hidden]="!isScript">
<tb-js-func required
formControlName="expressionSCRIPT"
functionName="calculate"
[functionArgs]="functionArgs$ | async"
[disableUndefinedCheck]="true"
[scriptLanguage]="ScriptLanguage.TBEL"
[highlightRules]="argumentsHighlightRules$ | async"
[editorCompleter]="argumentsEditorCompleter$ | async"
[helpPopupStyle]="{ width: '1200px' }"
helpId="calculated-field/expression_fn">
<div toolbarPrefixButton
class="tb-primary-background tbel-script-lang-chip">{{ 'api-usage.tbel' | translate }}
</div>
<button toolbarSuffixButton
mat-icon-button
matTooltip="{{ 'calculated-fields.test-script-function' | translate }}"
matTooltipPosition="above"
class="tb-mat-32"
[disabled]="simpleConfiguration.get('arguments').invalid"
(click)="onTestScript()">
<mat-icon class="material-icons" color="primary">bug_report</mat-icon>
</button>
</tb-js-func>
<div>
<button mat-button mat-raised-button color="primary"
type="button"
(click)="onTestScript()"
[disabled]="simpleConfiguration.get('arguments').invalid">
{{ 'calculated-fields.test-script-function' | translate }}
</button>
</div>
</div>
</div>
<tb-calculate-field-output formControlName="output" [simpleMode]="!isScript" [entityId]="entityId">
<div class="tb-form-row simpleMode"
[class.!hidden]="simpleConfiguration.get('output').value.type !== 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>
</tb-calculate-field-output>
</div>

206
ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts

@ -0,0 +1,206 @@
///
/// 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, OnChanges, SimpleChanges } from '@angular/core';
import {
ControlValueAccessor,
FormBuilder,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
Validators
} from '@angular/forms';
import { oneSpaceInsideRegex } from '@shared/models/regex.constants';
import {
calculatedFieldDefaultScript,
CalculatedFieldScriptConfiguration,
CalculatedFieldSimpleConfiguration,
CalculatedFieldSimpleOutput,
CalculatedFieldType,
getCalculatedFieldArgumentsEditorCompleter,
getCalculatedFieldArgumentsHighlights,
OutputType
} from '@shared/models/calculated-field.models';
import { AttributeScope } from '@shared/models/telemetry/telemetry.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { deepClone } from '@core/utils';
import { EntityId } from '@shared/models/id/entity-id';
import { Observable } from 'rxjs';
import { ScriptLanguage } from '@shared/models/rule-node.models';
import { map } from 'rxjs/operators';
type SimpeConfiguration = CalculatedFieldSimpleConfiguration | CalculatedFieldScriptConfiguration;
@Component({
selector: 'tb-simple-configuration',
templateUrl: './simple-configuration.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SimpleConfigurationComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => SimpleConfigurationComponent),
multi: true
}
],
})
export class SimpleConfigurationComponent implements ControlValueAccessor, Validator, OnChanges {
@Input()
isScript: boolean;
@Input({required: true})
entityId: EntityId;
@Input({required: true})
tenantId: string;
@Input({required: true})
entityName: string;
@Input({required: true})
testScript: () => Observable<string>;
simpleConfiguration = this.fb.group({
arguments: this.fb.control({}),
expressionSIMPLE: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]],
expressionSCRIPT: [calculatedFieldDefaultScript],
output: this.fb.control<CalculatedFieldSimpleOutput>({
name: '',
scope: AttributeScope.SERVER_SCOPE,
type: OutputType.Timeseries,
decimalsByDefault: null
}),
useLatestTs: [false]
});
readonly ScriptLanguage = ScriptLanguage;
readonly OutputType = OutputType;
functionArgs$ = this.simpleConfiguration.get('arguments').valueChanges.pipe(
map(argumentsObj => ['ctx', ...Object.keys(argumentsObj)])
);
argumentsEditorCompleter$ = this.simpleConfiguration.get('arguments').valueChanges.pipe(
map(argumentsObj => getCalculatedFieldArgumentsEditorCompleter(argumentsObj ?? {}))
);
argumentsHighlightRules$ = this.simpleConfiguration.get('arguments').valueChanges.pipe(
map(argumentsObj => getCalculatedFieldArgumentsHighlights(argumentsObj))
);
private propagateChange: (config: SimpeConfiguration) => void = () => { };
constructor(private fb: FormBuilder) {
this.simpleConfiguration.get('output').valueChanges.pipe(
takeUntilDestroyed(),
).subscribe(() => {
this.toggleScopeByOutputType();
});
this.simpleConfiguration.valueChanges.pipe(
takeUntilDestroyed()
).subscribe((value) => {
const { expressionSIMPLE, expressionSCRIPT, ...config } = value;
const cfConfig = config as SimpeConfiguration;
cfConfig.expression = this.isScript ? expressionSCRIPT : expressionSIMPLE;
this.updatedModel(cfConfig);
})
}
ngOnChanges(changes: SimpleChanges): void {
for (const propName of Object.keys(changes)) {
const change = changes[propName];
if (change.currentValue !== change.previousValue) {
if (propName === 'isScript') {
this.updatedFormWithScript();
if (!change.firstChange) {
this.simpleConfiguration.updateValueAndValidity();
}
}
}
}
}
validate(): ValidationErrors | null {
return this.simpleConfiguration.valid || this.simpleConfiguration.status === "DISABLED" ? null : {invalidSimpleConfig: false};
}
writeValue(value: SimpeConfiguration): void {
const formValue: any = deepClone(value);
if (this.isScript) {
formValue.expressionSCRIPT = formValue.expression ?? calculatedFieldDefaultScript;
} else {
formValue.expressionSIMPLE = formValue.expression;
}
this.simpleConfiguration.patchValue(formValue, {emitEvent: false});
this.updatedFormWithScript();
setTimeout(() => {
this.simpleConfiguration.get('arguments').updateValueAndValidity({onlySelf: true});
});
}
registerOnChange(fn: (config: SimpeConfiguration) => void): void {
this.propagateChange = fn;
}
registerOnTouched(_: any): void {
}
setDisabledState(isDisabled: boolean): void {
if (isDisabled) {
this.simpleConfiguration.disable({emitEvent: false});
} else {
this.simpleConfiguration.enable({emitEvent: false});
this.updatedFormWithScript();
}
}
onTestScript() {
this.testScript().subscribe((expression) => {
this.simpleConfiguration.get('expressionSCRIPT').setValue(expression);
this.simpleConfiguration.get('expressionSCRIPT').markAsDirty();
})
}
private updatedModel(value: SimpeConfiguration): void {
value.type = this.isScript ? CalculatedFieldType.SCRIPT : CalculatedFieldType.SIMPLE;
this.propagateChange(value);
}
private updatedFormWithScript() {
if (this.isScript) {
this.simpleConfiguration.get('expressionSIMPLE').disable({emitEvent: false});
this.simpleConfiguration.get('expressionSCRIPT').enable({emitEvent: false});
} else {
this.simpleConfiguration.get('expressionSIMPLE').enable({emitEvent: false});
this.simpleConfiguration.get('expressionSCRIPT').disable({emitEvent: false});
}
this.toggleScopeByOutputType();
}
private toggleScopeByOutputType(): void {
if (this.isScript || this.simpleConfiguration.get('output').value.type === OutputType.Attribute) {
this.simpleConfiguration.get('useLatestTs').disable({emitEvent: false});
} else {
this.simpleConfiguration.get('useLatestTs').enable({emitEvent: false});
}
}
}

44
ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.module.ts

@ -0,0 +1,44 @@
///
/// 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 {
SimpleConfigurationComponent
} from '@home/components/calculated-fields/components/simple-configuration/simple-configuration.component';
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';
@NgModule({
imports: [
CommonModule,
SharedModule,
CalculatedFieldOutputModule,
CalculatedFieldArgumentsTableModule,
],
declarations: [
SimpleConfigurationComponent,
],
exports: [
SimpleConfigurationComponent
]
})
export class SimpleConfigurationModule {}

41
ui-ngx/src/app/modules/home/components/home-components.module.ts

@ -183,36 +183,13 @@ import {
} from '@home/components/dashboard-page/layout/select-dashboard-breakpoint.component'; } from '@home/components/dashboard-page/layout/select-dashboard-breakpoint.component';
import { EntityChipsComponent } from '@home/components/entity/entity-chips.component'; import { EntityChipsComponent } from '@home/components/entity/entity-chips.component';
import { DashboardViewComponent } from '@home/components/dashboard-view/dashboard-view.component'; import { DashboardViewComponent } from '@home/components/dashboard-view/dashboard-view.component';
import { CalculatedFieldsTableComponent } from '@home/components/calculated-fields/calculated-fields-table.component';
import { CalculatedFieldDialogComponent } from '@home/components/calculated-fields/components/dialog/calculated-field-dialog.component';
import { import {
EntityDebugSettingsButtonComponent EntityDebugSettingsButtonComponent
} from '@home/components/entity/debug/entity-debug-settings-button.component'; } from '@home/components/entity/debug/entity-debug-settings-button.component';
import {
CalculatedFieldArgumentsTableComponent
} from '@home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component';
import {
CalculatedFieldArgumentPanelComponent
} from '@home/components/calculated-fields/components/panel/calculated-field-argument-panel.component';
import {
CalculatedFieldDebugDialogComponent
} from '@home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component';
import {
CalculatedFieldScriptTestDialogComponent
} from '@home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component';
import {
CalculatedFieldTestArgumentsComponent
} from '@home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component';
import { CheckConnectivityDialogComponent } from '@home/components/ai-model/check-connectivity-dialog.component'; import { CheckConnectivityDialogComponent } from '@home/components/ai-model/check-connectivity-dialog.component';
import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialog.component'; import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialog.component';
import { ResourcesDialogComponent } from "@home/components/resources/resources-dialog.component"; import { ResourcesDialogComponent } from "@home/components/resources/resources-dialog.component";
import { ResourcesLibraryComponent } from "@home/components/resources/resources-library.component"; import { ResourcesLibraryComponent } from "@home/components/resources/resources-library.component";
import {
CalculatedFieldGeofencingZoneGroupsTableComponent
} from '@home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component';
import {
CalculatedFieldGeofencingZoneGroupsPanelComponent
} from '@home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component';
@NgModule({ @NgModule({
declarations: declarations:
@ -357,15 +334,6 @@ import {
SendNotificationButtonComponent, SendNotificationButtonComponent,
EntityChipsComponent, EntityChipsComponent,
DashboardViewComponent, DashboardViewComponent,
CalculatedFieldsTableComponent,
CalculatedFieldDialogComponent,
CalculatedFieldArgumentsTableComponent,
CalculatedFieldArgumentPanelComponent,
CalculatedFieldDebugDialogComponent,
CalculatedFieldScriptTestDialogComponent,
CalculatedFieldTestArgumentsComponent,
CalculatedFieldGeofencingZoneGroupsTableComponent,
CalculatedFieldGeofencingZoneGroupsPanelComponent,
CheckConnectivityDialogComponent, CheckConnectivityDialogComponent,
AIModelDialogComponent, AIModelDialogComponent,
ResourcesDialogComponent, ResourcesDialogComponent,
@ -508,15 +476,6 @@ import {
SendNotificationButtonComponent, SendNotificationButtonComponent,
EntityChipsComponent, EntityChipsComponent,
DashboardViewComponent, DashboardViewComponent,
CalculatedFieldsTableComponent,
CalculatedFieldDialogComponent,
CalculatedFieldArgumentsTableComponent,
CalculatedFieldArgumentPanelComponent,
CalculatedFieldDebugDialogComponent,
CalculatedFieldScriptTestDialogComponent,
CalculatedFieldTestArgumentsComponent,
CalculatedFieldGeofencingZoneGroupsTableComponent,
CalculatedFieldGeofencingZoneGroupsPanelComponent,
CheckConnectivityDialogComponent, CheckConnectivityDialogComponent,
AIModelDialogComponent, AIModelDialogComponent,
ResourcesDialogComponent, ResourcesDialogComponent,

4
ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile.module.ts

@ -20,6 +20,7 @@ import { SharedModule } from '@shared/shared.module';
import { HomeComponentsModule } from '@modules/home/components/home-components.module'; import { HomeComponentsModule } from '@modules/home/components/home-components.module';
import { AssetProfileTabsComponent } from './asset-profile-tabs.component'; import { AssetProfileTabsComponent } from './asset-profile-tabs.component';
import { AssetProfileRoutingModule } from './asset-profile-routing.module'; import { AssetProfileRoutingModule } from './asset-profile-routing.module';
import { CalculatedFieldsModule } from '@home/components/calculated-fields/calculated-field.module';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -29,7 +30,8 @@ import { AssetProfileRoutingModule } from './asset-profile-routing.module';
CommonModule, CommonModule,
SharedModule, SharedModule,
HomeComponentsModule, HomeComponentsModule,
AssetProfileRoutingModule CalculatedFieldsModule,
AssetProfileRoutingModule,
] ]
}) })
export class AssetProfileModule { } export class AssetProfileModule { }

4
ui-ngx/src/app/modules/home/pages/asset/asset.module.ts

@ -23,6 +23,7 @@ import { AssetTableHeaderComponent } from './asset-table-header.component';
import { AssetRoutingModule } from './asset-routing.module'; import { AssetRoutingModule } from './asset-routing.module';
import { HomeComponentsModule } from '@modules/home/components/home-components.module'; import { HomeComponentsModule } from '@modules/home/components/home-components.module';
import { AssetTabsComponent } from '@home/pages/asset/asset-tabs.component'; import { AssetTabsComponent } from '@home/pages/asset/asset-tabs.component';
import { CalculatedFieldsModule } from '@home/components/calculated-fields/calculated-field.module';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -35,7 +36,8 @@ import { AssetTabsComponent } from '@home/pages/asset/asset-tabs.component';
SharedModule, SharedModule,
HomeComponentsModule, HomeComponentsModule,
HomeDialogsModule, HomeDialogsModule,
AssetRoutingModule CalculatedFieldsModule,
AssetRoutingModule,
] ]
}) })
export class AssetModule { } export class AssetModule { }

2
ui-ngx/src/app/modules/home/pages/device-profile/device-profile.module.ts

@ -20,6 +20,7 @@ import { SharedModule } from '@shared/shared.module';
import { HomeComponentsModule } from '@modules/home/components/home-components.module'; import { HomeComponentsModule } from '@modules/home/components/home-components.module';
import { DeviceProfileTabsComponent } from './device-profile-tabs.component'; import { DeviceProfileTabsComponent } from './device-profile-tabs.component';
import { DeviceProfileRoutingModule } from './device-profile-routing.module'; import { DeviceProfileRoutingModule } from './device-profile-routing.module';
import { CalculatedFieldsModule } from '@home/components/calculated-fields/calculated-field.module';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -29,6 +30,7 @@ import { DeviceProfileRoutingModule } from './device-profile-routing.module';
CommonModule, CommonModule,
SharedModule, SharedModule,
HomeComponentsModule, HomeComponentsModule,
CalculatedFieldsModule,
DeviceProfileRoutingModule DeviceProfileRoutingModule
] ]
}) })

2
ui-ngx/src/app/modules/home/pages/device/device.module.ts

@ -36,6 +36,7 @@ import { SnmpDeviceTransportConfigurationComponent } from './data/snmp-device-tr
import { DeviceCredentialsModule } from '@home/components/device/device-credentials.module'; import { DeviceCredentialsModule } from '@home/components/device/device-credentials.module';
import { DeviceProfileCommonModule } from '@home/components/profile/device/common/device-profile-common.module'; import { DeviceProfileCommonModule } from '@home/components/profile/device/common/device-profile-common.module';
import { DeviceCheckConnectivityDialogComponent } from './device-check-connectivity-dialog.component'; import { DeviceCheckConnectivityDialogComponent } from './device-check-connectivity-dialog.component';
import { CalculatedFieldsModule } from '@home/components/calculated-fields/calculated-field.module';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -61,6 +62,7 @@ import { DeviceCheckConnectivityDialogComponent } from './device-check-connectiv
HomeDialogsModule, HomeDialogsModule,
DeviceCredentialsModule, DeviceCredentialsModule,
DeviceProfileCommonModule, DeviceProfileCommonModule,
CalculatedFieldsModule,
DeviceRoutingModule DeviceRoutingModule
] ]
}) })

2
ui-ngx/src/app/shared/components/time-unit-input.component.ts

@ -178,7 +178,7 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator,
this.timeInputForm.disable({emitEvent: false}); this.timeInputForm.disable({emitEvent: false});
} else { } else {
this.timeInputForm.enable({emitEvent: false}); this.timeInputForm.enable({emitEvent: false});
if(this.timeInputForm.invalid) { if(!this.timeInputForm.valid) {
setTimeout(() => this.updatedModel(this.timeInputForm.value, true)) setTimeout(() => this.updatedModel(this.timeInputForm.value, true))
} }
} }

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

@ -31,16 +31,41 @@ import {
} from '@shared/models/ace/ace.models'; } from '@shared/models/ace/ace.models';
import { EntitySearchDirection } from '@shared/models/relation.models'; import { EntitySearchDirection } from '@shared/models/relation.models';
export interface CalculatedField extends Omit<BaseData<CalculatedFieldId>, 'label'>, HasVersion, HasEntityDebugSettings, HasTenantId, ExportableEntity<CalculatedFieldId> { interface BaseCalculatedField extends Omit<BaseData<CalculatedFieldId>, 'label'>, HasVersion, HasEntityDebugSettings, HasTenantId, ExportableEntity<CalculatedFieldId> {
configuration: CalculatedFieldConfiguration;
type: CalculatedFieldType;
entityId: EntityId; entityId: EntityId;
} }
export interface CalculatedFieldSimple extends BaseCalculatedField {
type: CalculatedFieldType.SIMPLE;
configuration: CalculatedFieldSimpleConfiguration;
}
export interface CalculatedFieldScript extends BaseCalculatedField {
type: CalculatedFieldType.SCRIPT;
configuration: CalculatedFieldScriptConfiguration;
}
export interface CalculatedFieldGeofencing extends BaseCalculatedField {
type: CalculatedFieldType.GEOFENCING;
configuration: CalculatedFieldGeofencingConfiguration;
}
export interface CalculatedFieldPropagation extends BaseCalculatedField {
type: CalculatedFieldType.PROPAGATION;
configuration: CalculatedFieldPropagationConfiguration;
}
export type CalculatedField =
| CalculatedFieldSimple
| CalculatedFieldScript
| CalculatedFieldGeofencing
| CalculatedFieldPropagation;
export enum CalculatedFieldType { export enum CalculatedFieldType {
SIMPLE = 'SIMPLE', SIMPLE = 'SIMPLE',
SCRIPT = 'SCRIPT', SCRIPT = 'SCRIPT',
GEOFENCING = 'GEOFENCING' GEOFENCING = 'GEOFENCING',
PROPAGATION = 'PROPAGATION'
} }
export const CalculatedFieldTypeTranslations = new Map<CalculatedFieldType, string>( export const CalculatedFieldTypeTranslations = new Map<CalculatedFieldType, string>(
@ -48,22 +73,66 @@ export const CalculatedFieldTypeTranslations = new Map<CalculatedFieldType, stri
[CalculatedFieldType.SIMPLE, 'calculated-fields.type.simple'], [CalculatedFieldType.SIMPLE, 'calculated-fields.type.simple'],
[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'],
] ]
) )
export interface CalculatedFieldConfiguration { export type CalculatedFieldConfiguration =
type: CalculatedFieldType; | CalculatedFieldSimpleConfiguration
expression?: string; | CalculatedFieldScriptConfiguration
arguments?: Record<string, CalculatedFieldArgument>; | CalculatedFieldGeofencingConfiguration
zoneGroups?: Record<string, CalculatedFieldGeofencing>; | CalculatedFieldPropagationConfiguration;
export interface CalculatedFieldSimpleConfiguration {
type: CalculatedFieldType.SIMPLE;
expression: string;
arguments: Record<string, CalculatedFieldArgument>;
output: CalculatedFieldSimpleOutput;
}
export interface CalculatedFieldScriptConfiguration {
type: CalculatedFieldType.SCRIPT;
expression: string;
arguments: Record<string, CalculatedFieldArgument>;
output: CalculatedFieldOutput;
}
export interface CalculatedFieldGeofencingConfiguration {
type: CalculatedFieldType.GEOFENCING;
zoneGroups: Record<string, CalculatedFieldGeofencing>;
scheduledUpdateEnabled: boolean;
scheduledUpdateInterval?: number; scheduledUpdateInterval?: number;
output: CalculatedFieldOutput; output: CalculatedFieldOutput;
} }
interface BasePropagationConfiguration {
type: CalculatedFieldType.PROPAGATION;
direction: EntitySearchDirection;
relationType: string;
arguments: Record<string, CalculatedFieldArgument>;
output: CalculatedFieldOutput;
}
export interface PropagationWithNoExpression extends BasePropagationConfiguration {
applyExpressionToResolvedArguments: false;
}
export interface PropagationWithExpression extends BasePropagationConfiguration {
applyExpressionToResolvedArguments: true;
expression: string;
}
export type CalculatedFieldPropagationConfiguration =
| PropagationWithNoExpression
| PropagationWithExpression;
export interface CalculatedFieldOutput { export interface CalculatedFieldOutput {
type: OutputType; type: OutputType;
name: string;
scope?: AttributeScope; scope?: AttributeScope;
}
export interface CalculatedFieldSimpleOutput extends CalculatedFieldOutput {
name: string;
decimalsByDefault?: number; decimalsByDefault?: number;
} }
@ -115,6 +184,13 @@ export const GeofencingDirectionLevelTranslations = new Map<EntitySearchDirectio
] ]
) )
export const PropagationDirectionTranslations = new Map<EntitySearchDirection, string>(
[
[EntitySearchDirection.FROM, 'calculated-fields.direction-down-child'],
[EntitySearchDirection.TO, 'calculated-fields.direction-up-parent'],
]
)
export enum ArgumentType { export enum ArgumentType {
Attribute = 'ATTRIBUTE', Attribute = 'ATTRIBUTE',
LatestTelemetry = 'TS_LATEST', LatestTelemetry = 'TS_LATEST',

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

@ -1053,7 +1053,8 @@
"type": { "type": {
"simple": "Simple", "simple": "Simple",
"script": "Script", "script": "Script",
"geofencing" : "Geofencing" "geofencing" : "Geofencing",
"propagation": "Propagation"
}, },
"arguments": "Arguments", "arguments": "Arguments",
"decimals-by-default": "Decimals by default", "decimals-by-default": "Decimals by default",
@ -1063,6 +1064,7 @@
"datasource": "Datasource", "datasource": "Datasource",
"add-argument": "Add argument", "add-argument": "Add argument",
"test-script-function": "Test script function", "test-script-function": "Test script function",
"test-expression-function": "Test expression function",
"no-arguments": "No arguments configured", "no-arguments": "No arguments configured",
"argument-settings": "Argument settings", "argument-settings": "Argument settings",
"argument-current": "Current entity", "argument-current": "Current entity",
@ -1138,14 +1140,26 @@
"level": "Level", "level": "Level",
"direction-level": "Direction", "direction-level": "Direction",
"direction-up": "Up", "direction-up": "Up",
"direction-up-parent": "Up to parent",
"direction-down": "Down", "direction-down": "Down",
"direction-down-child": "Down to child",
"add-level": "Add level", "add-level": "Add level",
"delete-level": "Delete level", "delete-level": "Delete level",
"no-level": "No level configured", "no-level": "No level configured",
"levels-required": "At least one level must be configured.", "levels-required": "At least one level must be configured.",
"max-allowed-levels-error": "Relation level exceeds the maximum allowed.", "max-allowed-levels-error": "Relation level exceeds the maximum allowed.",
"propagation-path-related-entities": "Propagation path to related entities",
"propagate-type": {
"arguments-only": "Arguments only",
"expression-result": "Expression result"
},
"data-propagate": "Data to propagate",
"output-key": "Output key",
"copy-output-key": "Copy output key",
"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-argument-entity-type": "Entity type is incompatible with 'Arguments only' propagation.",
"arguments-empty": "Arguments should not be empty.", "arguments-empty": "Arguments should not be empty.",
"expression-required": "Expression is required.", "expression-required": "Expression is required.",
"expression-invalid": "Expression is invalid", "expression-invalid": "Expression is invalid",
@ -1155,6 +1169,12 @@
"argument-name-duplicate": "Argument with such name already exists.", "argument-name-duplicate": "Argument with such name already exists.",
"argument-name-max-length": "Argument name should be less than 256 characters.", "argument-name-max-length": "Argument name should be less than 256 characters.",
"argument-name-forbidden": "Argument name is reserved and cannot be used.", "argument-name-forbidden": "Argument name is reserved and cannot be used.",
"output-key-required": "Output key is required.",
"output-key-pattern": "Output key is invalid.",
"output-key-duplicate": "Key with such name already exists.",
"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": "Mame is required.",
"name-pattern": "Name is invalid.", "name-pattern": "Name is invalid.",
"name-duplicate": "Name with such name already exists.", "name-duplicate": "Name with such name already exists.",
@ -1180,7 +1200,9 @@
"max-geofencing-zone": "Maximum number of geofencing zones reached.", "max-geofencing-zone": "Maximum number of geofencing zones reached.",
"zone-group-refresh-interval": "Defines how often zone groups configured via related entities are refreshed.", "zone-group-refresh-interval": "Defines how often zone groups configured via related entities are refreshed.",
"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.",
"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."
} }
}, },
"ai-models": { "ai-models": {

Loading…
Cancel
Save