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