Browse Source

Calculated field add/edit basic implementation

pull/12509/head
mpetrov 2 years ago
parent
commit
fd42c51df1
  1. 56
      ui-ngx/src/app/core/http/calculated-fields.service.ts
  2. 74
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts
  3. 23
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts
  4. 106
      ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html
  5. 32
      ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.scss
  6. 213
      ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.ts
  7. 169
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html
  8. 122
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts
  9. 198
      ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.html
  10. 29
      ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.scss
  11. 201
      ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.ts
  12. 18
      ui-ngx/src/app/modules/home/components/calculated-fields/components/public-api.ts
  13. 19
      ui-ngx/src/app/modules/home/components/home-components.module.ts
  14. 4
      ui-ngx/src/app/modules/home/pages/device/device-tabs.component.ts
  15. 13
      ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html
  16. 7
      ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts
  17. 39
      ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.html
  18. 134
      ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.ts
  19. 46
      ui-ngx/src/app/shared/components/js-func.component.ts
  20. 94
      ui-ngx/src/app/shared/models/calculated-field.models.ts
  21. 1
      ui-ngx/src/app/shared/models/public-api.ts
  22. 17
      ui-ngx/src/app/shared/models/regex.constants.ts
  23. 7
      ui-ngx/src/app/shared/shared.module.ts
  24. 67
      ui-ngx/src/assets/locale/locale.constant-en_US.json

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

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

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

@ -23,41 +23,33 @@ import { TimePageLink } from '@shared/models/page/page-link';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { PageData } from '@shared/models/page/page-data'; import { PageData } from '@shared/models/page/page-data';
import { EntityId } from '@shared/models/id/entity-id'; import { EntityId } from '@shared/models/id/entity-id';
import { DialogService } from '@core/services/dialog.service';
import { MINUTE } from '@shared/models/time/time.models'; import { MINUTE } from '@shared/models/time/time.models';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
import { getCurrentAuthState } from '@core/auth/auth.selectors'; import { getCurrentAuthState, getCurrentAuthUser } from '@core/auth/auth.selectors';
import { ChangeDetectorRef, DestroyRef, ViewContainerRef } from '@angular/core'; import { DestroyRef } from '@angular/core';
import { Overlay } from '@angular/cdk/overlay';
import { UtilsService } from '@core/services/utils.service';
import { EntityService } from '@core/http/entity.service';
import { EntityDebugSettings } from '@shared/models/entity.models'; import { EntityDebugSettings } from '@shared/models/entity.models';
import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe'; import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { TbPopoverService } from '@shared/components/popover.service'; import { TbPopoverService } from '@shared/components/popover.service';
import { EntityDebugSettingsPanelComponent } from '@home/components/entity/debug/entity-debug-settings-panel.component'; import { EntityDebugSettingsPanelComponent } from '@home/components/entity/debug/entity-debug-settings-panel.component';
import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; 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 { CalculatedField } from '@shared/models/calculated-field.models';
import { CalculatedFieldDialogComponent } from './components/public-api';
export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedField, TimePageLink> { export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedField, TimePageLink> {
readonly calculatedFieldsDebugPerTenantLimitsConfiguration = readonly calculatedFieldsDebugPerTenantLimitsConfiguration =
getCurrentAuthState(this.store)['calculatedFieldsDebugPerTenantLimitsConfiguration'] || '1:1'; getCurrentAuthState(this.store)['calculatedFieldsDebugPerTenantLimitsConfiguration'] || '1:1';
readonly maxDebugModeDuration = getCurrentAuthState(this.store).maxDebugModeDurationMinutes * MINUTE; readonly maxDebugModeDuration = getCurrentAuthState(this.store).maxDebugModeDurationMinutes * MINUTE;
readonly tenantId = getCurrentAuthUser(this.store).tenantId;
constructor(private calculatedFieldsService: CalculatedFieldsService, constructor(private calculatedFieldsService: CalculatedFieldsService,
private entityService: EntityService,
private dialogService: DialogService,
private translate: TranslateService, private translate: TranslateService,
private dialog: MatDialog, private dialog: MatDialog,
public entityId: EntityId = null, public entityId: EntityId = null,
private store: Store<AppState>, private store: Store<AppState>,
private viewContainerRef: ViewContainerRef,
private overlay: Overlay,
private cd: ChangeDetectorRef,
private utilsService: UtilsService,
private durationLeft: DurationLeftPipe, private durationLeft: DurationLeftPipe,
private popoverService: TbPopoverService, private popoverService: TbPopoverService,
private destroyRef: DestroyRef, private destroyRef: DestroyRef,
@ -67,6 +59,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
this.detailsPanelEnabled = false; this.detailsPanelEnabled = false;
this.selectionEnabled = true; this.selectionEnabled = true;
this.searchEnabled = true; this.searchEnabled = true;
this.pageMode = false;
this.addEnabled = true; this.addEnabled = true;
this.entitiesDeleteEnabled = true; this.entitiesDeleteEnabled = true;
this.actionsColumnTitle = ''; this.actionsColumnTitle = '';
@ -74,6 +67,12 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
this.entityTranslations = entityTypeTranslations.get(EntityType.CALCULATED_FIELD); this.entityTranslations = entityTypeTranslations.get(EntityType.CALCULATED_FIELD);
this.entitiesFetchFunction = pageLink => this.fetchCalculatedFields(pageLink); 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}; this.defaultSortOrder = {property: 'name', direction: Direction.DESC};
@ -97,8 +96,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
name: this.translate.instant('action.edit'), name: this.translate.instant('action.edit'),
icon: 'edit', icon: 'edit',
isEnabled: () => true, isEnabled: () => true,
// // [TODO]: [Calculated fields] - implement edit onAction: (_, entity) => this.editCalculatedField(entity)
onAction: (_, entity) => {}
} }
); );
} }
@ -121,7 +119,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
{ {
debugLimitsConfiguration: this.calculatedFieldsDebugPerTenantLimitsConfiguration, debugLimitsConfiguration: this.calculatedFieldsDebugPerTenantLimitsConfiguration,
maxDebugModeDuration: this.maxDebugModeDuration, maxDebugModeDuration: this.maxDebugModeDuration,
entityLabel: this.translate.instant('debug-settings.integration'), entityLabel: this.translate.instant('debug-settings.calculated-field'),
...debugSettings ...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 { private getDebugConfigLabel(debugSettings: EntityDebugSettings): string {
const isDebugActive = this.isDebugActive(debugSettings?.allEnabledUntil); const isDebugActive = this.isDebugActive(debugSettings?.allEnabledUntil);
@ -149,7 +189,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
} }
private onDebugConfigChanged(id: string, debugSettings: EntityDebugSettings): void { private onDebugConfigChanged(id: string, debugSettings: EntityDebugSettings): void {
this.calculatedFieldsService.getCalculatedField(id).pipe( this.calculatedFieldsService.getCalculatedFieldById(id).pipe(
switchMap(field => this.calculatedFieldsService.saveCalculatedField({ ...field, debugSettings })), switchMap(field => this.calculatedFieldsService.saveCalculatedField({ ...field, debugSettings })),
catchError(() => of(null)), catchError(() => of(null)),
takeUntilDestroyed(this.destroyRef), takeUntilDestroyed(this.destroyRef),

23
ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts

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

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

@ -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>

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

@ -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;
}
}
}

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

@ -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();
}
}

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

@ -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>

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

@ -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});
}
}

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

