Browse Source

Merge pull request #12509 from maxunbearable/feature/calculated-fields-table

Implemented Device Calculated Fields
pull/12603/head
Vladyslav Prykhodko 1 year ago
committed by GitHub
parent
commit
cfecf5eb8d
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 51
      ui-ngx/src/app/core/http/calculated-fields.service.ts
  2. 188
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts
  3. 20
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.html
  4. 22
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.scss
  5. 80
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts
  6. 114
      ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html
  7. 22
      ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.scss
  8. 210
      ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.ts
  9. 170
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html
  10. 142
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts
  11. 177
      ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.html
  12. 199
      ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.ts
  13. 19
      ui-ngx/src/app/modules/home/components/calculated-fields/components/public-api.ts
  14. 1
      ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-button.component.ts
  15. 6
      ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.ts
  16. 6
      ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts
  17. 28
      ui-ngx/src/app/modules/home/components/home-components.module.ts
  18. 3
      ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts
  19. 4
      ui-ngx/src/app/modules/home/pages/device/device-tabs.component.html
  20. 19
      ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html
  21. 7
      ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts
  22. 51
      ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.html
  23. 145
      ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.ts
  24. 46
      ui-ngx/src/app/shared/components/js-func.component.ts
  25. 134
      ui-ngx/src/app/shared/models/calculated-field.models.ts
  26. 1
      ui-ngx/src/app/shared/models/constants.ts
  27. 15
      ui-ngx/src/app/shared/models/entity-type.models.ts
  28. 26
      ui-ngx/src/app/shared/models/id/calculated-field-id.ts
  29. 1
      ui-ngx/src/app/shared/models/public-api.ts
  30. 19
      ui-ngx/src/app/shared/models/regex.constants.ts
  31. 7
      ui-ngx/src/app/shared/shared.module.ts
  32. 79
      ui-ngx/src/assets/locale/locale.constant-en_US.json

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

@ -0,0 +1,51 @@
///
/// 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 { Injectable } from '@angular/core';
import { defaultHttpOptionsFromConfig, RequestConfig } from './http-utils';
import { Observable } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { PageData } from '@shared/models/page/page-data';
import { CalculatedField } from '@shared/models/calculated-field.models';
import { PageLink } from '@shared/models/page/page-link';
import { EntityId } from '@shared/models/id/entity-id';
@Injectable({
providedIn: 'root'
})
export class CalculatedFieldsService {
constructor(
private http: HttpClient
) { }
public getCalculatedFieldById(calculatedFieldId: string, config?: RequestConfig): Observable<CalculatedField> {
return this.http.get<CalculatedField>(`/api/calculatedField/${calculatedFieldId}`, defaultHttpOptionsFromConfig(config));
}
public saveCalculatedField(calculatedField: CalculatedField, config?: RequestConfig): Observable<CalculatedField> {
return this.http.post<CalculatedField>('/api/calculatedField', calculatedField, defaultHttpOptionsFromConfig(config));
}
public deleteCalculatedField(calculatedFieldId: string, config?: RequestConfig): Observable<boolean> {
return this.http.delete<boolean>(`/api/calculatedField/${calculatedFieldId}`, defaultHttpOptionsFromConfig(config));
}
public getCalculatedFields({ entityType, id }: EntityId, pageLink: PageLink, config?: RequestConfig): Observable<PageData<CalculatedField>> {
return this.http.get<PageData<CalculatedField>>(`/api/${entityType}/${id}/calculatedFields${pageLink.toQuery()}`,
defaultHttpOptionsFromConfig(config));
}
}

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

