Browse Source

Merge branch 'feature/calculated-fields' of github.com:thingsboard/thingsboard into feature/calculated-fields

pull/12681/head
Andrii Shvaika 1 year ago
parent
commit
722c707591
  1. 7
      ui-ngx/src/app/core/http/calculated-fields.service.ts
  2. 62
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts
  3. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html
  4. 7
      ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.html
  5. 10
      ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.scss
  6. 14
      ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.ts
  7. 10
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html
  8. 29
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts
  9. 1
      ui-ngx/src/app/modules/home/components/calculated-fields/components/public-api.ts
  10. 36
      ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.html
  11. 88
      ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.ts
  12. 99
      ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.html
  13. 73
      ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.scss
  14. 157
      ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.ts
  15. 2
      ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.html
  16. 14
      ui-ngx/src/app/modules/home/components/event/event-table-config.ts
  17. 10
      ui-ngx/src/app/modules/home/components/home-components.module.ts
  18. 8
      ui-ngx/src/app/shared/components/value-input.component.html
  19. 4
      ui-ngx/src/app/shared/components/value-input.component.ts
  20. 20
      ui-ngx/src/app/shared/models/calculated-field.models.ts
  21. 9
      ui-ngx/src/app/shared/models/entity.models.ts
  22. 7
      ui-ngx/src/app/shared/models/rule-node.models.ts
  23. 1
      ui-ngx/src/assets/help/en_US/calculated-field/expression_fn.md
  24. 1
      ui-ngx/src/assets/help/en_US/calculated-field/test-expression_fn.md
  25. 6
      ui-ngx/src/assets/locale/locale.constant-en_US.json

7
ui-ngx/src/app/core/http/calculated-fields.service.ts

@ -19,9 +19,10 @@ import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils';
import { Observable } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { PageData } from '@shared/models/page/page-data';
import { CalculatedField } from '@shared/models/calculated-field.models';
import { CalculatedField, CalculatedFieldTestScriptInputParams } from '@shared/models/calculated-field.models';
import { PageLink } from '@shared/models/page/page-link';
import { EntityId } from '@shared/models/id/entity-id';
import { EntityTestScriptResult } from '@shared/models/entity.models';
@Injectable({
providedIn: 'root'
@ -48,4 +49,8 @@ export class CalculatedFieldsService {
return this.http.get<PageData<CalculatedField>>(`/api/${entityType}/${id}/calculatedFields${pageLink.toQuery()}`,
defaultHttpOptionsFromConfig(config));
}
public testScript(inputParams: CalculatedFieldTestScriptInputParams, config?: RequestConfig): Observable<EntityTestScriptResult> {
return this.http.post<EntityTestScriptResult>('/api/calculatedField/testScript', inputParams, defaultHttpOptionsFromConfig(config));
}
}

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

@ -34,15 +34,20 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { TbPopoverService } from '@shared/components/popover.service';
import { EntityDebugSettingsPanelComponent } from '@home/components/entity/debug/entity-debug-settings-panel.component';
import { CalculatedFieldsService } from '@core/http/calculated-fields.service';
import { catchError, filter, switchMap } from 'rxjs/operators';
import { catchError, filter, switchMap, tap } from 'rxjs/operators';
import {
CalculatedField,
CalculatedFieldDebugDialogData,
CalculatedFieldDialogData
CalculatedFieldDialogData,
CalculatedFieldTestScriptDialogData,
} from '@shared/models/calculated-field.models';
import { CalculatedFieldDebugDialogComponent, CalculatedFieldDialogComponent } from './components/public-api';
import {
CalculatedFieldDebugDialogComponent,
CalculatedFieldDialogComponent,
CalculatedFieldScriptTestDialogComponent
} from './components/public-api';
import { ImportExportService } from '@shared/import-export/import-export.service';
import { CalculatedFieldId } from '@shared/models/id/calculated-field-id';
import { isObject } from '@core/utils';
export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedField, PageLink> {
@ -53,7 +58,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
readonly tenantId = getCurrentAuthUser(this.store).tenantId;
additionalDebugActionConfig = {
title: this.translate.instant('calculated-fields.see-debug-events'),
action: (id?: CalculatedFieldId) => this.openDebugDialog.call(this, id),
action: (calculatedField: CalculatedField) => this.openDebugDialog.call(this, calculatedField),
};
constructor(private calculatedFieldsService: CalculatedFieldsService,
@ -134,10 +139,11 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
return this.calculatedFieldsService.getCalculatedFields(this.entityId, pageLink);
}
onOpenDebugConfig($event: Event, { debugSettings = {}, id }: CalculatedField): void {
onOpenDebugConfig($event: Event, calculatedField: CalculatedField): void {
const { debugSettings = {}, id } = calculatedField;
const additionalActionConfig = {
...this.additionalDebugActionConfig,
action: () => this.openDebugDialog(id)
action: () => this.openDebugDialog(calculatedField)
};
const { viewContainerRef } = this.getTable();
if ($event) {
@ -173,8 +179,8 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
)
}
private editCalculatedField(calculatedField: CalculatedField): void {
this.getCalculatedFieldDialog(calculatedField, 'action.apply')
private editCalculatedField(calculatedField: CalculatedField, isDirty = false): void {
this.getCalculatedFieldDialog(calculatedField, 'action.apply', isDirty)
.pipe(
filter(Boolean),
switchMap((updatedCalculatedField) => this.calculatedFieldsService.saveCalculatedField({ ...calculatedField, ...updatedCalculatedField })),
@ -186,7 +192,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
});
}
private getCalculatedFieldDialog(value?: CalculatedField, buttonTitle = 'action.add'): Observable<CalculatedField> {
private getCalculatedFieldDialog(value?: CalculatedField, buttonTitle = 'action.add', isDirty = false): Observable<CalculatedField> {
return this.dialog.open<CalculatedFieldDialogComponent, CalculatedFieldDialogData, CalculatedField>(CalculatedFieldDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
@ -198,19 +204,22 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
tenantId: this.tenantId,
entityName: this.entityName,
additionalDebugActionConfig: this.additionalDebugActionConfig,
}
getTestScriptDialogFn: this.getTestScriptDialog.bind(this),
isDirty
},
enterAnimationDuration: isDirty ? 0 : null,
})
.afterClosed();
}
private openDebugDialog(id: CalculatedFieldId): void {
private openDebugDialog(calculatedField: CalculatedField): void {
this.dialog.open<CalculatedFieldDebugDialogComponent, CalculatedFieldDebugDialogData, null>(CalculatedFieldDebugDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
tenantId: this.tenantId,
entityId: this.entityId,
id
value: calculatedField,
getTestScriptDialogFn: this.getTestScriptDialog.bind(this),
}
})
.afterClosed()
@ -251,4 +260,29 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
takeUntilDestroyed(this.destroyRef),
).subscribe(() => this.updateData());
}
private getTestScriptDialog(calculatedField: CalculatedField, argumentsObj?: Record<string, unknown>, openCalculatedFieldEdit = true): Observable<string> {
const resultArguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => {
acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key) ? argumentsObj[key] : '';
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,
openCalculatedFieldEdit
}
}).afterClosed()
.pipe(
filter(Boolean),
tap(expression => {
if (openCalculatedFieldEdit) {
this.editCalculatedField({...calculatedField, configuration: {...calculatedField.configuration, expression } }, true)
}
}),
);
}
}

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