@ -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>

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

@ -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%;
}
}
}

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

@ -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;
}

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

@ -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';

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

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

4
ui-ngx/src/app/modules/home/pages/device/device-tabs.component.ts

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

13
ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html

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

7
ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts

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

39
ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.html

@ -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>

134
ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.ts

@ -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});
}
}

46
ui-ngx/src/app/shared/components/js-func.component.ts

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

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

@ -13,29 +13,109 @@
/// See the License for the specific language governing permissions and /// See the License for the specific language governing permissions and
/// limitations under the License. /// limitations under the License.
/// ///
import { EntityDebugSettings, HasTenantId, HasVersion } from '@shared/models/entity.models'; import { EntityDebugSettings, HasTenantId, HasVersion } from '@shared/models/entity.models';
import { BaseData } from '@shared/models/base-data'; import { BaseData } from '@shared/models/base-data';
import { CalculatedFieldId } from '@shared/models/id/calculated-field-id'; 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 { export interface CalculatedField extends Omit<BaseData<CalculatedFieldId>, 'label'>, HasVersion, HasTenantId {
type: CalculatedFieldType;
debugSettings?: EntityDebugSettings; debugSettings?: EntityDebugSettings;
externalId?: string; externalId?: string;
configuration: CalculatedFieldConfiguration; configuration: CalculatedFieldConfiguration;
type: CalculatedFieldType;
} }
export enum CalculatedFieldType { export enum CalculatedFieldType {
SIMPLE = 'SIMPLE', SIMPLE = 'SIMPLE',
COMPLEX = 'COMPLEX', SCRIPT = 'SCRIPT',
} }
export const CalculatedFieldTypeTranslations = new Map<CalculatedFieldType, string>(
[
[CalculatedFieldType.SIMPLE, 'calculated-fields.type.simple'],
[CalculatedFieldType.SCRIPT, 'calculated-fields.type.script'],
]
)
export interface CalculatedFieldConfiguration { export interface CalculatedFieldConfiguration {
type: CalculatedFieldConfigType; type: CalculatedFieldType;
expression: string; expression: string;
arguments: Record<string, unknown>; arguments: Record<string, CalculatedFieldArgument>;
} }
export enum CalculatedFieldConfigType { export enum ArgumentEntityType {
SIMPLE = 'SIMPLE', Current = 'CURRENT',
SCRIPT = 'SCRIPT', 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;
} }

1
ui-ngx/src/app/shared/models/public-api.ts

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

17
ui-ngx/src/app/shared/models/regex.constants.ts

@ -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+)*$/;

7
ui-ngx/src/app/shared/shared.module.ts

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

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

@ -996,6 +996,7 @@
"failures": "Failures", "failures": "Failures",
"entity": "entity", "entity": "entity",
"rule-node": "rule node", "rule-node": "rule node",
"calculated-field": "calculated field",
"hint": { "hint": {
"main": "All node debug messages rate limited with:", "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}}.", "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", "expression": "Expression",
"no-found": "No calculated fields found", "no-found": "No calculated fields found",
"list": "{ count, plural, =1 {One calculated field} other {List of # calculated fields} }", "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": { "confirm-on-exit": {
"message": "You have unsaved changes. Are you sure you want to leave this page?", "message": "You have unsaved changes. Are you sure you want to leave this page?",
@ -1035,6 +1085,7 @@
"common": { "common": {
"name": "Name", "name": "Name",
"type": "Type", "type": "Type",
"general": "General",
"username": "Username", "username": "Username",
"password": "Password", "password": "Password",
"enter-username": "Enter username", "enter-username": "Enter username",
@ -1047,7 +1098,19 @@
"open-details-page": "Open details page", "open-details-page": "Open details page",
"not-found": "Not found", "not-found": "Not found",
"documentation": "Documentation", "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": { "content-type": {
"json": "Json", "json": "Json",

Loading…
Cancel
Save