@ -0,0 +1,188 @@
///
/// 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 { EntityTableColumn, EntityTableConfig } from '@home/models/entity/entities-table-config.models';
import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models';
import { TranslateService } from '@ngx-translate/core';
import { Direction } from '@shared/models/page/sort-order';
import { MatDialog } from '@angular/material/dialog';
import { PageLink } 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 { MINUTE } from '@shared/models/time/time.models';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { getCurrentAuthState, getCurrentAuthUser } from '@core/auth/auth.selectors';
import { DestroyRef, Renderer2 } 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, filter, switchMap } from 'rxjs/operators';
import { CalculatedField, CalculatedFieldDialogData } from '@shared/models/calculated-field.models';
import { CalculatedFieldDialogComponent } from './components/public-api';
export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedField, PageLink> {
// TODO: [Calculated Fields] remove hardcode when BE variable implemented
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 translate: TranslateService,
private dialog: MatDialog,
public entityId: EntityId = null,
private store: Store<AppState>,
private durationLeft: DurationLeftPipe,
private popoverService: TbPopoverService,
private destroyRef: DestroyRef,
private renderer: Renderer2
) {
super();
this.tableTitle = this.translate.instant('entity.type-calculated-fields');
this.detailsPanelEnabled = false;
this.pageMode = false;
this.entityType = EntityType.CALCULATED_FIELD;
this.entityTranslations = entityTypeTranslations.get(EntityType.CALCULATED_FIELD);
this.entitiesFetchFunction = (pageLink: PageLink) => this.fetchCalculatedFields(pageLink);
this.addEntity = this.addCalculatedField.bind(this);
this.deleteEntityTitle = (field: CalculatedField) => 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};
const expressionColumn = new EntityTableColumn<CalculatedField>('expression', 'calculated-fields.expression', '33%', entity => entity.configuration?.expression);
expressionColumn.sortable = false;
this.columns.push(new EntityTableColumn<CalculatedField>('name', 'common.name', '33%'));
this.columns.push(new EntityTableColumn<CalculatedField>('type', 'common.type', '50px'));
this.columns.push(expressionColumn);
this.cellActionDescriptors.push(
{
name: '',
nameFunction: entity => this.getDebugConfigLabel(entity?.debugSettings),
icon: 'mdi:bug',
isEnabled: () => true,
iconFunction: ({ debugSettings }) => this.isDebugActive(debugSettings?.allEnabledUntil) || debugSettings?.failuresEnabled ? 'mdi:bug' : 'mdi:bug-outline',
onAction: ($event, entity) => this.onOpenDebugConfig($event, entity),
},
{
name: this.translate.instant('action.edit'),
icon: 'edit',
isEnabled: () => true,
onAction: (_, entity) => this.editCalculatedField(entity),
}
);
}
fetchCalculatedFields(pageLink: PageLink): Observable<PageData<CalculatedField>> {
return this.calculatedFieldsService.getCalculatedFields(this.entityId, pageLink);
}
onOpenDebugConfig($event: Event, { debugSettings = {}, id }: CalculatedField): void {
const { viewContainerRef } = this.getTable();
if ($event) {
$event.stopPropagation();
}
const trigger = $event.target as Element;
if (this.popoverService.hasPopover(trigger)) {
this.popoverService.hidePopover(trigger);
} else {
const debugStrategyPopover = this.popoverService.displayPopover(trigger, this.renderer,
viewContainerRef, EntityDebugSettingsPanelComponent, 'bottom', true, null,
{
debugLimitsConfiguration: this.calculatedFieldsDebugPerTenantLimitsConfiguration,
maxDebugModeDuration: this.maxDebugModeDuration,
entityLabel: this.translate.instant('debug-settings.calculated-field'),
...debugSettings
},
{},
{}, {}, true);
debugStrategyPopover.tbComponentRef.instance.onSettingsApplied.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((settings: EntityDebugSettings) => {
this.onDebugConfigChanged(id.id, settings);
debugStrategyPopover.hide();
});
}
}
private addCalculatedField(): Observable<CalculatedField> {
return this.getCalculatedFieldDialog()
.pipe(
filter(Boolean),
switchMap(calculatedField => this.calculatedFieldsService.saveCalculatedField({ entityId: this.entityId, ...calculatedField })),
)
}
private editCalculatedField(calculatedField: CalculatedField): void {
this.getCalculatedFieldDialog(calculatedField, 'action.apply')
.pipe(
filter(Boolean),
switchMap((updatedCalculatedField) => this.calculatedFieldsService.saveCalculatedField({ ...calculatedField, ...updatedCalculatedField })),
)
.subscribe((res) => {
if (res) {
this.updateData();
}
});
}
private getCalculatedFieldDialog(value?: CalculatedField, buttonTitle = 'action.add'): Observable<CalculatedField> {
return this.dialog.open<CalculatedFieldDialogComponent, CalculatedFieldDialogData, CalculatedField>(CalculatedFieldDialogComponent, {
disableClose: true,
panelClass: ['tb-dialog', 'tb-fullscreen-dialog'],
data: {
value,
buttonTitle,
entityId: this.entityId,
debugLimitsConfiguration: this.calculatedFieldsDebugPerTenantLimitsConfiguration,
tenantId: this.tenantId,
}
})
.afterClosed();
}
private getDebugConfigLabel(debugSettings: EntityDebugSettings): string {
const isDebugActive = this.isDebugActive(debugSettings?.allEnabledUntil);
if (!isDebugActive) {
return debugSettings?.failuresEnabled ? this.translate.instant('debug-settings.failures') : this.translate.instant('common.disabled');
} else {
return this.durationLeft.transform(debugSettings?.allEnabledUntil);
}
}
private isDebugActive(allEnabledUntil: number): boolean {
return allEnabledUntil > new Date().getTime();
}
private onDebugConfigChanged(id: string, debugSettings: EntityDebugSettings): void {
this.calculatedFieldsService.getCalculatedFieldById(id).pipe(
switchMap(field => this.calculatedFieldsService.saveCalculatedField({ ...field, debugSettings })),
catchError(() => of(null)),
takeUntilDestroyed(this.destroyRef),
).subscribe(() => this.updateData());
}
}

20
ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.html

@ -0,0 +1,20 @@
<!--
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.
-->
@if (calculatedFieldsTableConfig) {
<tb-entities-table [entitiesTableConfig]="calculatedFieldsTableConfig"></tb-entities-table>
}

22
ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.scss

