25 changed files with 622 additions and 55 deletions
@ -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> |
||||
@ -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 }; |
||||
|
} |
||||
|
} |
||||
@ -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> |
||||
@ -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"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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' |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1 @@ |
|||||
|
<!-- [TODO]: [Calculated Fields] add content --> |
||||
@ -0,0 +1 @@ |
|||||
|
<!-- [TODO]: [Calculated Fields] add content --> |
||||
Loading…
Reference in new issue