Browse Source

UI: Add configuration propagate calculate field

pull/14107/head
Vladyslav_Prykhodko 11 months ago
parent
commit
fca2166170
  1. 6
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts
  2. 70
      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. 58
      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. 10
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html
  12. 11
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts
  13. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts
  14. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.module.ts
  15. 0
      ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.module.ts
  16. 99
      ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.html
  17. 174
      ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.ts
  18. 44
      ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.module.ts
  19. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.html
  20. 21
      ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts
  21. 12
      ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.module.ts
  22. 51
      ui-ngx/src/app/shared/models/calculated-field.models.ts
  23. 23
      ui-ngx/src/assets/locale/locale.constant-en_US.json

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

@ -40,6 +40,9 @@ import {
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: [
@ -55,7 +58,8 @@ import {
GeofencingConfigurationModule,
EntityDebugSettingsButtonComponent,
HomeComponentsModule,
SimpleConfigurationModule
SimpleConfigurationModule,
PropagationConfigurationModule,
],
exports: [
CalculatedFieldsTableComponent,

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

@ -40,10 +40,12 @@ import {
ArgumentType,
CalculatedField,
CalculatedFieldEventArguments,
CalculatedFieldScriptConfiguration,
CalculatedFieldType,
CalculatedFieldTypeTranslations,
getCalculatedFieldArgumentsEditorCompleter,
getCalculatedFieldArgumentsHighlights,
PropagationWithExpression,
} from '@shared/models/calculated-field.models';
import {
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 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.cellActionDescriptors.push(
@ -156,7 +158,8 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
}
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(', ') + ')';
} else if (entity.type === CalculatedFieldType.SIMPLE) {
return entity.configuration.expression ?? '';
@ -288,35 +291,42 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
}
private getTestScriptDialog(calculatedField: CalculatedField, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true): Observable<string> {
if (calculatedField.type === CalculatedFieldType.GEOFENCING || calculatedField.type === CalculatedFieldType.SIMPLE) {
if (
calculatedField.type === CalculatedFieldType.SCRIPT ||
(calculatedField.type === CalculatedFieldType.PROPAGATION && calculatedField.configuration.applyExpressionToResolvedArguments === true)
) {
const resultArguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => {
const type = calculatedField.configuration.arguments[key].refEntityKey.type;
acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key)
? {...argumentsObj[key], type}
: type === ArgumentType.Rolling ? {values: [], type} : {value: '', type, ts: new Date().getTime()};
return acc;
}, {});
return this.dialog.open<CalculatedFieldScriptTestDialogComponent, CalculatedFieldTestScriptDialogData, string>(CalculatedFieldScriptTestDialogComponent,
{
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog', 'tb-fullscreen-dialog-gt-xs'],
data: {
arguments: resultArguments,
expression: (calculatedField.configuration as CalculatedFieldScriptConfiguration | PropagationWithExpression).expression,
argumentsEditorCompleter: getCalculatedFieldArgumentsEditorCompleter(calculatedField.configuration.arguments),
argumentsHighlightRules: getCalculatedFieldArgumentsHighlights(calculatedField.configuration.arguments),
openCalculatedFieldEdit
}
}).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);
}
const resultArguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => {
const type = calculatedField.configuration.arguments[key].refEntityKey.type;
acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key)
? { ...argumentsObj[key], type }
: type === ArgumentType.Rolling ? { values: [], type } : { value: '', type, ts: new Date().getTime() };
return acc;
}, {});
return this.dialog.open<CalculatedFieldScriptTestDialogComponent, CalculatedFieldTestScriptDialogData, string>(CalculatedFieldScriptTestDialogComponent,
{
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog', 'tb-fullscreen-dialog-gt-xs'],
data: {
arguments: resultArguments,
expression: calculatedField.configuration.expression,
argumentsEditorCompleter: getCalculatedFieldArgumentsEditorCompleter(calculatedField.configuration.arguments),
argumentsHighlightRules: getCalculatedFieldArgumentsHighlights(calculatedField.configuration.arguments),
openCalculatedFieldEdit
}
}).afterClosed()
.pipe(
filter(Boolean),
tap(expression => {
if (openCalculatedFieldEdit) {
this.editCalculatedField({ entityId: this.entityId, ...calculatedField, configuration: {...calculatedField.configuration, expression } }, true)
}
}),
);
}
}