@ -24,7 +24,7 @@
<div class="tb-form-table-header-cell w-1/6">{{ 'entity.key' | translate }}</div>
<div class="tb-form-table-header-cell w-24 min-w-24"></div>
</div>
<div class="tb-form-table-body tb-drop-list">
<div class="tb-form-table-body">
@for (group of argumentsFormArray.controls; track group) {
<div [formGroup]="group" class="tb-form-table-row">
<mat-form-field appearance="outline" class="tb-inline-field w-1/6" subscriptSizing="dynamic">

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

@ -15,7 +15,7 @@
limitations under the License.
-->
<div class="debug-dialog-container w-screen max-w-5xl">
<div class="w-screen max-w-5xl">
<mat-toolbar color="primary">
<h2>{{ 'calculated-fields.debugging' | translate}}</h2>
<span class="flex-1"></span>
@ -25,15 +25,16 @@
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<div mat-dialog-content class="tb-form-panel stroked debug-dialog-content h-full">
<div mat-dialog-content class="tb-form-panel stroked debug-dialog-content">
<tb-event-table
[tenantId]="data.tenantId"
[debugEventTypes]="[debugEventTypes.DEBUG_CALCULATED_FIELD]"
[disabledEventTypes]="[EventType.LC_EVENT, EventType.ERROR, EventType.STATS]"
[defaultEventType]="DebugEventType.DEBUG_CALCULATED_FIELD"
[active]="true"
[entityId]="data.id"
[entityId]="data.value.id"
[functionTestButtonLabel]="'common.test-function' | translate"
(debugEventSelected)="onDebugEventSelected($event)"
/>
</div>
<div mat-dialog-actions class="justify-end">

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

@ -14,12 +14,8 @@
* limitations under the License.
*/
:host {
.debug-dialog-container {
height: 77vh;
min-width: 80vw;
.debug-dialog-content {
border-radius: 0;
}
.debug-dialog-content {
height: 65vh;
border-radius: 0;
}
}

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

@ -20,16 +20,16 @@ import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Router } from '@angular/router';
import { DialogComponent } from '@shared/components/dialog.component';
import { DebugEventType, EventType } from '@shared/models/event.models';
import { CalculatedFieldEventBody, DebugEventType, EventType } from '@shared/models/event.models';
import { EventTableComponent } from '@home/components/event/event-table.component';
import { CalculatedFieldDebugDialogData } from '@shared/models/calculated-field.models';
import { CalculatedFieldDebugDialogData, CalculatedFieldType } from '@shared/models/calculated-field.models';
@Component({
selector: 'tb-calculated-field-debug-dialog',
styleUrls: ['calculated-field-debug-dialog.component.scss'],
templateUrl: './calculated-field-debug-dialog.component.html',
})
export class CalculatedFieldDebugDialogComponent extends DialogComponent<CalculatedFieldDebugDialogComponent, null> implements AfterViewInit {
export class CalculatedFieldDebugDialogComponent extends DialogComponent<CalculatedFieldDebugDialogComponent, string> implements AfterViewInit {
@ViewChild(EventTableComponent, {static: true}) eventsTable: EventTableComponent;
@ -40,15 +40,21 @@ export class CalculatedFieldDebugDialogComponent extends DialogComponent<Calcula
constructor(protected store: Store<AppState>,
protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: CalculatedFieldDebugDialogData,
protected dialogRef: MatDialogRef<CalculatedFieldDebugDialogComponent, null>) {
protected dialogRef: MatDialogRef<CalculatedFieldDebugDialogComponent, string>) {
super(store, router, dialogRef);
}
ngAfterViewInit(): void {
this.eventsTable.entitiesTable.updateData();
this.eventsTable.entitiesTable.cellActionDescriptors[0].isEnabled = () => this.data.value.type === CalculatedFieldType.SCRIPT;
}
cancel(): void {
this.dialogRef.close(null);
}
onDebugEventSelected(event: CalculatedFieldEventBody): void {
this.data.getTestScriptDialogFn(this.data.value, JSON.parse(event.arguments))
.subscribe(expression => this.dialogRef.close(expression));
}
}

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

@ -99,8 +99,16 @@
[functionArgs]="functionArgs$ | async"
[disableUndefinedCheck]="true"
[scriptLanguage]="ScriptLanguage.TBEL"
helpId="[TODO]: [Calculated Fields] add valid link"
helpId="calculated-field/expression_fn"
/>
<div>
<button mat-button mat-raised-button color="primary"
type="button"
(click)="onTestScript()"
[disabled]="configFormGroup.get('expressionSCRIPT').invalid || configFormGroup.get('arguments').invalid ">
{{ 'common.test-function' | translate }}
</button>
</div>
}
</div>
<div class="tb-form-panel" [formGroup]="outputFormGroup">

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

@ -14,7 +14,7 @@
/// limitations under the License.
///
import { Component, Inject } from '@angular/core';
import { AfterViewInit, Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
@ -41,7 +41,7 @@ import { ScriptLanguage } from '@shared/models/rule-node.models';
selector: 'tb-calculated-field-dialog',
templateUrl: './calculated-field-dialog.component.html',
})
export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFieldDialogComponent, CalculatedField> {
export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFieldDialogComponent, CalculatedField> implements AfterViewInit {
fieldFormGroup = this.fb.group({
name: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex), Validators.maxLength(255)]],
@ -67,7 +67,7 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
additionalDebugActionConfig = this.data.value?.id ? {
...this.data.additionalDebugActionConfig,
action: () => this.data.additionalDebugActionConfig.action(this.data.value.id)
action: () => this.data.additionalDebugActionConfig.action({ id: this.data.value.id, ...this.fromGroupValue }),
} : null;
readonly OutputTypeTranslations = OutputTypeTranslations;
@ -98,18 +98,35 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
return this.fieldFormGroup.get('configuration').get('output') as FormGroup;
}
get fromGroupValue(): CalculatedField {
const { configuration, type, ...rest } = this.fieldFormGroup.value;
const { expressionSIMPLE, expressionSCRIPT, ...restConfig } = configuration;
return { configuration: { ...restConfig, type, expression: configuration['expression'+type] }, ...rest, type } as CalculatedField;
}
ngAfterViewInit(): void {
if (this.data.isDirty) {
this.fieldFormGroup.markAsDirty();
}
}
cancel(): void {
this.dialogRef.close(null);
}
add(): void {
if (this.fieldFormGroup.valid) {
const { configuration, type, ...rest } = this.fieldFormGroup.value;
const { expressionSIMPLE, expressionSCRIPT, ...restConfig } = configuration;
this.dialogRef.close({ configuration: { ...restConfig, type, expression: configuration['expression'+type] }, ...rest, type } as CalculatedField);
this.dialogRef.close(this.fromGroupValue);
}
}
onTestScript(): void {
this.data.getTestScriptDialogFn(this.fromGroupValue, null, false).subscribe(expression => {
this.configFormGroup.get('expressionSCRIPT').setValue(expression);
this.configFormGroup.get('expressionSCRIPT').markAsDirty();
});
}
private applyDialogData(): void {
const { configuration = {}, type = CalculatedFieldType.SIMPLE, ...value } = this.data.value ?? {};
const { expression, ...restConfig } = configuration as CalculatedFieldConfiguration;

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

@ -18,3 +18,4 @@ 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 './test-dialog/calculated-field-script-test-dialog.component';

36
ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.html

@ -0,0 +1,36 @@
<!--
Copyright © 2016-2024 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="flex flex-col gap-2 px-4">
<div>{{ 'calculated-fields.arguments' | translate }}</div>
<div class="tb-form-table">
<div class="tb-form-table-header">
<div class="tb-form-table-header-cell w-1/4">{{ 'calculated-fields.argument-name' | translate }}</div>
<div class="tb-form-table-header-cell flex-1">{{ 'common.value' | translate }}</div>
</div>
<div class="tb-form-table-body">
@for (group of argumentsFormArray.controls; track group) {
<div [formGroup]="group" class="tb-form-table-row">
<mat-form-field appearance="outline" class="tb-inline-field w-1/4" subscriptSizing="dynamic">
<input matInput formControlName="argumentName" placeholder="{{ 'action.set' | translate }}">
</mat-form-field>
<tb-value-input class="flex-1" [required]="false" formControlName="value"/>
</div>
}
</div>
</div>
</div>

88
ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.ts

@ -0,0 +1,88 @@
///
/// Copyright © 2016-2024 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 } from '@angular/core';
import {
ControlValueAccessor,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
Validator,
ValidationErrors,
FormBuilder,
FormGroup
} from '@angular/forms';
import { PageComponent } from '@shared/components/page.component';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'tb-calculated-field-test-arguments',
templateUrl: './calculated-field-test-arguments.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CalculatedFieldTestArgumentsComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => CalculatedFieldTestArgumentsComponent),
multi: true,
}
]
})
export class CalculatedFieldTestArgumentsComponent extends PageComponent implements ControlValueAccessor, Validator {
argumentsFormArray = this.fb.array<FormGroup>([]);
private propagateChange: (value: { argumentName: string; value: unknown }) => void;
constructor(private fb: FormBuilder) {
super();
this.argumentsFormArray.valueChanges
.pipe(takeUntilDestroyed())
.subscribe(() => this.propagateChange(this.getValue()));
}
registerOnChange(propagateChange: (value: { argumentName: string; value: unknown }) => void): void {
this.propagateChange = propagateChange;
}
registerOnTouched(_): void {
}
writeValue(argumentsObj: Record<string, unknown>): void {
this.argumentsFormArray.clear();
Object.keys(argumentsObj).forEach(key => {
this.argumentsFormArray.push(this.fb.group({
argumentName: [{ value: key, disabled: true}],
value: [argumentsObj[key]]
}) as FormGroup, {emitEvent: false});
});
}
validate(): ValidationErrors | null {
return this.argumentsFormArray.valid ? null : { arguments: { valid: false } };
}
private getValue(): { argumentName: string; value: unknown } {
return this.argumentsFormArray.getRawValue().reduce((acc, rowItem) => {
const { argumentName, value } = rowItem;
acc[argumentName] = value;
return acc;
}, {}) as { argumentName: string; value: unknown };
}
}

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