@ -0,0 +1,22 @@
/**
* 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 {
tb-entities-table {
.mat-drawer-container {
background-color: white;
}
}
}

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

@ -0,0 +1,80 @@
///
/// 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 {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
effect,
input,
Renderer2,
ViewChild,
} from '@angular/core';
import { EntityId } from '@shared/models/id/entity-id';
import { EntitiesTableComponent } from '@home/components/entity/entities-table.component';
import { TranslateService } from '@ngx-translate/core';
import { MatDialog } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
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';
import { CalculatedFieldsService } from '@core/http/calculated-fields.service';
@Component({
selector: 'tb-calculated-fields-table',
templateUrl: './calculated-fields-table.component.html',
styleUrls: ['./calculated-fields-table.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CalculatedFieldsTableComponent {
@ViewChild(EntitiesTableComponent, {static: true}) entitiesTable: EntitiesTableComponent;
active = input<boolean>();
entityId = input<EntityId>();
calculatedFieldsTableConfig: CalculatedFieldsTableConfig;
constructor(private calculatedFieldsService: CalculatedFieldsService,
private translate: TranslateService,
private dialog: MatDialog,
private store: Store<AppState>,
private durationLeft: DurationLeftPipe,
private popoverService: TbPopoverService,
private cd: ChangeDetectorRef,
private renderer: Renderer2,
private destroyRef: DestroyRef) {
effect(() => {
if (this.active()) {
this.calculatedFieldsTableConfig = new CalculatedFieldsTableConfig(
this.calculatedFieldsService,
this.translate,
this.dialog,
this.entityId(),
this.store,
this.durationLeft,
this.popoverService,
this.destroyRef,
this.renderer
);
this.cd.markForCheck();
}
});
}
}

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

@ -0,0 +1,114 @@
<!--
Copyright © 2016-2024 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="flex flex-col gap-3">
<div class="tb-form-table">
<div class="tb-form-table-header">
<div class="tb-form-table-header-cell w-1/6">{{ 'calculated-fields.argument-name' | translate }}</div>
<div class="tb-form-table-header-cell w-1/3">{{ 'calculated-fields.datasource' | translate }}</div>
<div class="tb-form-table-header-cell w-1/6">{{ 'common.type' | translate }}</div>
<div class="tb-form-table-header-cell w-1/6">{{ 'entity.key' | translate }}</div>
<div class="tb-form-table-header-cell w-24 min-w-24"></div>
</div>
<div class="tb-form-table-body tb-drop-list">
@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>
<section class="flex w-1/3 gap-2">
@if (group.get('refEntityId')?.get('id')?.value) {
<ng-container [formGroup]="group.get('refEntityId')">
<mat-form-field appearance="outline" class="tb-inline-field flex-1" 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="flex-1"
formControlName="id"
[inlineField]="true"
[hideLabel]="true"
[placeholder]="'action.set' | translate"
[entityType]="group.get('refEntityId').get('entityType').value"/>
</ng-container>
} @else {
<mat-form-field appearance="outline" class="tb-inline-field flex-1" 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>
}
</section>
<ng-container [formGroup]="group.get('refEntityKey')">
<mat-form-field appearance="outline" class="tb-inline-field w-1/6" subscriptSizing="dynamic">
@if (group.get('refEntityKey').get('type').value; as type) {
<mat-select [value]="type" formControlName="type">
<mat-option [value]="type">
{{ ArgumentTypeTranslations.get(type) | 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 && this.argumentsFormArray.dirty) {
<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>
</div>

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

@ -0,0 +1,22 @@
/**
* 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 {
.tb-inline-field {
a {
font-size: 14px;
}
}
}

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

@ -0,0 +1,210 @@
///
/// 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,
effect,
forwardRef,
input,
Input,
Renderer2,
ViewContainerRef,
} from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
FormBuilder,
FormGroup,
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/public-api';
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 { charsWithNumRegex } from '@shared/models/regex.constants';
import { TbPopoverComponent } from '@shared/components/popover.component';
@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>([]);
readonly entityTypeTranslations = entityTypeTranslations;
readonly ArgumentTypeTranslations = ArgumentTypeTranslations;
readonly EntityType = EntityType;
readonly ArgumentEntityType = ArgumentEntityType;
private popoverComponent: TbPopoverComponent<CalculatedFieldArgumentPanelComponent>;
private propagateChange: (argumentsObj: Record<string, CalculatedFieldArgument>) => void = () => {};
constructor(
private fb: FormBuilder,
private popoverService: TbPopoverService,
private viewContainerRef: ViewContainerRef,
private cd: ChangeDetectorRef,
private renderer: Renderer2
) {
this.argumentsFormArray.valueChanges.pipe(takeUntilDestroyed()).subscribe(() => {
this.propagateChange(this.getArgumentsObject());
});
effect(() => {
if (this.calculatedFieldType() && this.argumentsFormArray.dirty) {
this.argumentsFormArray.updateValueAndValidity();
}
});
}
registerOnChange(fn: (argumentsObj: Record<string, CalculatedFieldArgument>) => void): void {
this.propagateChange = fn;
}
registerOnTouched(_): void {}
validate(): ValidationErrors | null {
this.updateErrorText();
return this.errorText ? { argumentsFormArray: false } : null;
}
onDelete(index: number): void {
this.argumentsFormArray.removeAt(index);
this.argumentsFormArray.markAsDirty();
}
manageArgument($event: Event, matButton: MatButton, index?: number): void {
$event?.stopPropagation();
if (this.popoverComponent && !this.popoverComponent.tbHidden) {
this.popoverComponent.hide();
}
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.popoverComponent = this.popoverService.displayPopover(trigger, this.renderer,
this.viewContainerRef, CalculatedFieldArgumentPanelComponent, 'left', false, null,
ctx,
{},
{}, {}, true);
this.popoverComponent.tbComponentRef.instance.argumentsDataApplied.subscribe(({ value, index }) => {
this.popoverComponent.hide();
const formGroup = this.getArgumentFormGroup(value);
if (isDefinedAndNotNull(index)) {
this.argumentsFormArray.setControl(index, formGroup);
} else {
this.argumentsFormArray.push(formGroup);
}
formGroup.markAsDirty();
this.cd.markForCheck();
});
}
}
private updateErrorText(): void {
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 = '';
}
}
private getArgumentsObject(): Record<string, CalculatedFieldArgument> {
return this.argumentsFormArray.getRawValue().reduce((acc, rawValue) => {
const { argumentName, ...argument } = rawValue as CalculatedFieldArgumentValue;
acc[argumentName] = argument;
return acc;
}, {} as Record<string, CalculatedFieldArgument>);
}
writeValue(argumentsObj: Record<string, CalculatedFieldArgument>): void {
this.argumentsFormArray.clear();
this.populateArgumentsFormArray(argumentsObj)
}
private populateArgumentsFormArray(argumentsObj: Record<string, CalculatedFieldArgument>): void {
Object.keys(argumentsObj).forEach(key => {
const value: CalculatedFieldArgumentValue = {
...argumentsObj[key],
argumentName: key
};
this.argumentsFormArray.push(this.getArgumentFormGroup(value), {emitEvent: false});
});
}
private getArgumentFormGroup(value: CalculatedFieldArgumentValue): FormGroup {
return this.fb.group({
...value,
argumentName: [value.argumentName, [Validators.required, Validators.maxLength(255), Validators.pattern(charsWithNumRegex)]],
...(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 }],
}),
})
}
}

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

@ -0,0 +1,170 @@
<!--
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="calculatedField"></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.title-required' | translate }}
} @else if (fieldFormGroup.get('name').hasError('pattern')) {
{{ 'common.hint.title-pattern' | translate }}
} @else if (fieldFormGroup.get('name').hasError('maxlength')) {
{{ 'common.hint.title-max-length' | translate }}
}
</mat-error>
}
</mat-form-field>
<tb-entity-debug-settings-button
formControlName="debugSettings"
[class.mb-5]="fieldFormGroup.get('name').errors && fieldFormGroup.get('name').touched"
[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">
@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').value"
/>
</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]: [Calculated Fields] add valid link"
/>
}
</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-3">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'calculated-fields.output-type' | translate }}</mat-label>
<mat-select formControlName="type">
@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>

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

@ -0,0 +1,142 @@
///
/// 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 { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { DialogComponent } from '@shared/components/dialog.component';
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, startWith } from 'rxjs/operators';
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],
debugSettings: [],
configuration: this.fb.group({
arguments: this.fb.control({}),
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.configFormGroup.valueChanges
.pipe(
startWith(this.data.value?.configuration ?? {}),
map(configuration => Object.keys(configuration.arguments))
);
readonly OutputTypeTranslations = OutputTypeTranslations;
readonly OutputType = OutputType;
readonly AttributeScope = AttributeScope;
readonly EntityType = EntityType;
readonly CalculatedFieldType = CalculatedFieldType;
readonly ScriptLanguage = ScriptLanguage;
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,
protected dialogRef: MatDialogRef<CalculatedFieldDialogComponent, CalculatedField>,
private fb: FormBuilder) {
super(store, router, dialogRef);
this.applyDialogData();
this.observeTypeChanges();
}
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 } as CalculatedField);
}
}
private applyDialogData(): void {
const { configuration = {}, type = CalculatedFieldType.SIMPLE, ...value } = this.data.value ?? {};
const { expression, ...restConfig } = configuration as CalculatedFieldConfiguration;
const updatedConfig = { ...restConfig , ['expression'+type]: expression };
this.fieldFormGroup.patchValue({ configuration: updatedConfig, type, ...value }, {emitEvent: false});
}
private observeTypeChanges(): void {
this.toggleKeyByCalculatedFieldType(this.fieldFormGroup.get('type').value);
this.toggleScopeByOutputType(this.outputFormGroup.get('type').value);
this.outputFormGroup.get('type').valueChanges
.pipe(takeUntilDestroyed())
.subscribe(type => this.toggleScopeByOutputType(type));
this.fieldFormGroup.get('type').valueChanges
.pipe(takeUntilDestroyed())
.subscribe(type => this.toggleKeyByCalculatedFieldType(type));
}
private toggleScopeByOutputType(type: OutputType): void {
this.outputFormGroup.get('scope')[type === OutputType.Attribute? 'enable' : 'disable']({emitEvent: false});
}
private toggleKeyByCalculatedFieldType(type: CalculatedFieldType): void {
if (type === CalculatedFieldType.SIMPLE) {
this.outputFormGroup.get('name').enable({emitEvent: false});
this.configFormGroup.get('expressionSIMPLE').enable({emitEvent: false});
this.configFormGroup.get('expressionSCRIPT').disable({emitEvent: false});
} else {
this.outputFormGroup.get('name').disable({emitEvent: false});
this.configFormGroup.get('expressionSIMPLE').disable({emitEvent: false});
this.configFormGroup.get('expressionSCRIPT').enable({emitEvent: false});
}
}
}

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

@ -0,0 +1,177 @@
<!--
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>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<input matInput autocomplete="off" name="value" formControlName="argumentName" maxlength="255" placeholder="{{ 'action.set' | translate }}"/>
@if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('required')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.argument-name-required' | translate"
class="tb-error">
warning
</mat-icon>
} @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('pattern')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.argument-name-pattern' | translate"
class="tb-error">
warning
</mat-icon>
} @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('maxlength')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.argument-name-max-length' | translate"
class="tb-error">
warning
</mat-icon>
}
</mat-form-field>
</div>
<ng-container [formGroup]="refEntityIdFormGroup">
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'entity.entity-type' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="entityType">
@for (type of argumentEntityTypes; track type) {
<mat-option [value]="type">{{ ArgumentEntityTypeTranslations.get(type) | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
@if (ArgumentEntityTypeParamsMap.has(entityType)) {
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ ArgumentEntityTypeParamsMap.get(entityType).title | translate }}</div>
<tb-entity-autocomplete
class="flex flex-1"
formControlName="id"
[hideLabel]="true"
[inlineField]="true"
[placeholder]="'action.set' | translate"
[required]="true"
[entityType]="ArgumentEntityTypeParamsMap.get(entityType).entityType"
/>
</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 (entityFilter.singleEntity.id || entityType === ArgumentEntityType.Current || entityType === ArgumentEntityType.Tenant) {
@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 class="flex-1" 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" class="flex-1">
<mat-select formControlName="scope">
<mat-option [value]="AttributeScope.SERVER_SCOPE">
{{ 'calculated-fields.server-attributes' | translate }}
</mat-option>
@if (entityType === ArgumentEntityType.Device
|| entityType === ArgumentEntityType.Current && entityId.entityType === 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"
class="flex-1"
[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>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<input matInput autocomplete="off" name="value" formControlName="defaultValue" placeholder="{{ 'action.set' | translate }}"/>
</mat-form-field>
</div>
} @else {
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'calculated-fields.time-window' | translate }}</div>
<tb-timeinterval
subscriptSizing="dynamic"
appearance="outline"
class="flex-1"
formControlName="timeWindow"
/>
</div>
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'calculated-fields.limit' | translate }}</div>
<tb-datapoints-limit class="flex-1" formControlName="limit"/>
</div>
}
</div>
</div>
<div class="flex justify-end gap-2">
<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>

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

@ -0,0 +1,199 @@
///
/// 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, Input, OnInit, output } from '@angular/core';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { charsWithNumRegex, noLeadTrailSpacesRegex } from '@shared/models/regex.constants';
import {
ArgumentEntityType,
ArgumentEntityTypeParamsMap,
ArgumentEntityTypeTranslations,
ArgumentType,
ArgumentTypeTranslations,
CalculatedFieldArgumentValue,
CalculatedFieldType
} from '@shared/models/calculated-field.models';
import { debounceTime, distinctUntilChanged, filter } 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';
import { MINUTE } from '@shared/models/time/time.models';
@Component({
selector: 'tb-calculated-field-argument-panel',
templateUrl: './calculated-field-argument-panel.component.html',
})
export class CalculatedFieldArgumentPanelComponent implements OnInit {
@Input() buttonTitle: string;
@Input() index: number;
@Input() argument: CalculatedFieldArgumentValue;
@Input() entityId: EntityId;
@Input() tenantId: string;
@Input() calculatedFieldType: CalculatedFieldType;
argumentsDataApplied = output<{ value: CalculatedFieldArgumentValue, index: number }>();
argumentFormGroup = this.fb.group({
argumentName: ['', [Validators.required, Validators.pattern(charsWithNumRegex), 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 }, [Validators.required]],
}),
defaultValue: ['', [Validators.pattern(noLeadTrailSpacesRegex)]],
limit: [1000],
timeWindow: [MINUTE * 15],
});
argumentTypes: ArgumentType[];
entityFilter: EntityFilter;
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;
readonly ArgumentEntityType = ArgumentEntityType;
readonly ArgumentEntityTypeParamsMap = ArgumentEntityTypeParamsMap;
constructor(
private fb: FormBuilder,
private cd: ChangeDetectorRef,
private popover: TbPopoverComponent<CalculatedFieldArgumentPanelComponent>
) {
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 {
const { refEntityId, ...restConfig } = this.argumentFormGroup.value;
const value = (refEntityId.entityType === ArgumentEntityType.Current ? restConfig : { refEntityId, ...restConfig }) as CalculatedFieldArgumentValue;
if (refEntityId.entityType === ArgumentEntityType.Tenant) {
refEntityId.id = this.tenantId;
}
this.argumentsDataApplied.emit({ value, 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 = ArgumentEntityType.Current, 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 unknown as EntityId;
}
if (!onInit) {
this.argumentFormGroup.get('refEntityKey').get('key').setValue('');
}
this.entityFilter = {
type: AliasFilterType.singleEntity,
singleEntity: entityId,
};
this.cd.markForCheck();
}
private observeEntityFilterChanges(): void {
merge(
this.refEntityIdFormGroup.get('entityType').valueChanges,
this.refEntityKeyFormGroup.get('type').valueChanges,
this.refEntityIdFormGroup.get('id').valueChanges.pipe(filter(Boolean)),
this.refEntityKeyFormGroup.get('scope').valueChanges,
)
.pipe(debounceTime(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();
}
}
}

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

@ -0,0 +1,19 @@
///
/// 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';
export * from './panel/calculated-field-argument-panel.component';

1
ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-button.component.ts

@ -137,7 +137,6 @@ export class EntityDebugSettingsButtonComponent implements ControlValueAccessor
},
{},
{}, {}, true);
debugStrategyPopover.tbComponentRef.instance.popover = debugStrategyPopover;
debugStrategyPopover.tbComponentRef.instance.onSettingsApplied.subscribe((settings: EntityDebugSettings) => {
this.debugSettingsFormGroup.patchValue(settings);
this.cd.markForCheck();

6
ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.ts

@ -48,7 +48,6 @@ import { distinctUntilChanged, map, startWith, switchMap, takeWhile } from 'rxjs
})
export class EntityDebugSettingsPanelComponent extends PageComponent implements OnInit {
@Input() popover: TbPopoverComponent<EntityDebugSettingsPanelComponent>;
@Input({ transform: booleanAttribute }) failuresEnabled = false;
@Input({ transform: booleanAttribute }) allEnabled = false;
@Input() entityLabel: string;
@ -82,7 +81,8 @@ export class EntityDebugSettingsPanelComponent extends PageComponent implements
onSettingsApplied = new EventEmitter<EntityDebugSettings>();
constructor(private fb: FormBuilder,
private cd: ChangeDetectorRef) {
private cd: ChangeDetectorRef,
private popover: TbPopoverComponent<EntityDebugSettingsPanelComponent>) {
super();
this.debugAllControl.valueChanges.pipe(
@ -107,7 +107,7 @@ export class EntityDebugSettingsPanelComponent extends PageComponent implements
}
onCancel(): void {
this.popover?.hide();
this.popover.hide();
}
onApply(): void {

6
ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts

@ -26,7 +26,8 @@ import {
OnDestroy,
OnInit,
SimpleChanges,
ViewChild
ViewChild,
ViewContainerRef,
} from '@angular/core';
import { PageComponent } from '@shared/components/page.component';
import { Store } from '@ngrx/store';
@ -141,7 +142,8 @@ export class EntitiesTableComponent extends PageComponent implements IEntitiesTa
private router: Router,
private elementRef: ElementRef,
private fb: FormBuilder,
private zone: NgZone) {
private zone: NgZone,
public viewContainerRef: ViewContainerRef) {
super(store);
}

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

@ -183,6 +183,18 @@ import {
} from '@home/components/dashboard-page/layout/select-dashboard-breakpoint.component';
import { EntityChipsComponent } from '@home/components/entity/entity-chips.component';
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:
@ -326,7 +338,11 @@ import { DashboardViewComponent } from '@home/components/dashboard-view/dashboar
RateLimitsDetailsDialogComponent,
SendNotificationButtonComponent,
EntityChipsComponent,
DashboardViewComponent
DashboardViewComponent,
CalculatedFieldsTableComponent,
CalculatedFieldDialogComponent,
CalculatedFieldArgumentsTableComponent,
CalculatedFieldArgumentPanelComponent,
],
imports: [
CommonModule,
@ -338,7 +354,8 @@ import { DashboardViewComponent } from '@home/components/dashboard-view/dashboar
SnmpDeviceProfileTransportModule,
StatesControllerModule,
DeviceCredentialsModule,
DeviceProfileCommonModule
DeviceProfileCommonModule,
EntityDebugSettingsButtonComponent
],
exports: [
RouterTabsComponent,
@ -463,11 +480,16 @@ import { DashboardViewComponent } from '@home/components/dashboard-view/dashboar
RateLimitsDetailsDialogComponent,
SendNotificationButtonComponent,
EntityChipsComponent,
DashboardViewComponent
DashboardViewComponent,
CalculatedFieldsTableComponent,
CalculatedFieldDialogComponent,
CalculatedFieldArgumentsTableComponent,
CalculatedFieldArgumentPanelComponent,
],
providers: [
WidgetComponentService,
CustomDialogService,
DurationLeftPipe,
{provide: EMBED_DASHBOARD_DIALOG_TOKEN, useValue: EmbedDashboardDialogComponent},
{provide: COMPLEX_FILTER_PREDICATE_DIALOG_COMPONENT_TOKEN, useValue: ComplexFilterPredicateDialogComponent},
{provide: DASHBOARD_PAGE_COMPONENT_TOKEN, useValue: DashboardPageComponent},

3
ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts

@ -20,7 +20,7 @@ import { SafeHtml } from '@angular/platform-browser';
import { PageLink } from '@shared/models/page/page-link';
import { Timewindow } from '@shared/models/time/time.models';
import { EntitiesDataSource } from '@home/models/datasource/entity-datasource';
import { ElementRef, EventEmitter } from '@angular/core';
import { ElementRef, EventEmitter, Renderer2, ViewContainerRef } from '@angular/core';
import { TbAnchorComponent } from '@shared/components/tb-anchor.component';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
@ -64,6 +64,7 @@ export interface IEntitiesTableComponent {
paginator: MatPaginator;
sort: MatSort;
route: ActivatedRoute;
viewContainerRef: ViewContainerRef;
addEnabled(): boolean;
clearSelection(): void;

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

@ -32,6 +32,10 @@
[entityName]="entity.name">
</tb-attribute-table>
</mat-tab>
<mat-tab *ngIf="entity && authUser.authority === authorities.TENANT_ADMIN"
label="{{ 'entity.type-calculated-fields' | translate }}" #calculatedFieldsTab="matTab">
<tb-calculated-fields-table [active]="calculatedFieldsTab.isActive" [entityId]="entity.id"/>
</mat-tab>
<mat-tab *ngIf="entity"
label="{{ 'alarm.alarms' | translate }}" #alarmsTab="matTab">
<tb-alarm-table [active]="alarmsTab.isActive" [entityId]="entity.id"></tb-alarm-table>

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

@ -15,12 +15,17 @@
limitations under the License.
-->
<mat-form-field [formGroup]="selectEntityFormGroup" class="mat-block" [appearance]="appearance" [subscriptSizing]="subscriptSizing"
<mat-form-field [formGroup]="selectEntityFormGroup" class="mat-block"
[appearance]="inlineField ? 'outline' : appearance"
[subscriptSizing]="inlineField ? 'dynamic' : subscriptSizing"
[class.tb-inline-field]="inlineField"
[class.flex]="inlineField"
[class]="additionalClasses">
<mat-label>{{ label | translate }}</mat-label>
<mat-label *ngIf="!hideLabel">{{ label | translate }}</mat-label>
<input matInput type="text"
#entityInput
formControlName="entity"
[placeholder]="placeholder"
(focusin)="onFocus()"
[required]="required"
[matAutocomplete]="entityAutocomplete"
@ -28,6 +33,14 @@
<a *ngIf="selectEntityFormGroup.get('entity').value && disabled" aria-label="Open device profile" [routerLink]=entityURL>
{{ displayEntityFn(selectEntityFormGroup.get('entity').value) }}
</a>
<mat-icon *ngIf="inlineField && selectEntityFormGroup.get('entity').hasError('required') && selectEntityFormGroup.get('entity').touched"
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 +72,7 @@
</div>
</mat-option>
</mat-autocomplete>
<mat-error *ngIf="selectEntityFormGroup.get('entity').hasError('required')">
<mat-error *ngIf="selectEntityFormGroup.get('entity').hasError('required') && !inlineField">
{{ requiredErrorText | translate }}
</mat-error>
</mat-form-field>

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

@ -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 }) hideLabel = false;
@Input({ transform: booleanAttribute }) inlineField = false;
@Input() placeholder: string;
@Input()
subscriptSizing: SubscriptSizing = 'fixed';

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

@ -0,0 +1,51 @@
<!--
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="{{ 'action.set' | 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>
} @else if (keyControl.hasError('required') && keyControl.touched) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'common.hint.key-required' | translate"
class="tb-error">
warning
</mat-icon>
}
<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>
} @empty {
@if (!this.keyControl.value) {
<mat-option [value]="''">{{ 'entity.no-keys-found' | translate }}</mat-option>
}
}
</mat-autocomplete>
</mat-form-field>

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

@ -0,0 +1,145 @@
///
/// 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, OnChanges, SimpleChanges, 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';
import { isEqual } from '@core/utils';
@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
}
],
})
export class EntityKeyAutocompleteComponent implements ControlValueAccessor, Validator, OnChanges {
@ViewChild('keyInput', {static: true}) 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 propagateChange: (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.propagateChange(value));
effect(() => {
if (this.keyScopeType() || this.entityFilter() && this.dataKeyType()) {
this.cachedResult = null;
this.searchText = '';
}
});
}
ngOnChanges(changes: SimpleChanges): void {
const filterChanged = changes.entityFilter?.previousValue &&
!isEqual(changes.entityFilter.currentValue, changes.entityFilter.previousValue);
const keyScopeChanged = changes.keyScopeType?.previousValue &&
changes.keyScopeType.currentValue !== changes.keyScopeType.previousValue;
const keyTypeChanged = changes.dataKeyType?.previousValue &&
changes.dataKeyType.currentValue !== changes.dataKeyType.previousValue;
if (filterChanged || keyScopeChanged || keyTypeChanged) {
this.keyControl.setValue('', {emitEvent: false});
}
}
clear(): void {
this.keyControl.patchValue('', {emitEvent: true});
setTimeout(() => {
this.keyInput.nativeElement.blur();
this.keyInput.nativeElement.focus();
}, 0);
}
registerOnChange(onChange: (value: string) => void): void {
this.propagateChange = 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,
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();

134
ui-ngx/src/app/shared/models/calculated-field.models.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 { 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';
import { EntityType } from '@shared/models/entity-type.models';
export interface CalculatedField extends Omit<BaseData<CalculatedFieldId>, 'label'>, HasVersion, HasTenantId {
debugSettings?: EntityDebugSettings;
externalId?: string;
configuration: CalculatedFieldConfiguration;
type: CalculatedFieldType;
entityId: EntityId;
}
export enum CalculatedFieldType {
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;
}
export interface ArgumentEntityTypeParams {
title: string;
entityType: EntityType
}
export const ArgumentEntityTypeParamsMap =new Map<ArgumentEntityType, ArgumentEntityTypeParams>([
[ArgumentEntityType.Device, { title: 'calculated-fields.device-name', entityType: EntityType.DEVICE }],
[ArgumentEntityType.Asset, { title: 'calculated-fields.asset-name', entityType: EntityType.ASSET }],
[ArgumentEntityType.Customer, { title: 'calculated-fields.customer-name', entityType: EntityType.CUSTOMER }],
])

1
ui-ngx/src/app/shared/models/constants.ts

@ -196,6 +196,7 @@ export const HelpLinks = {
mobileApplication: `${helpBaseUrl}/docs${docPlatformPrefix}/mobile-center/applications/`,
mobileBundle: `${helpBaseUrl}/docs${docPlatformPrefix}/mobile-center/mobile-center/`,
mobileQrCode: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/mobile-qr-code/`,
calculatedField: `${helpBaseUrl}/docs${docPlatformPrefix}/`,
}
};
/* eslint-enable max-len */