134
ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/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-title">{{ 'calculated-fields.argument-settings' | translate }}</div>
<div class="tb-form-panel no-border no-padding">
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.argument-name' | 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]="'calculated-fields.hint.argument-name-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]="'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">
@if (!isOutputKey) {
<ng-container *ngTemplateOutlet="argumentNameTemplate; context: {
label: 'calculated-fields.argument-name',
required: 'calculated-fields.hint.argument-name-required',
duplicate: 'calculated-fields.hint.argument-name-duplicate',
pattern: 'calculated-fields.hint.argument-name-pattern',
maxlength: 'calculated-fields.hint.argument-name-max-length',
forbidden: 'calculated-fields.hint.argument-name-forbidden'
}"></ng-container>
}
<ng-container>
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'entity.entity-type' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="entityType">
<mat-select [formControl]="argumentType">
@for (type of argumentEntityTypes; track type) {
<mat-option [value]="type">{{ ArgumentEntityTypeTranslations.get(type) | translate }}</mat-option>
}
</mat-select>
@if (argumentType.touched && argumentType.hasError('required')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.entity-type-required' | translate"
class="tb-error">
warning
</mat-icon>
}
</mat-form-field>
</div>
@if (ArgumentEntityTypeParamsMap.has(entityType)) {
@ -83,7 +55,8 @@
<tb-entity-autocomplete
class="flex flex-1"
#entityAutocomplete
formControlName="id"
formControlName="refEntityId"
useFullEntityId
inlineField
[placeholder]="'action.set' | translate"
[required]="true"
@ -158,6 +131,16 @@
}
}
</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) {
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'calculated-fields.default-value' | translate }}</div>
@ -207,3 +190,54 @@
</button>
</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/simple-configuration/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/simple-configuration/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.
///
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 { FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms';
import { charsWithNumRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants';
@ -25,7 +34,6 @@ import {
ArgumentType,
ArgumentTypeTranslations,
CalculatedFieldArgumentValue,
CalculatedFieldType,
getCalculatedFieldCurrentEntityFilter
} from '@shared/models/calculated-field.models';
import { debounceTime, delay, distinctUntilChanged, filter } from 'rxjs/operators';
@ -43,6 +51,7 @@ import { AppState } from '@core/core.state';
import { Store } from '@ngrx/store';
import { EntityAutocompleteComponent } from '@shared/components/entity/entity-autocomplete.component';
import { NULL_UUID } from '@shared/models/id/has-uuid';
import { TenantId } from '@shared/models/id/tenant-id';
@Component({
selector: 'tb-calculated-field-argument-panel',
@ -56,22 +65,23 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
@Input() entityId: EntityId;
@Input() tenantId: string;
@Input() entityName: string;
@Input() calculatedFieldType: CalculatedFieldType;
@Input() isScript: boolean;
@Input() usedArgumentNames: string[];
@Input() isOutputKey = false;
@Input() argumentEntityTypes = Object.values(ArgumentEntityType).filter(value => value !== ArgumentEntityType.RelationQuery) as ArgumentEntityType[];
@ViewChild('entityAutocomplete') entityAutocomplete: EntityAutocompleteComponent;
argumentsDataApplied = output<CalculatedFieldArgumentValue>();
argumentType = this.fb.control(ArgumentEntityType.Current, Validators.required);
readonly maxDataPointsPerRollingArg = getCurrentAuthState(this.store).maxDataPointsPerRollingArg;
readonly defaultLimit = Math.floor(this.maxDataPointsPerRollingArg / 10);
argumentFormGroup = this.fb.group({
argumentName: ['', [Validators.required, this.uniqNameRequired(), this.forbiddenArgumentNameValidator(), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]],
refEntityId: this.fb.group({
entityType: [ArgumentEntityType.Current],
id: ['']
}),
refEntityId: [null],
refEntityKey: this.fb.group({
type: [ArgumentType.LatestTelemetry, [Validators.required]],
key: ['', [Validators.pattern(oneSpaceInsideRegex)]],
@ -86,7 +96,6 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
entityFilter: EntityFilter;
entityNameSubject = new BehaviorSubject<string>(null);
readonly argumentEntityTypes = Object.values(ArgumentEntityType).filter(value => value !== ArgumentEntityType.RelationQuery) as ArgumentEntityType[];
readonly ArgumentEntityTypeTranslations = ArgumentEntityTypeTranslations;
readonly ArgumentType = ArgumentType;
readonly DataKeyType = DataKeyType;
@ -103,20 +112,17 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
private fb: FormBuilder,
private cd: ChangeDetectorRef,
private popover: TbPopoverComponent<CalculatedFieldArgumentPanelComponent>,
private store: Store<AppState>
private store: Store<AppState>,
private destroyRef: DestroyRef
) {
this.observeEntityFilterChanges();
this.observeEntityTypeChanges();
this.observeArgumentTypeChanges();
this.observeEntityKeyChanges();
this.observeUpdatePosition();
}
get entityType(): ArgumentEntityType {
return this.argumentFormGroup.get('refEntityId').get('entityType').value;
}
get refEntityIdFormGroup(): FormGroup {
return this.argumentFormGroup.get('refEntityId') as FormGroup;
return this.argumentType.value;
}
get refEntityKeyFormGroup(): FormGroup {
@ -130,14 +136,18 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
}
ngOnInit(): void {
this.updatedArgumentType();
this.argumentFormGroup.patchValue(this.argument, {emitEvent: false});
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.setInitialEntityKeyType();
this.setInitialEntityType();
this.setWatchKeyChange();
this.argumentTypes = Object.values(ArgumentType)
.filter(type => type !== ArgumentType.Rolling || this.calculatedFieldType === CalculatedFieldType.SCRIPT);
.filter(type => type !== ArgumentType.Rolling || this.isScript);
}
ngAfterViewInit(): void {
@ -147,12 +157,11 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
}
saveArgument(): void {
const { refEntityId, ...restConfig } = this.argumentFormGroup.value;
const value = (refEntityId.entityType === ArgumentEntityType.Current ? restConfig : { refEntityId, ...restConfig }) as CalculatedFieldArgumentValue;
if (refEntityId.entityType === ArgumentEntityType.Tenant) {
refEntityId.id = this.tenantId;
const value = this.argumentFormGroup.value as CalculatedFieldArgumentValue;
if (this.entityType === ArgumentEntityType.Tenant) {
value.refEntityId = new TenantId(this.tenantId) as any;
}
if (refEntityId.entityType !== ArgumentEntityType.Current && refEntityId.entityType !== ArgumentEntityType.Tenant) {
if (this.entityType !== ArgumentEntityType.Current && this.entityType !== ArgumentEntityType.Tenant) {
value.entityName = this.entityNameSubject.value;
}
if (value.defaultValue) {
@ -166,6 +175,14 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
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 {
const isAttribute = type === ArgumentType.Attribute;
const isRolling = type === ArgumentType.Rolling;
@ -205,26 +222,21 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
private observeEntityFilterChanges(): void {
merge(
this.refEntityIdFormGroup.get('entityType').valueChanges,
this.argumentType.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,
)
.pipe(debounceTime(50), takeUntilDestroyed())
.subscribe(() => this.updateEntityFilter(this.entityType));
}
private observeEntityTypeChanges(): void {
this.refEntityIdFormGroup.get('entityType').valueChanges
private observeArgumentTypeChanges(): void {
this.argumentType.valueChanges
.pipe(distinctUntilChanged(), takeUntilDestroyed())
.subscribe(type => {
this.argumentFormGroup.get('refEntityId').get('id').setValue('');
const isEntityWithId = type !== ArgumentEntityType.Tenant && type !== ArgumentEntityType.Current;
this.argumentFormGroup.get('refEntityId')
.get('id')[isEntityWithId ? 'enable' : 'disable']();
if (!isEntityWithId) {
this.entityNameSubject.next(null);
}
this.argumentFormGroup.get('refEntityId').setValue(null);
this.updatedRefEntityIdState(type);
if (!this.enableAttributeScopeSelection) {
this.refEntityKeyFormGroup.get('scope').setValue(AttributeScope.SERVER_SCOPE);
}
@ -247,29 +259,56 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
}
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');
typeControl.setValue(null);
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 {
return (control: FormControl) => {
const trimmedValue = control.value.trim().toLowerCase();
const forbiddenArgumentNames = ['ctx', 'e', 'pi'];
const forbiddenArgumentNames = ['ctx', 'e', 'pi', 'propagationCtx'];
return forbiddenArgumentNames.includes(trimmedValue) ? { forbiddenName: true } : null;
};
}
private observeUpdatePosition(): void {
merge(
this.refEntityIdFormGroup.get('entityType').valueChanges,
this.argumentType.valueChanges,
this.refEntityKeyFormGroup.get('type').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())
.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/simple-configuration/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>
<ng-container [matColumnDef]="'name'">
<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-cell *matCellDef="let argument" class="argument-name-cell w-1/3 xs:w-1/2">
<div class="flex items-center">
@ -29,7 +29,7 @@
<tb-copy-button
class="copy-argument-name"
[copyText]="argument.argumentName"
tooltipText="{{ 'calculated-fields.copy-argument-name' | translate }}"
tooltipText="{{ argumentNameColumnCopy | translate }}"
tooltipPosition="above"
icon="content_copy"
/>
@ -37,7 +37,7 @@
</mat-cell>
</ng-container>
<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 }}
</mat-header-cell>
<mat-cell *matCellDef="let argument" class="w-1/5 xs:hidden">
@ -96,8 +96,7 @@
[matTooltip]="'action.edit' | translate"
matTooltipPosition="above">
<mat-icon
[matBadgeHidden]="!(argument.refEntityKey.type === ArgumentType.Rolling
&& calculatedFieldType === CalculatedFieldType.SIMPLE) && argument.refEntityId?.id !== NULL_UUID"
[matBadgeHidden]="isEditButtonShowBadge(argument)"
matBadgeColor="warn"
matBadgeSize="small"
matBadge="*"
@ -115,10 +114,8 @@
</div>
</mat-cell>
</ng-container>
<mat-header-row class="mat-row-select"
*matHeaderRowDef="['name', 'entityType', 'target', 'type', 'key', 'actions']"></mat-header-row>
<mat-row
*matRowDef="let argument; columns: ['name', 'entityType', 'target', 'type', 'key', 'actions']"></mat-row>
<mat-header-row class="mat-row-select" *matHeaderRowDef="displayColumns"></mat-header-row>
<mat-row *matRowDef="let argument; columns: displayColumns"></mat-row>
</table>
<div [class.!hidden]="(dataSource.isEmpty() | async) === false"
class="tb-prompt flex flex-1 items-end justify-center">

2
ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/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 {
.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;
}
}

58
ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/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

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

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

@ -67,13 +67,21 @@
<tb-geofencing-configuration formControlName="configuration" [entityId]="data.entityId" [entityName]="data.entityName" [tenantId]="data.tenantId">
</tb-geofencing-configuration>
}
@case (CalculatedFieldType.PROPAGATION) {
<tb-propagation-configuration formControlName="configuration"
[entityId]="data.entityId"
[entityName]="data.entityName"
[tenantId]="data.tenantId"
[testScript]="onTestScript.bind(this)">
</tb-propagation-configuration>
}
@default {
<tb-simple-configuration formControlName="configuration"
[entityId]="data.entityId"
[entityName]="data.entityName"
[tenantId]="data.tenantId"
[isScript]="fieldFormGroup.get('type').value === CalculatedFieldType.SCRIPT"
[testScript$]="onTestScript.bind(this)"
[testScript]="onTestScript.bind(this)"
>
</tb-simple-configuration>
}

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

@ -83,6 +83,7 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
private fb: FormBuilder) {
super(store, router, dialogRef);
this.observeIsLoading();
this.observeType();
this.applyDialogData();
}
@ -134,4 +135,14 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
}
});
}
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});
}
});
}
}

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