@ -0,0 +1,99 @@
<!--
Copyright © 2016-2024 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.
-->
<form class="test-dialog-container size-full" [formGroup]="calculatedFieldScriptTestFormGroup">
<mat-toolbar class="flex justify-between" color="primary">
<h2>{{ 'calculated-fields.test-script-function' | translate }} ({{ 'api-usage.tbel' | translate }})</h2>
<button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<div mat-dialog-content class="relative">
<div class="tb-absolute-fill">
<div class="tb-fullscreen-panel flex size-full flex-row">
<div #leftPanel class="test-block-content overflow-hidden">
<div class="relative size-full min-w-64">
<div class="block-label-container left">
<span class="block-label">{{ 'calculated-fields.expression' | translate }}</span>
</div>
<tb-js-func
#expressionContent
formControlName="expression"
functionName="calculate"
[functionArgs]="functionArgs"
[disableUndefinedCheck]="true"
[fillHeight]="true"
[scriptLanguage]="ScriptLanguage.TBEL"
resultType="object"
helpId="calculated-field/test-expression_fn"
/>
</div>
</div>
<div #rightPanel>
<div #topRightPanel class="test-block-content">
<div class="relative flex size-full min-w-96 gap-2">
<div class="block-label-container right-top">
<span class="block-label">{{ 'calculated-fields.arguments' | translate }}</span>
</div>
<tb-calculated-field-test-arguments class="size-full" formControlName="arguments"/>
</div>
</div>
<div #bottomRightPanel class="test-block-content">
<div class="relative size-full">
<div class="block-label-container right-bottom">
<span class="block-label" translate>common.output</span>
</div>
<tb-json-content
class="flex-1"
formControlName="output"
label="{{ 'common.output' | translate }}"
[contentType]="ContentType.JSON"
validateContent="false"
readonly="true"
[fillHeight]="true"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div mat-dialog-actions class="flex flex-row">
<button mat-button mat-raised-button color="primary"
type="button"
(click)="onTestScript()"
[disabled]="(isLoading$ | async) || calculatedFieldScriptTestFormGroup.invalid">
{{ 'action.test' | translate }}
</button>
<span class="flex-1"></span>
<button mat-button color="primary"
type="button"
cdkFocusInitial
[disabled]="(isLoading$ | async)"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-button mat-raised-button color="primary"
type="submit"
(click)="save()"
[disabled]="(isLoading$ | async) || calculatedFieldScriptTestFormGroup.get('expression').invalid || !calculatedFieldScriptTestFormGroup.get('expression').dirty">
{{ 'action.save' | translate }}
</button>
</div>
</form>

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

