committed by
GitHub
32 changed files with 1978 additions and 34 deletions
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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()); |
||||
|
} |
||||
|
} |
||||
@ -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> |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -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> |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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 }], |
||||
|
}), |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
@ -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> |
||||
@ -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}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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> |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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'; |
||||
@ -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> |
||||
@ -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}); |
||||
|
} |
||||
|
} |
||||
@ -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 }], |
||||
|
]) |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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]*$/; |
||||
Loading…
Reference in new issue