@ -114,7 +114,7 @@ export class GeofencingConfigurationComponent implements ControlValueAccessor, V
}
validate(): ValidationErrors | null {
return this.geofencingConfiguration.valid ? null : { geofencingConfigError: false };
return this.geofencingConfiguration.valid || this.geofencingConfiguration.status === "DISABLED" ? null : { geofencingConfigError: false };
}
writeValue(config: CalculatedFieldGeofencingConfiguration): void {

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

@ -28,7 +28,7 @@ import {
} from '@home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component';
import {
CalculatedFieldOutputModule
} from '@home/components/calculated-fields/components/output/caclculate-field-output.module';
} from '@home/components/calculated-fields/components/output/calculated-field-output.module';
@NgModule({
imports: [

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

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/simple-configuration/simple-configuration.component.html

@ -22,7 +22,7 @@
[entityId]="entityId"
[tenantId]="tenantId"
[entityName]="entityName"
[calculatedFieldType]="(isScript ? CalculatedFieldType.SCRIPT : CalculatedFieldType.SIMPLE)" />
[isScript]="isScript" />
</div>
<div class="tb-form-panel no-gap">
<div class="tb-form-panel-title tb-required">

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

@ -66,17 +66,17 @@ export class SimpleConfigurationComponent implements ControlValueAccessor, Valid
@Input()
isScript: boolean;
@Input()
@Input({required: true})
entityId: EntityId;
@Input()
@Input({required: true})
tenantId: string;
@Input()
@Input({required: true})
entityName: string;
@Input()
testScript$: Observable<string>;
@Input({required: true})
testScript: () => Observable<string>;
simpleConfiguration = this.fb.group({
arguments: this.fb.control({}),
@ -92,7 +92,6 @@ export class SimpleConfigurationComponent implements ControlValueAccessor, Valid
});
readonly ScriptLanguage = ScriptLanguage;
readonly CalculatedFieldType = CalculatedFieldType;
readonly OutputType = OutputType;
functionArgs$ = this.simpleConfiguration.get('arguments').valueChanges.pipe(
@ -141,19 +140,21 @@ export class SimpleConfigurationComponent implements ControlValueAccessor, Valid
}
validate(): ValidationErrors | null {
return this.simpleConfiguration.valid ? null : {invalidSimpleConfig: false};
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;
formValue.expressionSCRIPT = formValue.expression ?? calculatedFieldDefaultScript;
} else {
formValue.expressionSIMPLE = formValue.expression;
}
this.simpleConfiguration.patchValue(formValue, {emitEvent: false});
this.simpleConfiguration.get('arguments').updateValueAndValidity({onlySelf: true});
this.updatedFormWithScript();
setTimeout(() => {
this.simpleConfiguration.get('arguments').updateValueAndValidity({onlySelf: true});
});
}
registerOnChange(fn: (config: SimpeConfiguration) => void): void {
@ -173,7 +174,7 @@ export class SimpleConfigurationComponent implements ControlValueAccessor, Valid
}
onTestScript() {
this.testScript$?.subscribe((expression) => {
this.testScript().subscribe((expression) => {
this.simpleConfiguration.get('expressionSCRIPT').setValue(expression);
this.simpleConfiguration.get('expressionSCRIPT').markAsDirty();
})

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

@ -20,26 +20,22 @@ import { SharedModule } from '@shared/shared.module';
import {
SimpleConfigurationComponent
} from '@home/components/calculated-fields/components/simple-configuration/simple-configuration.component';
import {
CalculatedFieldArgumentPanelComponent
} from '@home/components/calculated-fields/components/simple-configuration/calculated-field-argument-panel.component';
import {
CalculatedFieldOutputModule
} from '@home/components/calculated-fields/components/output/caclculate-field-output.module';
} from '@home/components/calculated-fields/components/output/calculated-field-output.module';
import {
CalculatedFieldArgumentsTableComponent
} from '@home/components/calculated-fields/components/simple-configuration/calculated-field-arguments-table.component';
CalculatedFieldArgumentsTableModule
} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module';
@NgModule({
imports: [
CommonModule,
SharedModule,
CalculatedFieldOutputModule,
CalculatedFieldArgumentsTableModule,
],
declarations: [
SimpleConfigurationComponent,
CalculatedFieldArgumentPanelComponent,
CalculatedFieldArgumentsTableComponent
],
exports: [
SimpleConfigurationComponent

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

@ -50,10 +50,16 @@ export interface CalculatedFieldGeofencing extends BaseCalculatedField {
configuration: CalculatedFieldGeofencingConfiguration;
}
export interface CalculatedFieldPropagation extends BaseCalculatedField {
type: CalculatedFieldType.PROPAGATION;
configuration: CalculatedFieldPropagationConfiguration;
}
export type CalculatedField =
| CalculatedFieldSimple
| CalculatedFieldScript
| CalculatedFieldGeofencing;
| CalculatedFieldGeofencing
| CalculatedFieldPropagation;
export enum CalculatedFieldType {
SIMPLE = 'SIMPLE',
@ -74,30 +80,52 @@ export const CalculatedFieldTypeTranslations = new Map<CalculatedFieldType, stri
export type CalculatedFieldConfiguration =
| CalculatedFieldSimpleConfiguration
| CalculatedFieldScriptConfiguration
| CalculatedFieldGeofencingConfiguration;
| CalculatedFieldGeofencingConfiguration
| CalculatedFieldPropagationConfiguration;
export interface CalculatedFieldSimpleConfiguration {
type: CalculatedFieldType.SIMPLE;
expression?: string;
arguments?: Record<string, CalculatedFieldArgument>;
expression: string;
arguments: Record<string, CalculatedFieldArgument>;
output: CalculatedFieldSimpleOutput;
}
export interface CalculatedFieldScriptConfiguration {
type: CalculatedFieldType.SCRIPT;
expression?: string;
arguments?: Record<string, CalculatedFieldArgument>;
expression: string;
arguments: Record<string, CalculatedFieldArgument>;
output: CalculatedFieldOutput;
}
export interface CalculatedFieldGeofencingConfiguration {
type: CalculatedFieldType.GEOFENCING;
zoneGroups?: Record<string, CalculatedFieldGeofencing>;
scheduledUpdateEnabled?: boolean;
zoneGroups: Record<string, CalculatedFieldGeofencing>;
scheduledUpdateEnabled: boolean;
scheduledUpdateInterval?: number;
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 {
type: OutputType;
scope?: AttributeScope;
@ -156,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 {
Attribute = 'ATTRIBUTE',
LatestTelemetry = 'TS_LATEST',

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

@ -1064,6 +1064,7 @@
"datasource": "Datasource",
"add-argument": "Add argument",
"test-script-function": "Test script function",
"test-expression-function": "Test expression function",
"no-arguments": "No arguments configured",
"argument-settings": "Argument settings",
"argument-current": "Current entity",
@ -1139,14 +1140,26 @@
"level": "Level",
"direction-level": "Direction",
"direction-up": "Up",
"direction-up-parent": "Up to parent",
"direction-down": "Down",
"direction-down-child": "Down to child",
"add-level": "Add level",
"delete-level": "Delete level",
"no-level": "No level configured",
"levels-required": "At least one level must be configured.",
"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": {
"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.",
"expression-required": "Expression is required.",
"expression-invalid": "Expression is invalid",
@ -1156,6 +1169,12 @@
"argument-name-duplicate": "Argument with such name already exists.",
"argument-name-max-length": "Argument name should be less than 256 characters.",
"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-pattern": "Name is invalid.",
"name-duplicate": "Name with such name already exists.",
@ -1181,7 +1200,9 @@
"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-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": {

Loading…
Cancel
Save