15
ui-ngx/src/app/shared/models/entity-type.models.ts

@ -49,7 +49,8 @@ export enum EntityType {
OAUTH2_CLIENT = 'OAUTH2_CLIENT',
DOMAIN = 'DOMAIN',
MOBILE_APP_BUNDLE = 'MOBILE_APP_BUNDLE',
MOBILE_APP = 'MOBILE_APP'
MOBILE_APP = 'MOBILE_APP',
CALCULATED_FIELD = 'CALCULATED_FIELD',
}
export enum AliasEntityType {
@ -478,6 +479,18 @@ export const entityTypeTranslations = new Map<EntityType | AliasEntityType, Enti
noEntities: 'mobile.no-bundles',
search: 'mobile.search-bundles'
}
],
[
EntityType.CALCULATED_FIELD,
{
type: 'entity.type-calculated-field',
typePlural: 'entity.type-calculated-fields',
list: 'calculated-fields.list',
add: 'action.add',
noEntities: 'calculated-fields.no-found',
search: 'action.search',
selectedEntities: 'calculated-fields.selected-fields'
}
]
]
);

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

@ -0,0 +1,26 @@
///
/// 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 { EntityId } from './entity-id';
import { EntityType } from '@shared/models/entity-type.models';
export class CalculatedFieldId implements EntityId {
entityType = EntityType.CALCULATED_FIELD;
id: string;
constructor(id: string) {
this.id = id;
}
}

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 './usage.models';
export * from './query/query.models';
export * from './regex.constants';

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

