Calculated field add/edit basic implementation

This commit is contained in:
mpetrov 2025-01-31 11:25:22 +02:00
parent 7c11848e3d
commit fd42c51df1
24 changed files with 1577 additions and 122 deletions

View File

@ -16,7 +16,7 @@
import { Injectable } from '@angular/core';
import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils';
import { Observable, of } from 'rxjs';
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';
@ -25,64 +25,26 @@ import { PageLink } from '@shared/models/page/page-link';
@Injectable({
providedIn: 'root'
})
// [TODO]: [Calculated fields] - implement when BE ready
export class CalculatedFieldsService {
fieldsMock = [
{
name: 'Calculated Field 1',
type: 'Simple',
configuration: {
expression: '1 + 2',
type: 'SIMPLE',
},
entityId: '1',
id: {
id: '1',
}
},
{
name: 'Calculated Field 2',
type: 'Script',
entityId: '2',
configuration: {
expression: '${power}',
type: 'SIMPLE',
},
id: {
id: '2',
}
}
] as any[];
constructor(
private http: HttpClient
) { }
public getCalculatedField(calculatedFieldId: string, config?: RequestConfig): Observable<CalculatedField> {
return of(this.fieldsMock[0]);
// return this.http.get<any>(`/api/calculatedField/${calculatedFieldId}`, defaultHttpOptionsFromConfig(config));
public getCalculatedFieldById(calculatedFieldId: string, config?: RequestConfig): Observable<CalculatedField> {
return this.http.get<any>(`/api/calculatedField/${calculatedFieldId}`, defaultHttpOptionsFromConfig(config));
}
public saveCalculatedField(calculatedField: any, config?: RequestConfig): Observable<CalculatedField> {
return of(this.fieldsMock[1]);
// return this.http.post<any>('/api/calculatedField', calculatedField, defaultHttpOptionsFromConfig(config));
public saveCalculatedField(calculatedField: CalculatedField, config?: RequestConfig): Observable<CalculatedField> {
return this.http.post<any>('/api/calculatedField', calculatedField, defaultHttpOptionsFromConfig(config));
}
public deleteCalculatedField(calculatedFieldId: string, config?: RequestConfig): Observable<boolean> {
return of(true);
// return this.http.delete<boolean>(`/api/calculatedField/${calculatedFieldId}`, defaultHttpOptionsFromConfig(config));
return this.http.delete<boolean>(`/api/calculatedField/${calculatedFieldId}`, defaultHttpOptionsFromConfig(config));
}
public getCalculatedFields(pageLink: PageLink,
config?: RequestConfig): Observable<PageData<CalculatedField>> {
return of({
data: this.fieldsMock,
totalPages: 1,
totalElements: 2,
hasNext: false,
});
// return this.http.get<PageData<any>>(`/api/calculatedField${pageLink.toQuery()}`,
// defaultHttpOptionsFromConfig(config));
public getCalculatedFields(pageLink: PageLink, config?: RequestConfig): Observable<PageData<CalculatedField>> {
return this.http.get<PageData<any>>(`/api/calculatedFields${pageLink.toQuery()}`,
defaultHttpOptionsFromConfig(config));
}
}

View File

@ -23,41 +23,33 @@ import { TimePageLink } from '@shared/models/page/page-link';
import { Observable, of } from 'rxjs';
import { PageData } from '@shared/models/page/page-data';
import { EntityId } from '@shared/models/id/entity-id';
import { DialogService } from '@core/services/dialog.service';
import { MINUTE } from '@shared/models/time/time.models';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { getCurrentAuthState } from '@core/auth/auth.selectors';
import { ChangeDetectorRef, DestroyRef, ViewContainerRef } from '@angular/core';
import { Overlay } from '@angular/cdk/overlay';
import { UtilsService } from '@core/services/utils.service';
import { EntityService } from '@core/http/entity.service';
import { getCurrentAuthState, getCurrentAuthUser } from '@core/auth/auth.selectors';
import { DestroyRef } from '@angular/core';
import { EntityDebugSettings } from '@shared/models/entity.models';
import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe';
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, switchMap } from 'rxjs/operators';
import { catchError, filter, switchMap } from 'rxjs/operators';
import { CalculatedField } from '@shared/models/calculated-field.models';
import { CalculatedFieldDialogComponent } from './components/public-api';
export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedField, TimePageLink> {
readonly calculatedFieldsDebugPerTenantLimitsConfiguration =
getCurrentAuthState(this.store)['calculatedFieldsDebugPerTenantLimitsConfiguration'] || '1:1';
readonly maxDebugModeDuration = getCurrentAuthState(this.store).maxDebugModeDurationMinutes * MINUTE;
readonly tenantId = getCurrentAuthUser(this.store).tenantId;
constructor(private calculatedFieldsService: CalculatedFieldsService,
private entityService: EntityService,
private dialogService: DialogService,
private translate: TranslateService,
private dialog: MatDialog,
public entityId: EntityId = null,
private store: Store<AppState>,
private viewContainerRef: ViewContainerRef,
private overlay: Overlay,
private cd: ChangeDetectorRef,
private utilsService: UtilsService,
private durationLeft: DurationLeftPipe,
private popoverService: TbPopoverService,
private destroyRef: DestroyRef,
@ -67,6 +59,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
this.detailsPanelEnabled = false;
this.selectionEnabled = true;
this.searchEnabled = true;
this.pageMode = false;
this.addEnabled = true;
this.entitiesDeleteEnabled = true;
this.actionsColumnTitle = '';
@ -74,6 +67,12 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
this.entityTranslations = entityTypeTranslations.get(EntityType.CALCULATED_FIELD);
this.entitiesFetchFunction = pageLink => this.fetchCalculatedFields(pageLink);
this.addEntity = this.addCalculatedField.bind(this);
this.deleteEntityTitle = (field) => this.translate.instant('calculated-fields.delete-title', {title: field.name});
this.deleteEntityContent = () => this.translate.instant('calculated-fields.delete-text');
this.deleteEntitiesTitle = count => this.translate.instant('calculated-fields.delete-multiple-title', {count});
this.deleteEntitiesContent = () => this.translate.instant('calculated-fields.delete-multiple-text');
this.deleteEntity = id => this.calculatedFieldsService.deleteCalculatedField(id.id);
this.defaultSortOrder = {property: 'name', direction: Direction.DESC};
@ -97,8 +96,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
name: this.translate.instant('action.edit'),
icon: 'edit',
isEnabled: () => true,
// // [TODO]: [Calculated fields] - implement edit
onAction: (_, entity) => {}
onAction: (_, entity) => this.editCalculatedField(entity)
}
);
}
@ -121,7 +119,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
{
debugLimitsConfiguration: this.calculatedFieldsDebugPerTenantLimitsConfiguration,
maxDebugModeDuration: this.maxDebugModeDuration,
entityLabel: this.translate.instant('debug-settings.integration'),
entityLabel: this.translate.instant('debug-settings.calculated-field'),
...debugSettings
},
{},
@ -134,6 +132,48 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
}
}
private addCalculatedField(): void {
this.getCalculatedFieldDialog()
.afterClosed()
.pipe(
filter(Boolean),
switchMap(calculatedField => this.calculatedFieldsService.saveCalculatedField({ entityId: this.entityId, ...calculatedField} as any)),
)
.subscribe((res) => {
if (res) {
this.updateData();
}
});
}
private editCalculatedField(calculatedField: CalculatedField): void {
this.getCalculatedFieldDialog(calculatedField, 'action.apply')
.afterClosed()
.pipe(
filter(Boolean),
switchMap((updatedCalculatedField) => this.calculatedFieldsService.saveCalculatedField({ ...calculatedField, ...updatedCalculatedField} as any)),
)
.subscribe((res) => {
if (res) {
this.updateData();
}
});
}
private getCalculatedFieldDialog(value = {}, buttonTitle = 'action.add') {
return this.dialog.open<CalculatedFieldDialogComponent, any, CalculatedField>(CalculatedFieldDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
value,
buttonTitle,
entityId: this.entityId,
debugLimitsConfiguration: this.calculatedFieldsDebugPerTenantLimitsConfiguration,
tenantId: this.tenantId,
}
})
}
private getDebugConfigLabel(debugSettings: EntityDebugSettings): string {
const isDebugActive = this.isDebugActive(debugSettings?.allEnabledUntil);
@ -149,7 +189,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
}
private onDebugConfigChanged(id: string, debugSettings: EntityDebugSettings): void {
this.calculatedFieldsService.getCalculatedField(id).pipe(
this.calculatedFieldsService.getCalculatedFieldById(id).pipe(
switchMap(field => this.calculatedFieldsService.saveCalculatedField({ ...field, debugSettings })),
catchError(() => of(null)),
takeUntilDestroyed(this.destroyRef),

View File

@ -16,24 +16,18 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
Input,
OnInit,
ViewChild,
ViewContainerRef,
} from '@angular/core';
import { EntityId } from '@shared/models/id/entity-id';
import { EntitiesTableComponent } from '@home/components/entity/entities-table.component';
import { EntityService } from '@core/http/entity.service';
import { DialogService } from '@core/services/dialog.service';
import { TranslateService } from '@ngx-translate/core';
import { MatDialog } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { Overlay } from '@angular/cdk/overlay';
import { UtilsService } from '@core/services/utils.service';
import { CalculatedFieldsTableConfig } from '@home/components/calculated-fields/calculated-fields-table-config';
import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe';
import { TbPopoverService } from '@shared/components/popover.service';
@ -51,7 +45,6 @@ export class CalculatedFieldsTableComponent implements OnInit {
set entityId(entityId: EntityId) {
if (this.entityIdValue !== entityId) {
this.entityIdValue = entityId;
this.entitiesTable.resetSortAndFilter(this.activeValue);
if (!this.activeValue) {
this.hasInitialized = true;
}
@ -78,18 +71,12 @@ export class CalculatedFieldsTableComponent implements OnInit {
private entityIdValue: EntityId;
constructor(private calculatedFieldsService: CalculatedFieldsService,
private entityService: EntityService,
private dialogService: DialogService,
private translate: TranslateService,
private dialog: MatDialog,
private store: Store<AppState>,
private overlay: Overlay,
private viewContainerRef: ViewContainerRef,
private cd: ChangeDetectorRef,
private durationLeft: DurationLeftPipe,
private popoverService: TbPopoverService,
private destroyRef: DestroyRef,
private utilsService: UtilsService) {
private destroyRef: DestroyRef) {
}
ngOnInit() {
@ -97,19 +84,13 @@ export class CalculatedFieldsTableComponent implements OnInit {
this.calculatedFieldsTableConfig = new CalculatedFieldsTableConfig(
this.calculatedFieldsService,
this.entityService,
this.dialogService,
this.translate,
this.dialog,
this.entityIdValue,
this.store,
this.viewContainerRef,
this.overlay,
this.cd,
this.utilsService,
this.durationLeft,
this.popoverService,
this.destroyRef
this.destroyRef,
);
}
}

View File

@ -0,0 +1,106 @@
<!--
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="tb-form-table mb-3">
<div class="tb-form-table-header">
<div class="tb-form-table-header-cell w-[17%]">{{ 'calculated-fields.argument-name' | translate }}</div>
<div class="tb-form-table-header-cell w-[35%]">{{ 'calculated-fields.datasource' | translate }}</div>
<div class="tb-form-table-header-cell w-[17%]">{{ 'common.type' | translate }}</div>
<div class="tb-form-table-header-cell w-[31%]">{{ 'entity.key' | translate }}</div>
</div>
<div class="tb-form-table-body tb-drop-list">
@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">
<input matInput formControlName="argumentName" placeholder="{{ 'action.set' | translate }}">
</mat-form-field>
@if (group.get('refEntityId')?.get('id').value) {
<ng-container [formGroup]="group.get('refEntityId')">
<mat-form-field appearance="outline" class="tb-inline-field w-1/6" subscriptSizing="dynamic">
<mat-select [value]="group.get('refEntityId').get('entityType').value" formControlName="entityType">
<mat-option [value]="group.get('refEntityId').get('entityType').value">
{{ entityTypeTranslations.get(group.get('refEntityId').get('entityType').value)?.type | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<tb-entity-autocomplete
class="inline-entity-autocomplete w-1/6"
formControlName="id"
[withLabel]="false"
[placeholder]="'action.set' | translate"
[appearance]="'outline'"
[subscriptSizing]="'dynamic'"
[entityType]="group.get('refEntityId').get('entityType').value"/>
</ng-container>
} @else {
<mat-form-field appearance="outline" class="tb-inline-field w-[calc(33.3%+12px)]" subscriptSizing="dynamic">
<mat-select [value]="'current'" [disabled]="true">
<mat-option [value]="'current'">
{{ (group.get('refEntityId')?.get('entityType').value === ArgumentEntityType.Tenant
? 'calculated-fields.argument-current-tenant'
: 'calculated-fields.argument-current') | translate }}
</mat-option>
</mat-select>
</mat-form-field>
}
<ng-container [formGroup]="group.get('refEntityKey')">
<mat-form-field appearance="outline" class="tb-inline-field w-1/6" subscriptSizing="dynamic">
<mat-select [value]="group.get('refEntityKey').get('type').value" formControlName="type">
<mat-option [value]="group.get('refEntityKey').get('type').value">
{{ ArgumentTypeTranslations.get(group.get('refEntityKey').get('type').value) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-chip-listbox formControlName="key" class="tb-inline-field w-1/6">
<mat-chip>
<div tbTruncateWithTooltip class="max-w-25">
{{group.get('refEntityKey').get('key').value}}
</div>
</mat-chip>
</mat-chip-listbox>
</ng-container>
<div class="flex opacity-55">
<button type="button"
mat-icon-button
#button
(click)="manageArgument($event, button, $index)"
[matTooltip]="'action.edit' | translate"
matTooltipPosition="above">
<mat-icon>edit</mat-icon>
</button>
<button type="button"
mat-icon-button
(click)="onDelete($index)"
[matTooltip]="'action.delete' | translate"
matTooltipPosition="above">
<mat-icon>delete</mat-icon>
</button>
</div>
</div>
} @empty {
<span class="tb-prompt flex items-center justify-center">{{ 'calculated-fields.no-arguments' | translate }}</span>
}
</div>
@if (errorText) {
<tb-error noMargin [error]="errorText | translate" class="pl-3"/>
}
</div>
<div>
<button type="button" mat-stroked-button color="primary" #button (click)="manageArgument($event, button)">
{{ 'calculated-fields.add-argument' | translate }}
</button>
</div>

View File

@ -0,0 +1,32 @@
/**
* 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 ::ng-deep {
.inline-entity-autocomplete {
.mat-mdc-form-field-infix {
padding-top: 8px;
padding-bottom: 8px;
min-height: 40px;
width: auto;
.mdc-text-field__input, .mat-mdc-select {
font-weight: 400;
line-height: 20px;
}
}
a {
font-size: 14px;
}
}
}

View File

@ -0,0 +1,213 @@
///
/// 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 {
ChangeDetectorRef,
Component,
DestroyRef,
effect,
forwardRef,
input,
Input,
Renderer2,
ViewContainerRef,
} from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
FormBuilder,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
Validators
} from '@angular/forms';
import {
ArgumentEntityType,
ArgumentType,
ArgumentTypeTranslations,
CalculatedFieldArgument,
CalculatedFieldArgumentValue,
CalculatedFieldType,
} from '@shared/models/calculated-field.models';
import {
CalculatedFieldArgumentPanelComponent
} from '@home/components/calculated-fields/components/panel/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 { isDefinedAndNotNull } from '@core/utils';
import { noLeadTrailSpacesRegex } from '@shared/models/regex.constants';
@Component({
selector: 'tb-calculated-field-arguments-table',
templateUrl: './calculated-field-arguments-table.component.html',
styleUrls: [`calculated-field-arguments-table.component.scss`],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CalculatedFieldArgumentsTableComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => CalculatedFieldArgumentsTableComponent),
multi: true
}
],
})
export class CalculatedFieldArgumentsTableComponent implements ControlValueAccessor, Validator {
@Input() entityId: EntityId;
@Input() tenantId: string;
calculatedFieldType = input<CalculatedFieldType>()
errorText = '';
argumentsFormArray = this.fb.array<AbstractControl>([]);
keysPopupClosed = true;
readonly entityTypeTranslations = entityTypeTranslations;
readonly ArgumentTypeTranslations = ArgumentTypeTranslations;
readonly EntityType = EntityType;
readonly ArgumentEntityType = ArgumentEntityType;
private onChange: (argumentsObj: Record<string, CalculatedFieldArgument>) => void = () => {};
constructor(
private fb: FormBuilder,
private popoverService: TbPopoverService,
private viewContainerRef: ViewContainerRef,
private destroyRef: DestroyRef,
private cd: ChangeDetectorRef,
private renderer: Renderer2
) {
this.argumentsFormArray.valueChanges.pipe(takeUntilDestroyed()).subscribe(() => {
this.onChange(this.getArgumentsObject());
});
effect(() => this.calculatedFieldType() && this.argumentsFormArray.updateValueAndValidity());
}
registerOnChange(fn: (argumentsObj: Record<string, CalculatedFieldArgument>) => void): void {
this.onChange = fn;
}
registerOnTouched(_): void {}
validate(): ValidationErrors | null {
if (this.calculatedFieldType() === CalculatedFieldType.SIMPLE
&& this.argumentsFormArray.controls.some(control => control.get('refEntityKey').get('type').value === ArgumentType.Rolling)) {
this.errorText = 'calculated-fields.hint.arguments-simple-with-rolling';
} else if (!this.argumentsFormArray.controls.length) {
this.errorText = 'calculated-fields.hint.arguments-empty';
} else {
this.errorText = '';
}
return this.errorText ? { argumentsFormArray: false } : null;
}
private getArgumentsObject(): Record<string, CalculatedFieldArgument> {
return this.argumentsFormArray.controls.reduce((acc, control) => {
const rawValue = control.getRawValue();
const { argumentName, ...argument } = rawValue as CalculatedFieldArgumentValue;
acc[argumentName] = argument;
return acc;
}, {} as Record<string, CalculatedFieldArgument>);
}
writeValue(argumentsObj: Record<string, CalculatedFieldArgument>): void {
this.argumentsFormArray.clear();
Object.keys(argumentsObj).forEach(key => {
this.argumentsFormArray.push(this.fb.group({
argumentName: [key, [Validators.required, Validators.maxLength(255), Validators.pattern(noLeadTrailSpacesRegex)]],
...argumentsObj[key],
...(argumentsObj[key].refEntityId ? {
refEntityId: this.fb.group({
entityType: [{ value: argumentsObj[key].refEntityId.entityType, disabled: true }],
id: [{ value: argumentsObj[key].refEntityId.id , disabled: true }],
}),
} : {}),
refEntityKey: this.fb.group({
type: [{ value: argumentsObj[key].refEntityKey.type, disabled: true }],
key: [{ value: argumentsObj[key].refEntityKey.key, disabled: true }],
}),
}) as AbstractControl);
});
}
manageArgument($event: Event, matButton: MatButton, index?: number): void {
$event?.stopPropagation();
const trigger = matButton._elementRef.nativeElement;
if (this.popoverService.hasPopover(trigger)) {
this.popoverService.hidePopover(trigger);
} else {
const ctx = {
index,
argument: this.argumentsFormArray.at(index)?.getRawValue() ?? {},
entityId: this.entityId,
calculatedFieldType: this.calculatedFieldType(),
buttonTitle: this.argumentsFormArray.at(index)?.value ? 'action.apply' : 'action.add',
tenantId: this.tenantId,
};
this.keysPopupClosed = false;
const argumentsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer,
this.viewContainerRef, CalculatedFieldArgumentPanelComponent, 'leftBottom', false, null,
ctx,
{},
{}, {}, true);
argumentsPanelPopover.tbComponentRef.instance.popover = argumentsPanelPopover;
argumentsPanelPopover.tbComponentRef.instance.argumentsDataApplied.subscribe(({ value, index }) => {
argumentsPanelPopover.hide();
const formGroup = this.getArgumentFormGroup(value);
if (isDefinedAndNotNull(index)) {
this.argumentsFormArray.setControl(index, formGroup);
} else {
this.argumentsFormArray.push(formGroup);
}
this.argumentsFormArray.markAsDirty();
this.cd.markForCheck();
});
argumentsPanelPopover.tbHideStart.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.keysPopupClosed = true;
});
}
}
getArgumentFormGroup(value: CalculatedFieldArgumentValue): AbstractControl {
return this.fb.group({
...value,
argumentName: [value.argumentName, [Validators.required, Validators.maxLength(255), Validators.pattern(noLeadTrailSpacesRegex)]],
...(value.refEntityId ? {
refEntityId: this.fb.group({
entityType: [{ value: value.refEntityId.entityType, disabled: true }],
id: [{ value: value.refEntityId.id , disabled: true }],
}),
} : {}),
refEntityKey: this.fb.group({
type: [{ value: value.refEntityKey.type, disabled: true }],
key: [{ value: value.refEntityKey.key, disabled: true }],
}),
})
}
onDelete(index: number): void {
this.argumentsFormArray.removeAt(index);
this.argumentsFormArray.markAsDirty();
}
}

View File

@ -0,0 +1,169 @@
<!--
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 [formGroup]="fieldFormGroup" class="h-full w-screen min-w-80 max-w-4xl">
<mat-toolbar color="primary">
<h2>{{ 'entity.type-calculated-field' | translate}}</h2>
<span class="flex-1"></span>
<div [tb-help]="helpLink"></div>
<button mat-icon-button
(click)="cancel()"
type="button">
<mat-icon class="material-icons">close</mat-icon>
</button>
</mat-toolbar>
<div mat-dialog-content>
<div class="tb-form-panel no-border no-padding">
<div class="tb-form-panel">
<div class="tb-form-panel-title">{{ 'common.general' | translate }}</div>
<div class="flex items-center gap-2">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'entity-field.title' | translate }}</mat-label>
<input matInput maxlength="255" formControlName="name" required>
@if (fieldFormGroup.get('name').errors && fieldFormGroup.get('name').touched) {
<mat-error>
@if (fieldFormGroup.get('name').hasError('required')) {
{{ 'common.hint.name-required' | translate }}
} @else if (fieldFormGroup.get('name').hasError('pattern')) {
{{ 'common.hint.name-pattern' | translate }}
} @else if (fieldFormGroup.get('name').hasError('maxlength')) {
{{ 'common.hint.name-max-length' | translate }}
}
</mat-error>
}
</mat-form-field>
<tb-entity-debug-settings-button
formControlName="debugSettings"
[entityLabel]="'debug-settings.calculated-field' | translate"
[debugLimitsConfiguration]="data.debugLimitsConfiguration"
/>
</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'common.type' | translate }}</mat-label>
<mat-select formControlName="type" required>
@for (type of fieldTypes; track type) {
<mat-option [value]="type">{{ CalculatedFieldTypeTranslations.get(type) | translate}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<ng-container [formGroup]="configFormGroup">
<div class="tb-form-panel">
<div class="tb-form-panel-title">{{ 'calculated-fields.arguments' | translate }}</div>
<tb-calculated-field-arguments-table
formControlName="arguments"
[entityId]="data.entityId"
[tenantId]="data.tenantId"
[calculatedFieldType]="fieldFormGroup.get('type').valueChanges | async"
/>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title">{{ 'calculated-fields.expression' | translate }}*</div>
@if (fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE) {
<mat-form-field class="mat-block" appearance="outline">
<input matInput formControlName="expressionSIMPLE" maxlength="255" [placeholder]="'action.set' | translate" required>
@if (configFormGroup.get('expressionSIMPLE').errors && configFormGroup.get('expressionSIMPLE').touched) {
<mat-error>
@if (configFormGroup.get('expressionSIMPLE').hasError('required')) {
{{ 'calculated-fields.hint.expression-required' | translate }}
} @else if (configFormGroup.get('expressionSIMPLE').hasError('pattern')) {
{{ 'calculated-fields.hint.expression-invalid' | translate }}
} @else if (configFormGroup.get('expressionSIMPLE').hasError('maxLength')) {
{{ 'calculated-fields.hint.expression-max-length' | translate }}
}
</mat-error>
}
</mat-form-field>
} @else {
<tb-js-func
required
formControlName="expressionSCRIPT"
functionName="calculate"
[functionArgs]="functionArgs$ | async"
[disableUndefinedCheck]="true"
[scriptLanguage]="ScriptLanguage.TBEL"
helpId="[TODO]: ADD VALID LINK HERE!!!"
/>
}
</div>
<div class="tb-form-panel" [formGroup]="outputFormGroup">
<div class="tb-form-panel-title">{{ 'calculated-fields.output' | translate }}</div>
<div class="flex items-center gap-2">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'calculated-fields.output-type' | translate }}</mat-label>
<mat-select formControlName="type" required>
@for (type of outputTypes; track type) {
<mat-option [value]="type">{{ OutputTypeTranslations.get(type) | translate}}</mat-option>
}
</mat-select>
</mat-form-field>
@if (outputFormGroup.get('type').value === OutputType.Attribute) {
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'calculated-fields.output-type' | translate }}</mat-label>
<mat-select formControlName="scope" class="w-full">
<mat-option [value]="AttributeScope.SERVER_SCOPE">
{{ 'calculated-fields.server-attributes' | translate }}
</mat-option>
@if (data.entityId.entityType === EntityType.DEVICE) {
<mat-option [value]="AttributeScope.SHARED_SCOPE">
{{ 'calculated-fields.shared-attributes' | translate }}
</mat-option>
}
</mat-select>
</mat-form-field>
}
</div>
@if (fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE) {
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>
{{ (outputFormGroup.get('type').value === OutputType.Timeseries
? 'calculated-fields.timeseries-key'
: 'calculated-fields.attribute-key')
| translate }}
</mat-label>
<input matInput formControlName="name" required>
@if (outputFormGroup.get('name').errors && outputFormGroup.get('name').touched) {
<mat-error>
@if (outputFormGroup.get('name').hasError('required')) {
{{ 'common.hint.key-required' | translate }}
} @else if (outputFormGroup.get('name').hasError('pattern')) {
{{ 'common.hint.key-pattern' | translate }}
} @else if (outputFormGroup.get('name').hasError('maxlength')) {
{{ 'common.hint.key-max-length' | translate }}
}
</mat-error>
}
</mat-form-field>
}
</div>
</ng-container>
</div>
</div>
<div mat-dialog-actions class="justify-end">
<button mat-button color="primary"
type="button"
cdkFocusInitial
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button color="primary"
(click)="add()"
[disabled]="fieldFormGroup.invalid || !fieldFormGroup.dirty">
{{ data.buttonTitle | translate }}
</button>
</div>
</div>

View File

@ -0,0 +1,122 @@
///
/// 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, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { FormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { DialogComponent } from '@shared/components/dialog.component';
import { helpBaseUrl } from '@shared/models/constants';
import {
CalculatedField,
CalculatedFieldConfiguration,
CalculatedFieldDialogData,
CalculatedFieldType,
CalculatedFieldTypeTranslations,
OutputType,
OutputTypeTranslations
} from '@shared/models/calculated-field.models';
import { noLeadTrailSpacesRegex } from '@shared/models/regex.constants';
import { AttributeScope } from '@shared/models/telemetry/telemetry.models';
import { EntityType } from '@shared/models/entity-type.models';
import { map } from 'rxjs/operators';
import { isObject } from '@core/utils';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ScriptLanguage } from '@shared/models/rule-node.models';
@Component({
selector: 'tb-calculated-field-dialog',
templateUrl: './calculated-field-dialog.component.html',
})
export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFieldDialogComponent, CalculatedField> {
fieldFormGroup = this.fb.group({
name: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex), Validators.maxLength(255)]],
type: [CalculatedFieldType.SIMPLE, [Validators.required]],
debugSettings: [],
configuration: this.fb.group({
arguments: [{}],
expressionSIMPLE: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex), Validators.maxLength(255)]],
expressionSCRIPT: [],
output: this.fb.group({
name: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex), Validators.maxLength(255)]],
scope: [{ value: AttributeScope.SERVER_SCOPE, disabled: true }],
type: [OutputType.Timeseries]
}),
}),
});
functionArgs$ = this.fieldFormGroup.get('configuration').valueChanges
.pipe(
map(configuration => isObject(configuration?.arguments) ? Object.keys(configuration.arguments) : [])
);
readonly OutputTypeTranslations = OutputTypeTranslations;
readonly OutputType = OutputType;
readonly AttributeScope = AttributeScope;
readonly EntityType = EntityType;
readonly CalculatedFieldType = CalculatedFieldType;
readonly ScriptLanguage = ScriptLanguage;
readonly helpLink = `${helpBaseUrl}/[TODO: ADD VALID LINK!!!]`;
readonly fieldTypes = Object.values(CalculatedFieldType) as CalculatedFieldType[];
readonly outputTypes = Object.values(OutputType) as OutputType[];
readonly CalculatedFieldTypeTranslations = CalculatedFieldTypeTranslations;
constructor(protected store: Store<AppState>,
protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: CalculatedFieldDialogData,
public dialogRef: MatDialogRef<CalculatedFieldDialogComponent, CalculatedField>,
public fb: UntypedFormBuilder) {
super(store, router, dialogRef);
this.applyDialogData();
this.outputFormGroup.get('type').valueChanges
.pipe(takeUntilDestroyed())
.subscribe(type => this.toggleScopeByOutputType(type));
this.toggleScopeByOutputType(this.outputFormGroup.get('type').value);
}
get configFormGroup(): FormGroup {
return this.fieldFormGroup.get('configuration') as FormGroup;
}
get outputFormGroup(): FormGroup {
return this.fieldFormGroup.get('configuration').get('output') as FormGroup;
}
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 });
}
}
private applyDialogData(): void {
const { configuration = {}, type = CalculatedFieldType.SIMPLE, ...value } = this.data.value;
const { expression, ...restConfig } = configuration as CalculatedFieldConfiguration;
this.fieldFormGroup.patchValue({ configuration: { ...restConfig, ['expression'+type]: expression }, ...value });
}
private toggleScopeByOutputType(type: OutputType): void {
this.outputFormGroup.get('scope')[type === OutputType.Attribute? 'enable' : 'disable']({emitEvent: false});
}
}

View File

@ -0,0 +1,198 @@
<!--
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="w-screen max-w-xl" [formGroup]="argumentFormGroup">
<div class="tb-form-panel no-border no-padding mb-2">
<div class="tb-form-panel-title">{{ 'calculated-fields.argument-settings' | translate }}</div>
<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>
<div class="tb-flex no-gap">
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<input matInput name="value" formControlName="argumentName" maxlength="255" placeholder="{{ 'action.set' | translate }}"/>
@if (argumentFormGroup.get('argumentName').hasError('required') && argumentFormGroup.get('argumentName').touched) {
<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').hasError('pattern') && argumentFormGroup.get('argumentName').touched) {
<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').hasError('maxlength') && argumentFormGroup.get('argumentName').touched) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.argument-name-max-length' | translate"
class="tb-error">
warning
</mat-icon>
}
</mat-form-field>
</div>
</div>
<ng-container [formGroup]="refEntityIdFormGroup">
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'entity.entity-type' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="entityType">
@for (type of argumentEntityTypes; track type) {
<mat-option [value]="type">{{ ArgumentEntityTypeTranslations.get(type) | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
@if (entityType === ArgumentEntityType.Device || entityType === ArgumentEntityType.Asset) {
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.device-name' | translate }}</div>
<tb-entity-autocomplete
class="entity-autocomplete w-full"
formControlName="id"
[withLabel]="false"
[placeholder]="'action.set' | translate"
[appearance]="'outline'"
[required]="true"
[iconError]="true"
[subscriptSizing]="'dynamic'"
[entityType]="EntityType.DEVICE"
/>
</div>
} @else if (entityType === ArgumentEntityType.Customer) {
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.customer-name' | translate }}</div>
<tb-entity-autocomplete
class="entity-autocomplete w-full"
formControlName="id"
[withLabel]="false"
[placeholder]="'action.set' | translate"
[appearance]="'outline'"
[iconError]="true"
[required]="true"
[subscriptSizing]="'dynamic'"
[entityType]="EntityType.CUSTOMER"
/>
</div>
}
</ng-container>
<ng-container [formGroup]="refEntityKeyFormGroup">
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.argument-type' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="type">
@for (type of argumentTypes; track type) {
<mat-option [value]="type">{{ ArgumentTypeTranslations.get(type) | translate }}</mat-option>
}
</mat-select>
@if (refEntityKeyFormGroup.get('type').hasError('required') && refEntityKeyFormGroup.get('type').touched) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.argument-type-required' | translate"
class="tb-error">
warning
</mat-icon>
}
</mat-form-field>
</div>
@if (refEntityKeyFormGroup.get('type').value !== ArgumentType.Attribute) {
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.timeseries-key' | translate }}</div>
<tb-entity-key-autocomplete formControlName="key" [dataKeyType]="DataKeyType.timeseries" [entityFilter]="entityFilter"/>
</div>
} @else {
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.attribute-scope' | translate }}</div>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="scope" class="w-full">
<mat-option [value]="AttributeScope.SERVER_SCOPE">
{{ 'calculated-fields.server-attributes' | translate }}
</mat-option>
@if ((keyEntityType$ | async) === EntityType.DEVICE) {
<mat-option [value]="AttributeScope.CLIENT_SCOPE">
{{ 'calculated-fields.client-attributes' | translate }}
</mat-option>
<mat-option [value]="AttributeScope.SHARED_SCOPE">
{{ 'calculated-fields.shared-attributes' | translate }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.attribute-key' | translate }}</div>
<tb-entity-key-autocomplete
formControlName="key"
[dataKeyType]="DataKeyType.attribute"
[entityFilter]="entityFilter"
[keyScopeType]="argumentFormGroup.get('refEntityKey').get('scope').value"
/>
</div>
}
</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>
<div class="tb-flex no-gap">
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<input matInput autocomplete="off" name="value" formControlName="defaultValue" placeholder="{{ 'action.set' | translate }}"/>
</mat-form-field>
</div>
</div>
} @else {
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'calculated-fields.time-window' | translate }}</div>
<div class="tb-flex no-gap">
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<input matInput type="number" min="0" name="value" formControlName="timeWindow" placeholder="{{ 'action.set' | translate }}"/>
<span class="block pr-2" matSuffix>{{ 'common.suffix.ms' | translate }}</span>
</mat-form-field>
</div>
</div>
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'calculated-fields.limit' | translate }}</div>
<div class="tb-flex no-gap">
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<input matInput type="number" min="0" name="value" formControlName="limit" placeholder="{{ 'action.set' | translate }}"/>
</mat-form-field>
</div>
</div>
}
</div>
</div>
<div class="tb-flex flex-end">
<button mat-button
color="primary"
type="button"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button
color="primary"
type="button"
(click)="saveArgument()"
[disabled]="argumentFormGroup.invalid || !argumentFormGroup.dirty">
{{ buttonTitle | translate }}
</button>
</div>
</div>

View File

@ -0,0 +1,29 @@
/**
* 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 {
.mat-mdc-form-field {
width: 100%;
}
}
:host ::ng-deep {
.entity-autocomplete {
.mat-mdc-form-field {
width: 100%;
}
}
}

View File

@ -0,0 +1,201 @@
///
/// 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, ElementRef, Input, OnInit, output, ViewChild } from '@angular/core';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { PageComponent } from '@shared/components/page.component';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { noLeadTrailSpacesRegex } from '@shared/models/regex.constants';
import {
ArgumentEntityType,
ArgumentEntityTypeTranslations,
ArgumentType,
ArgumentTypeTranslations,
CalculatedFieldArgumentValue,
CalculatedFieldType
} from '@shared/models/calculated-field.models';
import { debounceTime, delay, distinctUntilChanged, filter, map, startWith } from 'rxjs/operators';
import { EntityType } from '@shared/models/entity-type.models';
import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { DatasourceType } from '@shared/models/widget.models';
import { EntityId } from '@shared/models/id/entity-id';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EntityFilter } from '@shared/models/query/query.models';
import { AliasFilterType } from '@shared/models/alias.models';
import { merge } from 'rxjs';
@Component({
selector: 'tb-calculated-field-argument-panel',
templateUrl: './calculated-field-argument-panel.component.html',
styleUrls: ['./calculated-field-argument-panel.component.scss']
})
export class CalculatedFieldArgumentPanelComponent extends PageComponent implements OnInit {
@Input() popover: TbPopoverComponent<CalculatedFieldArgumentPanelComponent>;
@Input() buttonTitle: string;
@Input() index: number;
@Input() argument: CalculatedFieldArgumentValue;
@Input() entityId: EntityId;
@Input() tenantId: string;
@Input() calculatedFieldType: CalculatedFieldType;
@ViewChild('timeseriesInput') timeseriesInput: ElementRef;
argumentsDataApplied = output<{ value: CalculatedFieldArgumentValue, index: number }>();
argumentFormGroup = this.fb.group({
argumentName: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex), Validators.maxLength(255)]],
refEntityId: this.fb.group({
entityType: [ArgumentEntityType.Current],
id: ['']
}),
refEntityKey: this.fb.group({
type: [ArgumentType.LatestTelemetry, [Validators.required]],
key: [''],
scope: [{ value: AttributeScope.SERVER_SCOPE, disabled: true }],
}),
defaultValue: ['', [Validators.pattern(noLeadTrailSpacesRegex)]],
limit: [null],
timeWindow: [null],
});
argumentTypes: ArgumentType[];
entityFilter: EntityFilter;
keyEntityType$ = this.refEntityIdFormGroup.get('entityType').valueChanges
.pipe(
startWith(this.refEntityIdFormGroup.get('entityType').value),
map(type => type === ArgumentEntityType.Current ? this.entityId.entityType : type)
);
readonly argumentEntityTypes = Object.values(ArgumentEntityType) as ArgumentEntityType[];
readonly ArgumentEntityTypeTranslations = ArgumentEntityTypeTranslations;
readonly ArgumentType = ArgumentType;
readonly DataKeyType = DataKeyType;
readonly EntityType = EntityType;
readonly datasourceType = DatasourceType;
readonly ArgumentTypeTranslations = ArgumentTypeTranslations;
readonly AttributeScope = AttributeScope;
constructor(
private fb: FormBuilder,
) {
super();
this.observeEntityFilterChanges();
this.observeEntityTypeChanges()
this.observeEntityKeyChanges();
}
get entityType(): ArgumentEntityType {
return this.argumentFormGroup.get('refEntityId').get('entityType').value;
}
get refEntityIdFormGroup(): FormGroup {
return this.argumentFormGroup.get('refEntityId') as FormGroup;
}
get refEntityKeyFormGroup(): FormGroup {
return this.argumentFormGroup.get('refEntityKey') as FormGroup;
}
ngOnInit(): void {
this.argumentFormGroup.patchValue(this.argument, {emitEvent: false});
this.updateEntityFilter(this.argument.refEntityId?.entityType, true);
this.toggleByEntityKeyType(this.argument.refEntityKey?.type);
this.setInitialEntityKeyType();
this.argumentTypes = Object.values(ArgumentType)
.filter(type => type !== ArgumentType.Rolling || this.calculatedFieldType === CalculatedFieldType.SCRIPT);
}
saveArgument(): void {
this.argumentsDataApplied.emit({ value: this.argumentFormGroup.value as CalculatedFieldArgumentValue, index: this.index });
}
cancel(): void {
this.popover.hide();
}
private toggleByEntityKeyType(type: ArgumentType): void {
const isAttribute = type === ArgumentType.Attribute;
const isRolling = type === ArgumentType.Rolling;
this.argumentFormGroup.get('refEntityKey').get('scope')[isAttribute? 'enable' : 'disable']({ emitEvent: false });
this.argumentFormGroup.get('limit')[isRolling? 'enable' : 'disable']({ emitEvent: false });
this.argumentFormGroup.get('timeWindow')[isRolling? 'enable' : 'disable']({ emitEvent: false });
this.argumentFormGroup.get('defaultValue')[isRolling? 'disable' : 'enable']({ emitEvent: false });
}
private updateEntityFilter(entityType: ArgumentEntityType, onInit = false): void {
let entityId: EntityId;
switch (entityType) {
case ArgumentEntityType.Current:
entityId = this.entityId
break;
case ArgumentEntityType.Tenant:
entityId = {
id: this.tenantId,
entityType: EntityType.TENANT
};
break;
default:
entityId = this.argumentFormGroup.get('refEntityId').value as any;
}
if (onInit) {
this.argumentFormGroup.get('refEntityKey').get('key').setValue('');
}
this.entityFilter = {
type: AliasFilterType.singleEntity,
singleEntity: entityId,
};
}
private observeEntityFilterChanges(): void {
merge(
this.argumentFormGroup.get('refEntityId').get('entityType').valueChanges,
this.argumentFormGroup.get('refEntityId').get('id').valueChanges.pipe(filter(Boolean)),
this.argumentFormGroup.get('refEntityKey').get('scope').valueChanges,
)
.pipe(debounceTime(300), delay(50), takeUntilDestroyed())
.subscribe(() => this.updateEntityFilter(this.entityType));
}
private observeEntityTypeChanges(): void {
this.argumentFormGroup.get('refEntityId').get('entityType').valueChanges
.pipe(distinctUntilChanged(), takeUntilDestroyed())
.subscribe(type => {
this.argumentFormGroup.get('refEntityId').get('id').setValue('');
this.argumentFormGroup.get('refEntityId')
.get('id')[type === ArgumentEntityType.Tenant || type === ArgumentEntityType.Current ? 'disable' : 'enable']();
});
}
private observeEntityKeyChanges(): void {
this.argumentFormGroup.get('refEntityKey').get('type').valueChanges
.pipe(takeUntilDestroyed())
.subscribe(type => this.toggleByEntityKeyType(type));
}
private setInitialEntityKeyType(): void {
if (this.calculatedFieldType === CalculatedFieldType.SIMPLE && this.argument.refEntityKey?.type === ArgumentType.Rolling) {
const typeControl = this.argumentFormGroup.get('refEntityKey').get('type');
typeControl.setValue(null);
typeControl.markAsTouched();
typeControl.updateValueAndValidity();
}
}
protected readonly ArgumentEntityType = ArgumentEntityType;
}

View File

@ -0,0 +1,18 @@
///
/// 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.
///
export * from './dialog/calculated-field-dialog.component';
export * from './arguments-table/calculated-field-arguments-table.component';

View File

@ -185,6 +185,16 @@ import { EntityChipsComponent } from '@home/components/entity/entity-chips.compo
import { DashboardViewComponent } from '@home/components/dashboard-view/dashboard-view.component';
import { CalculatedFieldsTableComponent } from '@home/components/calculated-fields/calculated-fields-table.component';
import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe';
import { CalculatedFieldDialogComponent } from '@home/components/calculated-fields/components/dialog/calculated-field-dialog.component';
import {
EntityDebugSettingsButtonComponent
} from '@home/components/entity/debug/entity-debug-settings-button.component';
import {
CalculatedFieldArgumentsTableComponent
} from '@home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component';
import {
CalculatedFieldArgumentPanelComponent
} from '@home/components/calculated-fields/components/panel/calculated-field-argument-panel.component';
@NgModule({
declarations:
@ -330,6 +340,9 @@ import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe';
EntityChipsComponent,
DashboardViewComponent,
CalculatedFieldsTableComponent,
CalculatedFieldDialogComponent,
CalculatedFieldArgumentsTableComponent,
CalculatedFieldArgumentPanelComponent,
],
imports: [
CommonModule,
@ -341,7 +354,8 @@ import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe';
SnmpDeviceProfileTransportModule,
StatesControllerModule,
DeviceCredentialsModule,
DeviceProfileCommonModule
DeviceProfileCommonModule,
EntityDebugSettingsButtonComponent
],
exports: [
RouterTabsComponent,
@ -468,6 +482,9 @@ import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe';
EntityChipsComponent,
DashboardViewComponent,
CalculatedFieldsTableComponent,
CalculatedFieldDialogComponent,
CalculatedFieldArgumentsTableComponent,
CalculatedFieldArgumentPanelComponent,
],
providers: [
WidgetComponentService,

View File

@ -19,6 +19,7 @@ import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { DeviceInfo } from '@shared/models/device.models';
import { EntityTabsComponent } from '../../components/entity/entity-tabs.component';
import { EntityType } from '@shared/models/entity-type.models';
@Component({
selector: 'tb-device-tabs',
@ -27,6 +28,8 @@ import { EntityTabsComponent } from '../../components/entity/entity-tabs.compone
})
export class DeviceTabsComponent extends EntityTabsComponent<DeviceInfo> {
readonly EntityType = EntityType;
constructor(protected store: Store<AppState>) {
super(store);
}
@ -34,5 +37,4 @@ export class DeviceTabsComponent extends EntityTabsComponent<DeviceInfo> {
ngOnInit() {
super.ngOnInit();
}
}

View File

@ -17,10 +17,11 @@
-->
<mat-form-field [formGroup]="selectEntityFormGroup" class="mat-block" [appearance]="appearance" [subscriptSizing]="subscriptSizing"
[class]="additionalClasses">
<mat-label>{{ label | translate }}</mat-label>
<mat-label *ngIf="withLabel">{{ label | translate }}</mat-label>
<input matInput type="text"
#entityInput
formControlName="entity"
[placeholder]="placeholder"
(focusin)="onFocus()"
[required]="required"
[matAutocomplete]="entityAutocomplete"
@ -28,6 +29,14 @@
<a *ngIf="selectEntityFormGroup.get('entity').value && disabled" aria-label="Open device profile" [routerLink]=entityURL>
{{ displayEntityFn(selectEntityFormGroup.get('entity').value) }}
</a>
<mat-icon *ngIf="selectEntityFormGroup.get('entity').hasError('required') && iconError"
matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="requiredErrorText | translate"
class="tb-error">
warning
</mat-icon>
<button *ngIf="selectEntityFormGroup.get('entity').value && !disabled"
type="button"
matSuffix mat-icon-button aria-label="Clear"
@ -59,7 +68,7 @@
</div>
</mat-option>
</mat-autocomplete>
<mat-error *ngIf="selectEntityFormGroup.get('entity').hasError('required')">
<mat-error *ngIf="selectEntityFormGroup.get('entity').hasError('required') && !iconError">
{{ requiredErrorText | translate }}
</mat-error>
</mat-form-field>

View File

@ -16,6 +16,7 @@
import {
AfterViewInit,
booleanAttribute,
Component,
ElementRef,
EventEmitter,
@ -135,6 +136,12 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit
@coerceBoolean()
allowCreateNew: boolean;
@Input({ transform: booleanAttribute }) withLabel = true;
@Input({ transform: booleanAttribute }) iconError = false;
@Input() placeholder: string;
@Input()
subscriptSizing: SubscriptSizing = 'fixed';

View File

@ -0,0 +1,39 @@
<!--
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.
-->
<mat-form-field class="tb-flex no-gap !w-full" appearance="outline" subscriptSizing="dynamic">
<input matInput type="text" placeholder="{{ 'entity.key-name' | translate }}"
#keyInput
[formControl]="keyControl"
required
(focusin)="keyInputSubject.next()"
[matAutocomplete]="keysAutocomplete">
@if (keyControl.value) {
<button type="button"
matSuffix mat-icon-button aria-label="Clear"
(click)="clear()">
<mat-icon class="material-icons">close</mat-icon>
</button>
}
<mat-autocomplete
class="tb-autocomplete"
#keysAutocomplete="matAutocomplete">
@for (key of filteredKeys$ | async; track key) {
<mat-option [value]="key"><span [innerHTML]="key | highlight: searchText"></span></mat-option>
}
</mat-autocomplete>
</mat-form-field>

View File

@ -0,0 +1,134 @@
///
/// 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, effect, ElementRef, forwardRef, input, ViewChild, } from '@angular/core';
import {
ControlValueAccessor,
FormBuilder,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
Validators
} from '@angular/forms';
import { map, startWith, switchMap } from 'rxjs/operators';
import { combineLatest, of, Subject } from 'rxjs';
import { EntityService } from '@core/http/entity.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { EntitiesKeysByQuery } from '@shared/models/entity.models';
import { EntityFilter } from '@shared/models/query/query.models';
@Component({
selector: 'tb-entity-key-autocomplete',
templateUrl: './entity-key-autocomplete.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => EntityKeyAutocompleteComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => EntityKeyAutocompleteComponent),
multi: true
}
],
host: {
class: 'w-full'
}
})
export class EntityKeyAutocompleteComponent implements ControlValueAccessor, Validator {
@ViewChild('keyInput') keyInput: ElementRef;
entityFilter = input.required<EntityFilter>();
dataKeyType = input.required<DataKeyType>();
keyScopeType = input<AttributeScope>();
keyControl = this.fb.control('', [Validators.required]);
searchText = '';
keyInputSubject = new Subject<void>();
private onChange: (value: string) => void;
private cachedResult: EntitiesKeysByQuery;
keys$ = this.keyInputSubject.asObservable()
.pipe(
switchMap(() => {
return this.cachedResult ? of(this.cachedResult) : this.entityService.findEntityKeysByQuery({
pageLink: { page: 0, pageSize: 100 },
entityFilter: this.entityFilter(),
}, this.dataKeyType() === DataKeyType.attribute, this.dataKeyType() === DataKeyType.timeseries, this.keyScopeType());
}),
map(result => {
this.cachedResult = result;
switch (this.dataKeyType()) {
case DataKeyType.attribute:
return result.attribute;
case DataKeyType.timeseries:
return result.timeseries;
default:
return [];
}
}),
);
filteredKeys$ = combineLatest([this.keys$, this.keyControl.valueChanges.pipe(startWith(''))])
.pipe(
map(([keys, searchText = '']) => {
this.searchText = searchText;
return searchText ? keys.filter(item => item.toLowerCase().includes(searchText.toLowerCase())) : keys;
})
);
constructor(
private fb: FormBuilder,
private entityService: EntityService,
) {
this.keyControl.valueChanges
.pipe(takeUntilDestroyed())
.subscribe(value => this.onChange(value));
effect(() => {
if (this.keyScopeType() || this.entityFilter() && this.dataKeyType()) {
this.cachedResult = null;
this.searchText = '';
}
});
}
clear(): void {
this.keyControl.patchValue('', {emitEvent: true});
setTimeout(() => {
this.keyInput.nativeElement.blur();
this.keyInput.nativeElement.focus();
}, 0);
}
registerOnChange(onChange: (value: string) => void): void {
this.onChange = onChange;
}
registerOnTouched(_): void {}
validate(): ValidationErrors | null {
return this.keyControl.valid ? null : { keyControl: false };
}
writeValue(value: string): void {
this.keyControl.patchValue(value, {emitEvent: false});
}
}

View File

@ -20,9 +20,11 @@ import {
ElementRef,
forwardRef,
Input,
OnChanges,
OnDestroy,
OnInit,
Renderer2,
SimpleChanges,
ViewChild,
ViewContainerRef,
ViewEncapsulation
@ -67,7 +69,7 @@ import { catchError } from 'rxjs/operators';
],
encapsulation: ViewEncapsulation.None
})
export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor, Validator {
export class JsFuncComponent implements OnInit, OnChanges, OnDestroy, ControlValueAccessor, Validator {
@ViewChild('javascriptEditor', {static: true})
javascriptEditorElmRef: ElementRef;
@ -177,6 +179,13 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
private http: HttpClient) {
}
ngOnChanges(changes: SimpleChanges): void {
if (changes.functionArgs) {
this.updateFunctionArgsString();
this.updateFunctionLabel();
}
}
ngOnInit(): void {
if (this.functionTitle || this.label) {
this.hideBrackets = true;
@ -184,22 +193,6 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
if (!this.resultType || this.resultType.length === 0) {
this.resultType = 'nocheck';
}
if (this.functionArgs) {
this.functionArgs.forEach((functionArg) => {
if (this.functionArgsString.length > 0) {
this.functionArgsString += ', ';
}
this.functionArgsString += functionArg;
});
}
if (this.functionTitle) {
this.functionLabel = `${this.functionTitle}: f(${this.functionArgsString})`;
} else if (this.label) {
this.functionLabel = this.label;
} else {
this.functionLabel =
`function ${this.functionName ? this.functionName : ''}(${this.functionArgsString})${this.hideBrackets ? '' : ' {'}`;
}
const editorElement = this.javascriptEditorElmRef.nativeElement;
let editorOptions: Partial<Ace.EditorOptions> = {
mode: 'ace/mode/javascript',
@ -329,6 +322,25 @@ export class JsFuncComponent implements OnInit, OnDestroy, ControlValueAccessor,
);
}
private updateFunctionArgsString(): void {
this.functionArgsString = '';
if (this.functionArgs) {
this.functionArgsString = this.functionArgs.join(', ');
}
}
private updateFunctionLabel(): void {
if (this.functionTitle) {
this.functionLabel = `${this.functionTitle}: f(${this.functionArgsString})`;
} else if (this.label) {
this.functionLabel = this.label;
} else {
this.functionLabel =
`function ${this.functionName ? this.functionName : ''}(${this.functionArgsString})${this.hideBrackets ? '' : ' {'}`;
}
this.cd.markForCheck();
}
validateOnSubmit(): Observable<void> {
if (!this.disabled) {
this.cleanupJsErrors();

View File

@ -13,29 +13,109 @@
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { EntityDebugSettings, HasTenantId, HasVersion } from '@shared/models/entity.models';
import { BaseData } from '@shared/models/base-data';
import { CalculatedFieldId } from '@shared/models/id/calculated-field-id';
import { EntityId } from '@shared/models/id/entity-id';
import { AttributeScope } from '@shared/models/telemetry/telemetry.models';
export interface CalculatedField extends Omit<BaseData<CalculatedFieldId>, 'label'>, HasVersion, HasTenantId {
type: CalculatedFieldType;
debugSettings?: EntityDebugSettings;
externalId?: string;
configuration: CalculatedFieldConfiguration;
type: CalculatedFieldType;
}
export enum CalculatedFieldType {
SIMPLE = 'SIMPLE',
COMPLEX = 'COMPLEX',
}
export interface CalculatedFieldConfiguration {
type: CalculatedFieldConfigType;
expression: string;
arguments: Record<string, unknown>;
}
export enum CalculatedFieldConfigType {
SIMPLE = 'SIMPLE',
SCRIPT = 'SCRIPT',
}
export const CalculatedFieldTypeTranslations = new Map<CalculatedFieldType, string>(
[
[CalculatedFieldType.SIMPLE, 'calculated-fields.type.simple'],
[CalculatedFieldType.SCRIPT, 'calculated-fields.type.script'],
]
)
export interface CalculatedFieldConfiguration {
type: CalculatedFieldType;
expression: string;
arguments: Record<string, CalculatedFieldArgument>;
}
export enum ArgumentEntityType {
Current = 'CURRENT',
Device = 'DEVICE',
Asset = 'ASSET',
Customer = 'CUSTOMER',
Tenant = 'TENANT',
}
export const ArgumentEntityTypeTranslations = new Map<ArgumentEntityType, string>(
[
[ArgumentEntityType.Current, 'calculated-fields.argument-current'],
[ArgumentEntityType.Device, 'calculated-fields.argument-device'],
[ArgumentEntityType.Asset, 'calculated-fields.argument-asset'],
[ArgumentEntityType.Customer, 'calculated-fields.argument-customer'],
[ArgumentEntityType.Tenant, 'calculated-fields.argument-tenant'],
]
)
export enum ArgumentType {
Attribute = 'ATTRIBUTE',
LatestTelemetry = 'TS_LATEST',
Rolling = 'TS_ROLLING',
}
export enum OutputType {
Attribute = 'ATTRIBUTES',
Timeseries = 'TIME_SERIES',
}
export const OutputTypeTranslations = new Map<OutputType, string>(
[
[OutputType.Attribute, 'calculated-fields.attribute'],
[OutputType.Timeseries, 'calculated-fields.timeseries'],
]
)
export const ArgumentTypeTranslations = new Map<ArgumentType, string>(
[
[ArgumentType.Attribute, 'calculated-fields.attribute'],
[ArgumentType.LatestTelemetry, 'calculated-fields.latest-telemetry'],
[ArgumentType.Rolling, 'calculated-fields.rolling'],
]
)
export interface CalculatedFieldArgument {
refEntityKey: RefEntityKey;
defaultValue?: string;
refEntityId?: RefEntityKey;
limit?: number;
timeWindow?: number;
}
export interface RefEntityKey {
key: string;
type: ArgumentType;
scope?: AttributeScope;
}
export interface RefEntityKey {
entityType: ArgumentEntityType;
id: string;
}
export interface CalculatedFieldArgumentValue extends CalculatedFieldArgument {
argumentName: string;
}
export interface CalculatedFieldDialogData {
value: CalculatedField;
buttonTitle: string;
entityId: EntityId;
debugLimitsConfiguration: string;
tenantId: string;
}

View File

@ -61,3 +61,4 @@ export * from './widgets-bundle.model';
export * from './window-message.model';
export * from './usage.models';
export * from './query/query.models';
export * from './regex.constants';

View File

@ -0,0 +1,17 @@
///
/// 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.
///
export const noLeadTrailSpacesRegex = /^\S+(?: \S+)*$/;

View File

@ -224,6 +224,7 @@ import { IntervalOptionsConfigPanelComponent } from '@shared/components/time/int
import { GroupingIntervalOptionsComponent } from '@shared/components/time/aggregation/grouping-interval-options.component';
import { JsFuncModulesComponent } from '@shared/components/js-func-modules.component';
import { JsFuncModuleRowComponent } from '@shared/components/js-func-module-row.component';
import { EntityKeyAutocompleteComponent } from '@shared/components/entity/entity-key-autocomplete.component';
export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) {
return markedOptionsService;
@ -432,7 +433,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
ImageGalleryDialogComponent,
WidgetButtonComponent,
HexInputComponent,
ScadaSymbolInputComponent
ScadaSymbolInputComponent,
EntityKeyAutocompleteComponent,
],
imports: [
CommonModule,
@ -694,7 +696,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
EmbedImageDialogComponent,
ImageGalleryDialogComponent,
WidgetButtonComponent,
ScadaSymbolInputComponent
ScadaSymbolInputComponent,
EntityKeyAutocompleteComponent,
]
})
export class SharedModule { }

View File

@ -996,6 +996,7 @@
"failures": "Failures",
"entity": "entity",
"rule-node": "rule node",
"calculated-field": "calculated field",
"hint": {
"main": "All node debug messages rate limited with:",
"main-limited": "All {{entity}} debug messages will be rate-limited, with a maximum of {{msg}} messages allowed per {{time}}.",
@ -1007,7 +1008,56 @@
"expression": "Expression",
"no-found": "No calculated fields found",
"list": "{ count, plural, =1 {One calculated field} other {List of # calculated fields} }",
"selected-fields": "{ count, plural, =1 {1 calculated field} other {# calculated fields} } selected"
"selected-fields": "{ count, plural, =1 {1 calculated field} other {# calculated fields} } selected",
"type": {
"simple": "Simple",
"script": "Script"
},
"arguments": "Arguments",
"argument-name": "Argument name",
"datasource": "Datasource",
"add-argument": "Add argument",
"no-arguments": "No arguments configured",
"argument-settings": "Argument settings",
"argument-current": "Current entity",
"argument-current-tenant": "Current tenant",
"argument-device": "Device",
"argument-asset": "Asset",
"argument-customer": "Customer",
"argument-tenant": "Current tenant",
"argument-type": "Argument type",
"attribute": "Attribute",
"timeseries-key": "Time series key",
"device-name": "Device name",
"latest-telemetry": "Latest telemetry",
"rolling": "Rolling",
"attribute-scope": "Attribute scope",
"server-attributes": "Server attributes",
"client-attributes": "Client attributes",
"shared-attributes": "Shared attributes",
"attribute-key": "Attribute key",
"default-value": "Default value",
"limit": "Limit",
"time-window": "Time window",
"customer-name": "Customer name",
"timeseries": "Time series",
"output": "Output",
"output-type": "Output type",
"delete-title": "Are you sure you want to delete the calculated field '{{title}}'?",
"delete-text": "Be careful, after the confirmation the calculated field and all related data will become unrecoverable.",
"delete-multiple-title": "Are you sure you want to delete { count, plural, =1 {1 calculated field} other {# calculated fields} }?",
"delete-multiple-text": "Be careful, after the confirmation all selected calculated fields will be removed and all related data will become unrecoverable.",
"hint": {
"arguments-simple-with-rolling": "Simple type calculated field should not contain keys with rolling type.",
"arguments-empty": "Arguments should not be empty.",
"expression-required": "Expression is required.",
"expression-invalid": "Expression is invalid",
"expression-max-length": "Expression length should be less than 255 characters.",
"argument-name-required": "Argument name is required.",
"argument-name-pattern": "Argument name is invalid.",
"argument-name-max-length": "Argument name should be less than 256 characters.",
"argument-type-required": "Argument type is required."
}
},
"confirm-on-exit": {
"message": "You have unsaved changes. Are you sure you want to leave this page?",
@ -1035,6 +1085,7 @@
"common": {
"name": "Name",
"type": "Type",
"general": "General",
"username": "Username",
"password": "Password",
"enter-username": "Enter username",
@ -1047,7 +1098,19 @@
"open-details-page": "Open details page",
"not-found": "Not found",
"documentation": "Documentation",
"time-left": "{{time}} left"
"time-left": "{{time}} left",
"suffix": {
"s": "s",
"ms": "ms"
},
"hint": {
"name-required": "Name is required.",
"name-pattern": "Name is invalid.",
"name-max-length": "Name should be less than 256 characters.",
"key-required": "Key is required.",
"key-pattern": "Key is invalid.",
"key-max-length": "Key should be less than 256 characters."
}
},
"content-type": {
"json": "Json",