@ -0,0 +1,73 @@
/**
* Copyright © 2016-2024 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
:host {
.test-dialog-container {
.block-label {
padding: 4px;
color: #00acc1;
background: rgba(220, 220, 220, .35);
border-radius: 5px;
}
.test-block-content {
padding-top: 5px;
padding-left: 5px;
border: 1px solid #c0c0c0;
}
.block-label-container {
position: absolute;
z-index: 10;
font-size: 12px;
font-weight: bold;
&.left {
right: 112px;
top: 9px;
}
&.right-bottom {
right: 40px;
top: 6px;
}
&.right-top {
right: 8px;
top: 2px;
}
}
}
}
:host::ng-deep {
.test-dialog-container {
.gutter {
background-color: #eee;
background-repeat: no-repeat;
background-position: 50%;
}
.gutter.gutter-horizontal {
cursor: col-resize;
background-image: url("../../../../../../../assets/split.js/grips/horizontal.png");
}
.gutter.gutter-vertical {
cursor: row-resize;
background-image: url("../../../../../../../assets/split.js/grips/vertical.png");
}
}
}

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

@ -0,0 +1,157 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import {
AfterViewInit,
Component,
DestroyRef,
ElementRef,
Inject,
ViewChild,
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { FormBuilder } from '@angular/forms';
import { NEVER, Observable, of, switchMap } from 'rxjs';
import { Router } from '@angular/router';
import { DialogComponent } from '@shared/components/dialog.component';
import { ContentType } from '@shared/models/constants';
import { JsonContentComponent } from '@shared/components/json-content.component';
import { ScriptLanguage } from '@shared/models/rule-node.models';
import { ActionNotificationShow } from '@core/notification/notification.actions';
import { beautifyJs } from '@shared/models/beautify.models';
import { CalculatedFieldsService } from '@core/http/calculated-fields.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { filter } from 'rxjs/operators';
import { CalculatedFieldTestScriptDialogData } from '@shared/models/calculated-field.models';
@Component({
selector: 'tb-calculated-field-script-test-dialog',
templateUrl: './calculated-field-script-test-dialog.component.html',
styleUrls: ['./calculated-field-script-test-dialog.component.scss'],
})
export class CalculatedFieldScriptTestDialogComponent extends DialogComponent<CalculatedFieldScriptTestDialogComponent,
string> implements AfterViewInit {
@ViewChild('leftPanel', {static: true}) leftPanelElmRef: ElementRef<HTMLElement>;
@ViewChild('rightPanel', {static: true}) rightPanelElmRef: ElementRef<HTMLElement>;
@ViewChild('topRightPanel', {static: true}) topRightPanelElmRef: ElementRef<HTMLElement>;
@ViewChild('bottomRightPanel', {static: true}) bottomRightPanelElmRef: ElementRef<HTMLElement>;
@ViewChild('expressionContent', {static: true}) expressionContent: JsonContentComponent;
calculatedFieldScriptTestFormGroup = this.fb.group({
expression: [],
arguments: [],
output: []
});
readonly ContentType = ContentType;
readonly ScriptLanguage = ScriptLanguage;
readonly functionArgs = Object.keys(this.data.arguments);
constructor(protected store: Store<AppState>,
protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: CalculatedFieldTestScriptDialogData,
protected dialogRef: MatDialogRef<CalculatedFieldScriptTestDialogComponent, string>,
private dialog: MatDialog,
private fb: FormBuilder,
private destroyRef: DestroyRef,
private calculatedFieldService: CalculatedFieldsService) {
super(store, router, dialogRef);
beautifyJs(this.data.expression, {indent_size: 4}).pipe(filter(Boolean), takeUntilDestroyed()).subscribe(
(res) => this.calculatedFieldScriptTestFormGroup.get('expression').patchValue(res, {emitEvent: false})
);
this.calculatedFieldScriptTestFormGroup.get('arguments').patchValue(this.data.arguments, {emitEvent: false});
}
ngAfterViewInit(): void {
this.initSplitLayout(
this.leftPanelElmRef.nativeElement,
this.rightPanelElmRef.nativeElement,
this.topRightPanelElmRef.nativeElement,
this.bottomRightPanelElmRef.nativeElement
);
}
cancel(): void {
this.dialogRef.close(null);
}
onTestScript(): void {
this.testScript()
.pipe(
switchMap(output => beautifyJs(output, {indent_size: 4})),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(output => this.calculatedFieldScriptTestFormGroup.get('output').setValue(output));
}
save(): void {
this.testScript(true).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.calculatedFieldScriptTestFormGroup.get('expression').markAsPristine();
this.dialogRef.close(this.calculatedFieldScriptTestFormGroup.get('expression').value);
});
}
private testScript(onSave = false): Observable<string> {
if (this.checkInputParamErrors()) {
return this.calculatedFieldService.testScript({
expression: this.calculatedFieldScriptTestFormGroup.get('expression').value,
arguments: this.calculatedFieldScriptTestFormGroup.get('arguments').value
}).pipe(
switchMap(result => {
if (result.error) {
this.store.dispatch(new ActionNotificationShow(
{
message: result.error,
type: 'error'
}));
return NEVER;
} else {
if (onSave && this.data.openCalculatedFieldEdit) {
this.dialog.closeAll();
}
return of(result.output);
}
}),
);
} else {
return NEVER;
}
}
private checkInputParamErrors(): boolean {
this.expressionContent.validateOnSubmit();
return !this.calculatedFieldScriptTestFormGroup.get('expression').invalid;
}
private initSplitLayout(leftPanel, rightPanel, topRightPanel, bottomRightPanel): void {
Split([leftPanel, rightPanel], {
sizes: [50, 50],
gutterSize: 8,
cursor: 'col-resize'
});
Split([topRightPanel, bottomRightPanel], {
sizes: [50, 50],
gutterSize: 8,
cursor: 'row-resize',
direction: 'vertical'
});
}
}

2
ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.html

@ -54,7 +54,7 @@
<button mat-button
color="primary"
type="button"
(click)="additionalActionConfig.action()">
(click)="onCancel(); additionalActionConfig.action()">
{{ additionalActionConfig.title | translate }}
</button>
}

14
ui-ngx/src/app/modules/home/components/event/event-table-config.ts

@ -357,10 +357,10 @@ export class EventTableConfig extends EntityTableConfig<Event, TimePageLink> {
break;
case DebugEventType.DEBUG_CALCULATED_FIELD:
this.columns[0].width = '80px';
this.columns[1].width = '20%';
this.columns[1].width = '100px';
this.columns.push(
new EntityTableColumn<Event>('entityId', 'event.entity-id', '85px',
(entity) => `<span style="display: inline-block; width: 7ch">${entity.body.entityId.substring(0, 6)}…</span>`,
(entity) => `<span style="display: inline-block; width: 9ch">${entity.body.entityId.substring(0, 8)}…</span>`,
() => ({padding: '0 12px 0 0'}),
false,
() => ({padding: '0 12px 0 0'}),
@ -380,7 +380,7 @@ export class EventTableConfig extends EntityTableConfig<Event, TimePageLink> {
}
),
new EntityTableColumn<Event>('messageId', 'event.message-id', '85px',
(entity) => `<span style="display: inline-block; width: 7ch">${entity.body.msgId?.substring(0, 6)}…</span>`,
(entity) => `<span style="display: inline-block; width: 9ch">${entity.body.msgId?.substring(0, 8)}…</span>`,
() => ({padding: '0 12px 0 0'}),
false,
() => ({padding: '0 12px 0 0'}),
@ -457,6 +457,14 @@ export class EventTableConfig extends EntityTableConfig<Event, TimePageLink> {
});
}
break;
case DebugEventType.DEBUG_CALCULATED_FIELD:
this.cellActionDescriptors.push({
name: this.translate.instant('common.test-with-this-message', {test: this.translate.instant(this.testButtonLabel)}),
icon: 'bug_report',
isEnabled: () => true,
onAction: (_, entity) => this.debugEventSelected.next(entity.body)
});
break;
}
this.getTable()?.cellActionDescriptorsUpdated();
}

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

@ -198,6 +198,12 @@ import {
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';
@NgModule({
declarations:
@ -347,6 +353,8 @@ import {
CalculatedFieldArgumentsTableComponent,
CalculatedFieldArgumentPanelComponent,
CalculatedFieldDebugDialogComponent,
CalculatedFieldScriptTestDialogComponent,
CalculatedFieldTestArgumentsComponent,
],
imports: [
CommonModule,
@ -490,6 +498,8 @@ import {
CalculatedFieldArgumentsTableComponent,
CalculatedFieldArgumentPanelComponent,
CalculatedFieldDebugDialogComponent,
CalculatedFieldScriptTestDialogComponent,
CalculatedFieldTestArgumentsComponent,
],
providers: [
WidgetComponentService,

8
ui-ngx/src/app/shared/components/value-input.component.html

@ -32,7 +32,7 @@
</mat-select>
</mat-form-field>
<mat-form-field *ngIf="valueType === valueTypeEnum.STRING" appearance="outline" subscriptSizing="dynamic" class="tb-inline-field tb-suffix-absolute flex flex-1">
<input [disabled]="disabled" matInput required name="value" #value="ngModel" [(ngModel)]="modelValue" (ngModelChange)="onValueChanged()"
<input [disabled]="disabled" matInput [required]="required" name="value" #value="ngModel" [(ngModel)]="modelValue" (ngModelChange)="onValueChanged()"
placeholder="{{ 'value.string-value' | translate }}*"/>
<mat-icon matSuffix
matTooltipPosition="above"
@ -44,7 +44,7 @@
</mat-icon>
</mat-form-field>
<mat-form-field *ngIf="valueType === valueTypeEnum.INTEGER" appearance="outline" subscriptSizing="dynamic" class="tb-inline-field tb-suffix-absolute number flex flex-1">
<input [disabled]="disabled" matInput required name="value" type="number" step="1" pattern="^-?[0-9]+$" #value="ngModel" [(ngModel)]="modelValue" (ngModelChange)="onValueChanged()"
<input [disabled]="disabled" matInput [required]="required" name="value" type="number" step="1" pattern="^-?[0-9]+$" #value="ngModel" [(ngModel)]="modelValue" (ngModelChange)="onValueChanged()"
placeholder="{{ 'value.integer-value' | translate }}*"/>
<mat-icon matSuffix
matTooltipPosition="above"
@ -57,7 +57,7 @@
</mat-icon>
</mat-form-field>
<mat-form-field *ngIf="valueType === valueTypeEnum.DOUBLE" appearance="outline" subscriptSizing="dynamic" class="tb-inline-field tb-suffix-absolute number flex flex-1">
<input [disabled]="disabled" matInput required name="value" type="number" step="any" #value="ngModel" [(ngModel)]="modelValue" (ngModelChange)="onValueChanged()"
<input [disabled]="disabled" matInput [required]="required" name="value" type="number" step="any" #value="ngModel" [(ngModel)]="modelValue" (ngModelChange)="onValueChanged()"
placeholder="{{ 'value.double-value' | translate }}*"/>
<mat-icon matSuffix
matTooltipPosition="above"
@ -80,7 +80,7 @@
</ng-container>
<div *ngIf="valueType === valueTypeEnum.JSON" class="flex flex-1 flex-row items-center justify-start gap-2">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="tb-inline-field tb-suffix-absolute flex flex-1">
<input [disabled]="disabled" matInput tb-json-to-string required name="value" #value="ngModel"
<input [disabled]="disabled" matInput tb-json-to-string [required]="required" name="value" #value="ngModel"
[(ngModel)]="modelValue" (ngModelChange)="onValueChanged()" placeholder="{{ 'value.json-value' | translate }}*"/>
<mat-icon matSuffix
matTooltipPosition="above"

4
ui-ngx/src/app/shared/components/value-input.component.ts

@ -81,6 +81,10 @@ export class ValueInputComponent implements OnInit, OnDestroy, OnChanges, Contro
@coerceBoolean()
shortBooleanField = false;
@Input()
@coerceBoolean()
required = true;
@Input()
layout: ValueInputLayout | Layout = 'row';

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

@ -26,6 +26,7 @@ import { EntityId } from '@shared/models/id/entity-id';
import { AttributeScope } from '@shared/models/telemetry/telemetry.models';
import { EntityType } from '@shared/models/entity-type.models';
import { AliasFilterType } from '@shared/models/alias.models';
import { Observable } from 'rxjs';
export interface CalculatedField extends Omit<BaseData<CalculatedFieldId>, 'label'>, HasVersion, HasTenantId, ExportableEntity<CalculatedFieldId> {
debugSettings?: EntityDebugSettings;
@ -126,6 +127,8 @@ export interface CalculatedFieldArgumentValue extends CalculatedFieldArgument {
argumentName: string;
}
export type CalculatedFieldTestScriptFn = (calculatedField: CalculatedField, argumentsObj?: Record<string, unknown>, closeAllOnSave?: boolean) => Observable<string>;
export interface CalculatedFieldDialogData {
value?: CalculatedField;
buttonTitle: string;
@ -133,13 +136,24 @@ export interface CalculatedFieldDialogData {
debugLimitsConfiguration: string;
tenantId: string;
entityName?: string;
additionalDebugActionConfig: AdditionalDebugActionConfig;
additionalDebugActionConfig: AdditionalDebugActionConfig<(calculatedField: CalculatedField) => void>;
getTestScriptDialogFn: CalculatedFieldTestScriptFn;
isDirty?: boolean;
}
export interface CalculatedFieldDebugDialogData {
id?: CalculatedFieldId;
entityId: EntityId;
tenantId: string;
value: CalculatedField;
getTestScriptDialogFn: CalculatedFieldTestScriptFn;
}
export interface CalculatedFieldTestScriptInputParams {
arguments: Record<string, unknown>,
expression: string;
}
export interface CalculatedFieldTestScriptDialogData extends CalculatedFieldTestScriptInputParams {
openCalculatedFieldEdit?: boolean;
}
export interface ArgumentEntityTypeParams {

9
ui-ngx/src/app/shared/models/entity.models.ts

@ -203,8 +203,13 @@ export interface EntityDebugSettings {
allEnabledUntil?: number;
}
export interface AdditionalDebugActionConfig {
action?: (id?: EntityId) => void;
export interface EntityTestScriptResult {
output: string;
error: string;
}
export interface AdditionalDebugActionConfig<Action = (...args: unknown[]) => void> {
action: Action;
title: string;
}

7
ui-ngx/src/app/shared/models/rule-node.models.ts

@ -27,7 +27,7 @@ import { AppState } from '@core/core.state';
import { AbstractControl, UntypedFormGroup } from '@angular/forms';
import { RuleChainType } from '@shared/models/rule-chain.models';
import { DebugRuleNodeEventBody } from '@shared/models/event.models';
import { HasEntityDebugSettings } from '@shared/models/entity.models';
import { EntityTestScriptResult, HasEntityDebugSettings } from '@shared/models/entity.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
export interface RuleNodeConfiguration {
@ -374,10 +374,7 @@ export interface TestScriptInputParams {
msgType: string;
}
export interface TestScriptResult {
output: string;
error: string;
}
export type TestScriptResult = EntityTestScriptResult;
export enum MessageType {
POST_ATTRIBUTES_REQUEST = 'POST_ATTRIBUTES_REQUEST',

1
ui-ngx/src/assets/help/en_US/calculated-field/expression_fn.md

@ -0,0 +1 @@
<!-- [TODO]: [Calculated Fields] add content -->

1
ui-ngx/src/assets/help/en_US/calculated-field/test-expression_fn.md

@ -0,0 +1 @@
<!-- [TODO]: [Calculated Fields] add content -->

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

@ -65,6 +65,7 @@
"next-with-label": "Next: {{label}}",
"read-more": "Read more",
"hide": "Hide",
"test": "Test",
"done": "Done",
"print": "Print",
"restore": "Restore",
@ -1018,6 +1019,7 @@
"argument-name": "Argument name",
"datasource": "Datasource",
"add-argument": "Add argument",
"test-script-function": "Test script function",
"no-arguments": "No arguments configured",
"argument-settings": "Argument settings",
"argument-current": "Current entity",
@ -1107,8 +1109,12 @@
"proceed": "Proceed",
"open-details-page": "Open details page",
"not-found": "Not found",
"value": "Value",
"documentation": "Documentation",
"time-left": "{{time}} left",
"output": "Output",
"test-function": "Test function",
"test-with-this-message": "{{test}} with this message",
"suffix": {
"s": "s",
"ms": "ms"

Loading…
Cancel
Save