@ -0,0 +1,19 @@
///
/// 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+)*$/;
export const charsWithNumRegex = /^[a-zA-Z]+[a-zA-Z0-9]*$/;

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 { 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 { }

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

@ -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}}.",
@ -1003,6 +1004,62 @@
"all-messages": "Save all debug events during time limit."
}
},
"calculated-fields": {
"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",
"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": "Time series 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": "Max values",
"time-window": "Time window",
"customer-name": "Customer name",
"asset-name": "Asset 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 time series 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?",
"html-message": "You have unsaved changes.<br/>Are you sure you want to leave this page?",
@ -1027,6 +1084,9 @@
"city-max-length": "Specified city should be less than 256"
},
"common": {
"name": "Name",
"type": "Type",
"general": "General",
"username": "Username",
"password": "Password",
"enter-username": "Enter username",
@ -1039,7 +1099,22 @@
"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.",
"title-required": "Title is required.",
"title-pattern": "Title is invalid.",
"title-max-length": "Title 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",
@ -2427,6 +2502,8 @@
"type-current-tenant": "Current Tenant",
"type-current-user": "Current User",
"type-current-user-owner": "Current User Owner",
"type-calculated-field": "Calculated Field",
"type-calculated-fields": "Calculated Fields",
"type-widgets-bundle": "Widgets bundle",
"type-widgets-bundles": "Widgets bundles",
"list-of-widgets-bundles": "{ count, plural, =1 {One widgets bundle} other {List of # widget bundles} }",

Loading…
Cancel
Save