24 changed files with 1572 additions and 117 deletions
@ -0,0 +1,106 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2024 The Thingsboard Authors |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
|||
|
|||
--> |
|||
<div class="tb-form-table mb-3"> |
|||
<div class="tb-form-table-header"> |
|||
<div class="tb-form-table-header-cell w-[17%]">{{ 'calculated-fields.argument-name' | translate }}</div> |
|||
<div class="tb-form-table-header-cell w-[35%]">{{ 'calculated-fields.datasource' | translate }}</div> |
|||
<div class="tb-form-table-header-cell w-[17%]">{{ 'common.type' | translate }}</div> |
|||
<div class="tb-form-table-header-cell w-[31%]">{{ 'entity.key' | translate }}</div> |
|||
</div> |
|||
<div class="tb-form-table-body tb-drop-list"> |
|||
@for (group of argumentsFormArray.controls; track group) { |
|||
<div [formGroup]="group" class="tb-form-table-row"> |
|||
<mat-form-field appearance="outline" class="tb-inline-field w-1/6" subscriptSizing="dynamic"> |
|||
<input matInput formControlName="argumentName" placeholder="{{ 'action.set' | translate }}"> |
|||
</mat-form-field> |
|||
@if (group.get('refEntityId')?.get('id').value) { |
|||
<ng-container [formGroup]="group.get('refEntityId')"> |
|||
<mat-form-field appearance="outline" class="tb-inline-field w-1/6" subscriptSizing="dynamic"> |
|||
<mat-select [value]="group.get('refEntityId').get('entityType').value" formControlName="entityType"> |
|||
<mat-option [value]="group.get('refEntityId').get('entityType').value"> |
|||
{{ entityTypeTranslations.get(group.get('refEntityId').get('entityType').value)?.type | translate }} |
|||
</mat-option> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
<tb-entity-autocomplete |
|||
class="inline-entity-autocomplete w-1/6" |
|||
formControlName="id" |
|||
[withLabel]="false" |
|||
[placeholder]="'action.set' | translate" |
|||
[appearance]="'outline'" |
|||
[subscriptSizing]="'dynamic'" |
|||
[entityType]="group.get('refEntityId').get('entityType').value"/> |
|||
</ng-container> |
|||
} @else { |
|||
<mat-form-field appearance="outline" class="tb-inline-field w-[calc(33.3%+12px)]" subscriptSizing="dynamic"> |
|||
<mat-select [value]="'current'" [disabled]="true"> |
|||
<mat-option [value]="'current'"> |
|||
{{ (group.get('refEntityId')?.get('entityType').value === ArgumentEntityType.Tenant |
|||
? 'calculated-fields.argument-current-tenant' |
|||
: 'calculated-fields.argument-current') | translate }} |
|||
</mat-option> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
} |
|||
<ng-container [formGroup]="group.get('refEntityKey')"> |
|||
<mat-form-field appearance="outline" class="tb-inline-field w-1/6" subscriptSizing="dynamic"> |
|||
<mat-select [value]="group.get('refEntityKey').get('type').value" formControlName="type"> |
|||
<mat-option [value]="group.get('refEntityKey').get('type').value"> |
|||
{{ ArgumentTypeTranslations.get(group.get('refEntityKey').get('type').value) | translate }} |
|||
</mat-option> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
<mat-chip-listbox formControlName="key" class="tb-inline-field w-1/6"> |
|||
<mat-chip> |
|||
<div tbTruncateWithTooltip class="max-w-25"> |
|||
{{group.get('refEntityKey').get('key').value}} |
|||
</div> |
|||
</mat-chip> |
|||
</mat-chip-listbox> |
|||
</ng-container> |
|||
<div class="flex opacity-55"> |
|||
<button type="button" |
|||
mat-icon-button |
|||
#button |
|||
(click)="manageArgument($event, button, $index)" |
|||
[matTooltip]="'action.edit' | translate" |
|||
matTooltipPosition="above"> |
|||
<mat-icon>edit</mat-icon> |
|||
</button> |
|||
<button type="button" |
|||
mat-icon-button |
|||
(click)="onDelete($index)" |
|||
[matTooltip]="'action.delete' | translate" |
|||
matTooltipPosition="above"> |
|||
<mat-icon>delete</mat-icon> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
} @empty { |
|||
<span class="tb-prompt flex items-center justify-center">{{ 'calculated-fields.no-arguments' | translate }}</span> |
|||
} |
|||
</div> |
|||
@if (errorText) { |
|||
<tb-error noMargin [error]="errorText | translate" class="pl-3"/> |
|||
} |
|||
</div> |
|||
<div> |
|||
<button type="button" mat-stroked-button color="primary" #button (click)="manageArgument($event, button)"> |
|||
{{ 'calculated-fields.add-argument' | translate }} |
|||
</button> |
|||
</div> |
|||
@ -0,0 +1,32 @@ |
|||
/** |
|||
* Copyright © 2016-2024 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0 |
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
:host ::ng-deep { |
|||
.inline-entity-autocomplete { |
|||
.mat-mdc-form-field-infix { |
|||
padding-top: 8px; |
|||
padding-bottom: 8px; |
|||
min-height: 40px; |
|||
width: auto; |
|||
.mdc-text-field__input, .mat-mdc-select { |
|||
font-weight: 400; |
|||
line-height: 20px; |
|||
} |
|||
} |
|||
a { |
|||
font-size: 14px; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,213 @@ |
|||
///
|
|||
/// Copyright © 2016-2024 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { |
|||
ChangeDetectorRef, |
|||
Component, |
|||
DestroyRef, |
|||
effect, |
|||
forwardRef, |
|||
input, |
|||
Input, |
|||
Renderer2, |
|||
ViewContainerRef, |
|||
} from '@angular/core'; |
|||
import { |
|||
AbstractControl, |
|||
ControlValueAccessor, |
|||
FormBuilder, |
|||
NG_VALIDATORS, |
|||
NG_VALUE_ACCESSOR, |
|||
ValidationErrors, |
|||
Validator, |
|||
Validators |
|||
} from '@angular/forms'; |
|||
import { |
|||
ArgumentEntityType, |
|||
ArgumentType, |
|||
ArgumentTypeTranslations, |
|||
CalculatedFieldArgument, |
|||
CalculatedFieldArgumentValue, |
|||
CalculatedFieldType, |
|||
} from '@shared/models/calculated-field.models'; |
|||
import { |
|||
CalculatedFieldArgumentPanelComponent |
|||
} from '@home/components/calculated-fields/components/panel/calculated-field-argument-panel.component'; |
|||
import { MatButton } from '@angular/material/button'; |
|||
import { TbPopoverService } from '@shared/components/popover.service'; |
|||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
|||
import { EntityId } from '@shared/models/id/entity-id'; |
|||
import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; |
|||
import { isDefinedAndNotNull } from '@core/utils'; |
|||
import { noLeadTrailSpacesRegex } from '@shared/models/regex.constants'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-calculated-field-arguments-table', |
|||
templateUrl: './calculated-field-arguments-table.component.html', |
|||
styleUrls: [`calculated-field-arguments-table.component.scss`], |
|||
providers: [ |
|||
{ |
|||
provide: NG_VALUE_ACCESSOR, |
|||
useExisting: forwardRef(() => CalculatedFieldArgumentsTableComponent), |
|||
multi: true |
|||
}, |
|||
{ |
|||
provide: NG_VALIDATORS, |
|||
useExisting: forwardRef(() => CalculatedFieldArgumentsTableComponent), |
|||
multi: true |
|||
} |
|||
], |
|||
}) |
|||
export class CalculatedFieldArgumentsTableComponent implements ControlValueAccessor, Validator { |
|||
|
|||
@Input() entityId: EntityId; |
|||
@Input() tenantId: string; |
|||
|
|||
calculatedFieldType = input<CalculatedFieldType>() |
|||
|
|||
errorText = ''; |
|||
argumentsFormArray = this.fb.array<AbstractControl>([]); |
|||
keysPopupClosed = true; |
|||
|
|||
readonly entityTypeTranslations = entityTypeTranslations; |
|||
readonly ArgumentTypeTranslations = ArgumentTypeTranslations; |
|||
readonly EntityType = EntityType; |
|||
readonly ArgumentEntityType = ArgumentEntityType; |
|||
|
|||
private onChange: (argumentsObj: Record<string, CalculatedFieldArgument>) => void = () => {}; |
|||
|
|||
constructor( |
|||
private fb: FormBuilder, |
|||
private popoverService: TbPopoverService, |
|||
private viewContainerRef: ViewContainerRef, |
|||
private destroyRef: DestroyRef, |
|||
private cd: ChangeDetectorRef, |
|||
private renderer: Renderer2 |
|||
) { |
|||
this.argumentsFormArray.valueChanges.pipe(takeUntilDestroyed()).subscribe(() => { |
|||
this.onChange(this.getArgumentsObject()); |
|||
}); |
|||
effect(() => this.calculatedFieldType() && this.argumentsFormArray.updateValueAndValidity()); |
|||
} |
|||
|
|||
registerOnChange(fn: (argumentsObj: Record<string, CalculatedFieldArgument>) => void): void { |
|||
this.onChange = fn; |
|||
} |
|||
|
|||
registerOnTouched(_): void {} |
|||
|
|||
validate(): ValidationErrors | null { |
|||
if (this.calculatedFieldType() === CalculatedFieldType.SIMPLE |
|||
&& this.argumentsFormArray.controls.some(control => control.get('refEntityKey').get('type').value === ArgumentType.Rolling)) { |
|||
this.errorText = 'calculated-fields.hint.arguments-simple-with-rolling'; |
|||
} else if (!this.argumentsFormArray.controls.length) { |
|||
this.errorText = 'calculated-fields.hint.arguments-empty'; |
|||
} else { |
|||
this.errorText = ''; |
|||
} |
|||
return this.errorText ? { argumentsFormArray: false } : null; |
|||
} |
|||
|
|||
private getArgumentsObject(): Record<string, CalculatedFieldArgument> { |
|||
return this.argumentsFormArray.controls.reduce((acc, control) => { |
|||
const rawValue = control.getRawValue(); |
|||
const { argumentName, ...argument } = rawValue as CalculatedFieldArgumentValue; |
|||
acc[argumentName] = argument; |
|||
return acc; |
|||
}, {} as Record<string, CalculatedFieldArgument>); |
|||
} |
|||
|
|||
writeValue(argumentsObj: Record<string, CalculatedFieldArgument>): void { |
|||
this.argumentsFormArray.clear(); |
|||
Object.keys(argumentsObj).forEach(key => { |
|||
this.argumentsFormArray.push(this.fb.group({ |
|||
argumentName: [key, [Validators.required, Validators.maxLength(255), Validators.pattern(noLeadTrailSpacesRegex)]], |
|||
...argumentsObj[key], |
|||
...(argumentsObj[key].refEntityId ? { |
|||
refEntityId: this.fb.group({ |
|||
entityType: [{ value: argumentsObj[key].refEntityId.entityType, disabled: true }], |
|||
id: [{ value: argumentsObj[key].refEntityId.id , disabled: true }], |
|||
}), |
|||
} : {}), |
|||
refEntityKey: this.fb.group({ |
|||
type: [{ value: argumentsObj[key].refEntityKey.type, disabled: true }], |
|||
key: [{ value: argumentsObj[key].refEntityKey.key, disabled: true }], |
|||
}), |
|||
}) as AbstractControl); |
|||
}); |
|||
} |
|||
|
|||
|
|||
manageArgument($event: Event, matButton: MatButton, index?: number): void { |
|||
$event?.stopPropagation(); |
|||
const trigger = matButton._elementRef.nativeElement; |
|||
if (this.popoverService.hasPopover(trigger)) { |
|||
this.popoverService.hidePopover(trigger); |
|||
} else { |
|||
const ctx = { |
|||
index, |
|||
argument: this.argumentsFormArray.at(index)?.getRawValue() ?? {}, |
|||
entityId: this.entityId, |
|||
calculatedFieldType: this.calculatedFieldType(), |
|||
buttonTitle: this.argumentsFormArray.at(index)?.value ? 'action.apply' : 'action.add', |
|||
tenantId: this.tenantId, |
|||
}; |
|||
this.keysPopupClosed = false; |
|||
const argumentsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, |
|||
this.viewContainerRef, CalculatedFieldArgumentPanelComponent, 'leftBottom', false, null, |
|||
ctx, |
|||
{}, |
|||
{}, {}, true); |
|||
argumentsPanelPopover.tbComponentRef.instance.popover = argumentsPanelPopover; |
|||
argumentsPanelPopover.tbComponentRef.instance.argumentsDataApplied.subscribe(({ value, index }) => { |
|||
argumentsPanelPopover.hide(); |
|||
const formGroup = this.getArgumentFormGroup(value); |
|||
if (isDefinedAndNotNull(index)) { |
|||
this.argumentsFormArray.setControl(index, formGroup); |
|||
} else { |
|||
this.argumentsFormArray.push(formGroup); |
|||
} |
|||
this.argumentsFormArray.markAsDirty(); |
|||
this.cd.markForCheck(); |
|||
}); |
|||
argumentsPanelPopover.tbHideStart.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { |
|||
this.keysPopupClosed = true; |
|||
}); |
|||
} |
|||
} |
|||
|
|||
getArgumentFormGroup(value: CalculatedFieldArgumentValue): AbstractControl { |
|||
return this.fb.group({ |
|||
...value, |
|||
argumentName: [value.argumentName, [Validators.required, Validators.maxLength(255), Validators.pattern(noLeadTrailSpacesRegex)]], |
|||
...(value.refEntityId ? { |
|||
refEntityId: this.fb.group({ |
|||
entityType: [{ value: value.refEntityId.entityType, disabled: true }], |
|||
id: [{ value: value.refEntityId.id , disabled: true }], |
|||
}), |
|||
} : {}), |
|||
refEntityKey: this.fb.group({ |
|||
type: [{ value: value.refEntityKey.type, disabled: true }], |
|||
key: [{ value: value.refEntityKey.key, disabled: true }], |
|||
}), |
|||
}) |
|||
} |
|||
|
|||
onDelete(index: number): void { |
|||
this.argumentsFormArray.removeAt(index); |
|||
this.argumentsFormArray.markAsDirty(); |
|||
} |
|||
} |
|||
@ -0,0 +1,169 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2024 The Thingsboard Authors |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
|||
|
|||
--> |
|||
<div [formGroup]="fieldFormGroup" class="h-full w-screen min-w-80 max-w-4xl"> |
|||
<mat-toolbar color="primary"> |
|||
<h2>{{ 'entity.type-calculated-field' | translate}}</h2> |
|||
<span class="flex-1"></span> |
|||
<div [tb-help]="helpLink"></div> |
|||
<button mat-icon-button |
|||
(click)="cancel()" |
|||
type="button"> |
|||
<mat-icon class="material-icons">close</mat-icon> |
|||
</button> |
|||
</mat-toolbar> |
|||
<div mat-dialog-content> |
|||
<div class="tb-form-panel no-border no-padding"> |
|||
<div class="tb-form-panel"> |
|||
<div class="tb-form-panel-title">{{ 'common.general' | translate }}</div> |
|||
<div class="flex items-center gap-2"> |
|||
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic"> |
|||
<mat-label>{{ 'entity-field.title' | translate }}</mat-label> |
|||
<input matInput maxlength="255" formControlName="name" required> |
|||
@if (fieldFormGroup.get('name').errors && fieldFormGroup.get('name').touched) { |
|||
<mat-error> |
|||
@if (fieldFormGroup.get('name').hasError('required')) { |
|||
{{ 'common.hint.name-required' | translate }} |
|||
} @else if (fieldFormGroup.get('name').hasError('pattern')) { |
|||
{{ 'common.hint.name-pattern' | translate }} |
|||
} @else if (fieldFormGroup.get('name').hasError('maxlength')) { |
|||
{{ 'common.hint.name-max-length' | translate }} |
|||
} |
|||
</mat-error> |
|||
} |
|||
</mat-form-field> |
|||
<tb-entity-debug-settings-button |
|||
formControlName="debugSettings" |
|||
[entityLabel]="'debug-settings.calculated-field' | translate" |
|||
[debugLimitsConfiguration]="data.debugLimitsConfiguration" |
|||
/> |
|||
</div> |
|||
<mat-form-field appearance="outline" subscriptSizing="dynamic"> |
|||
<mat-label>{{ 'common.type' | translate }}</mat-label> |
|||
<mat-select formControlName="type" required> |
|||
@for (type of fieldTypes; track type) { |
|||
<mat-option [value]="type">{{ CalculatedFieldTypeTranslations.get(type) | translate}}</mat-option> |
|||
} |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
<ng-container [formGroup]="configFormGroup"> |
|||
<div class="tb-form-panel"> |
|||
<div class="tb-form-panel-title">{{ 'calculated-fields.arguments' | translate }}</div> |
|||
<tb-calculated-field-arguments-table |
|||
formControlName="arguments" |
|||
[entityId]="data.entityId" |
|||
[tenantId]="data.tenantId" |
|||
[calculatedFieldType]="fieldFormGroup.get('type').valueChanges | async" |
|||
/> |
|||
</div> |
|||
<div class="tb-form-panel"> |
|||
<div class="tb-form-panel-title">{{ 'calculated-fields.expression' | translate }}*</div> |
|||
@if (fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE) { |
|||
<mat-form-field class="mat-block" appearance="outline"> |
|||
<input matInput formControlName="expressionSIMPLE" maxlength="255" [placeholder]="'action.set' | translate" required> |
|||
@if (configFormGroup.get('expressionSIMPLE').errors && configFormGroup.get('expressionSIMPLE').touched) { |
|||
<mat-error> |
|||
@if (configFormGroup.get('expressionSIMPLE').hasError('required')) { |
|||
{{ 'calculated-fields.hint.expression-required' | translate }} |
|||
} @else if (configFormGroup.get('expressionSIMPLE').hasError('pattern')) { |
|||
{{ 'calculated-fields.hint.expression-invalid' | translate }} |
|||
} @else if (configFormGroup.get('expressionSIMPLE').hasError('maxLength')) { |
|||
{{ 'calculated-fields.hint.expression-max-length' | translate }} |
|||
} |
|||
</mat-error> |
|||
} |
|||
</mat-form-field> |
|||
} @else { |
|||
<tb-js-func |
|||
required |
|||
formControlName="expressionSCRIPT" |
|||
functionName="calculate" |
|||
[functionArgs]="functionArgs$ | async" |
|||
[disableUndefinedCheck]="true" |
|||
[scriptLanguage]="ScriptLanguage.TBEL" |
|||
helpId="[TODO]: ADD VALID LINK HERE!!!" |
|||
/> |
|||
} |
|||
</div> |
|||
<div class="tb-form-panel" [formGroup]="outputFormGroup"> |
|||
<div class="tb-form-panel-title">{{ 'calculated-fields.output' | translate }}</div> |
|||
<div class="flex items-center gap-2"> |
|||
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic"> |
|||
<mat-label>{{ 'calculated-fields.output-type' | translate }}</mat-label> |
|||
<mat-select formControlName="type" required> |
|||
@for (type of outputTypes; track type) { |
|||
<mat-option [value]="type">{{ OutputTypeTranslations.get(type) | translate}}</mat-option> |
|||
} |
|||
</mat-select> |
|||
</mat-form-field> |
|||
@if (outputFormGroup.get('type').value === OutputType.Attribute) { |
|||
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic"> |
|||
<mat-label>{{ 'calculated-fields.output-type' | translate }}</mat-label> |
|||
<mat-select formControlName="scope" class="w-full"> |
|||
<mat-option [value]="AttributeScope.SERVER_SCOPE"> |
|||
{{ 'calculated-fields.server-attributes' | translate }} |
|||
</mat-option> |
|||
@if (data.entityId.entityType === EntityType.DEVICE) { |
|||
<mat-option [value]="AttributeScope.SHARED_SCOPE"> |
|||
{{ 'calculated-fields.shared-attributes' | translate }} |
|||
</mat-option> |
|||
} |
|||
</mat-select> |
|||
</mat-form-field> |
|||
} |
|||
</div> |
|||
@if (fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE) { |
|||
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic"> |
|||
<mat-label> |
|||
{{ (outputFormGroup.get('type').value === OutputType.Timeseries |
|||
? 'calculated-fields.timeseries-key' |
|||
: 'calculated-fields.attribute-key') |
|||
| translate }} |
|||
</mat-label> |
|||
<input matInput formControlName="name" required> |
|||
@if (outputFormGroup.get('name').errors && outputFormGroup.get('name').touched) { |
|||
<mat-error> |
|||
@if (outputFormGroup.get('name').hasError('required')) { |
|||
{{ 'common.hint.key-required' | translate }} |
|||
} @else if (outputFormGroup.get('name').hasError('pattern')) { |
|||
{{ 'common.hint.key-pattern' | translate }} |
|||
} @else if (outputFormGroup.get('name').hasError('maxlength')) { |
|||
{{ 'common.hint.key-max-length' | translate }} |
|||
} |
|||
</mat-error> |
|||
} |
|||
</mat-form-field> |
|||
} |
|||
</div> |
|||
</ng-container> |
|||
</div> |
|||
</div> |
|||
<div mat-dialog-actions class="justify-end"> |
|||
<button mat-button color="primary" |
|||
type="button" |
|||
cdkFocusInitial |
|||
(click)="cancel()"> |
|||
{{ 'action.cancel' | translate }} |
|||
</button> |
|||
<button mat-raised-button color="primary" |
|||
(click)="add()" |
|||
[disabled]="fieldFormGroup.invalid || !fieldFormGroup.dirty"> |
|||
{{ data.buttonTitle | translate }} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,122 @@ |
|||
///
|
|||
/// Copyright © 2016-2024 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { Component, Inject } from '@angular/core'; |
|||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { FormGroup, UntypedFormBuilder, Validators } from '@angular/forms'; |
|||
import { Router } from '@angular/router'; |
|||
import { DialogComponent } from '@shared/components/dialog.component'; |
|||
import { helpBaseUrl } from '@shared/models/constants'; |
|||
import { |
|||
CalculatedField, |
|||
CalculatedFieldConfiguration, |
|||
CalculatedFieldDialogData, |
|||
CalculatedFieldType, |
|||
CalculatedFieldTypeTranslations, |
|||
OutputType, |
|||
OutputTypeTranslations |
|||
} from '@shared/models/calculated-field.models'; |
|||
import { noLeadTrailSpacesRegex } from '@shared/models/regex.constants'; |
|||
import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; |
|||
import { EntityType } from '@shared/models/entity-type.models'; |
|||
import { map } from 'rxjs/operators'; |
|||
import { isObject } from '@core/utils'; |
|||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
|||
import { ScriptLanguage } from '@shared/models/rule-node.models'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-calculated-field-dialog', |
|||
templateUrl: './calculated-field-dialog.component.html', |
|||
}) |
|||
export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFieldDialogComponent, CalculatedField> { |
|||
|
|||
fieldFormGroup = this.fb.group({ |
|||
name: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex), Validators.maxLength(255)]], |
|||
type: [CalculatedFieldType.SIMPLE, [Validators.required]], |
|||
debugSettings: [], |
|||
configuration: this.fb.group({ |
|||
arguments: [{}], |
|||
expressionSIMPLE: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex), Validators.maxLength(255)]], |
|||
expressionSCRIPT: [], |
|||
output: this.fb.group({ |
|||
name: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex), Validators.maxLength(255)]], |
|||
scope: [{ value: AttributeScope.SERVER_SCOPE, disabled: true }], |
|||
type: [OutputType.Timeseries] |
|||
}), |
|||
}), |
|||
}); |
|||
|
|||
functionArgs$ = this.fieldFormGroup.get('configuration').valueChanges |
|||
.pipe( |
|||
map(configuration => isObject(configuration?.arguments) ? Object.keys(configuration.arguments) : []) |
|||
); |
|||
|
|||
readonly OutputTypeTranslations = OutputTypeTranslations; |
|||
readonly OutputType = OutputType; |
|||
readonly AttributeScope = AttributeScope; |
|||
readonly EntityType = EntityType; |
|||
readonly CalculatedFieldType = CalculatedFieldType; |
|||
readonly ScriptLanguage = ScriptLanguage; |
|||
readonly helpLink = `${helpBaseUrl}/[TODO: ADD VALID LINK!!!]`; |
|||
readonly fieldTypes = Object.values(CalculatedFieldType) as CalculatedFieldType[]; |
|||
readonly outputTypes = Object.values(OutputType) as OutputType[]; |
|||
readonly CalculatedFieldTypeTranslations = CalculatedFieldTypeTranslations; |
|||
|
|||
constructor(protected store: Store<AppState>, |
|||
protected router: Router, |
|||
@Inject(MAT_DIALOG_DATA) public data: CalculatedFieldDialogData, |
|||
public dialogRef: MatDialogRef<CalculatedFieldDialogComponent, CalculatedField>, |
|||
public fb: UntypedFormBuilder) { |
|||
super(store, router, dialogRef); |
|||
this.applyDialogData(); |
|||
this.outputFormGroup.get('type').valueChanges |
|||
.pipe(takeUntilDestroyed()) |
|||
.subscribe(type => this.toggleScopeByOutputType(type)); |
|||
this.toggleScopeByOutputType(this.outputFormGroup.get('type').value); |
|||
} |
|||
|
|||
get configFormGroup(): FormGroup { |
|||
return this.fieldFormGroup.get('configuration') as FormGroup; |
|||
} |
|||
|
|||
get outputFormGroup(): FormGroup { |
|||
return this.fieldFormGroup.get('configuration').get('output') as FormGroup; |
|||
} |
|||
|
|||
cancel(): void { |
|||
this.dialogRef.close(null); |
|||
} |
|||
|
|||
add(): void { |
|||
if (this.fieldFormGroup.valid) { |
|||
const { configuration, type, ...rest } = this.fieldFormGroup.value; |
|||
const { expressionSIMPLE, expressionSCRIPT, ...restConfig } = configuration; |
|||
this.dialogRef.close({ configuration: { ...restConfig, type, expression: configuration['expression'+type] }, ...rest, type }); |
|||
} |
|||
} |
|||
|
|||
private applyDialogData(): void { |
|||
const { configuration = {}, type = CalculatedFieldType.SIMPLE, ...value } = this.data.value; |
|||
const { expression, ...restConfig } = configuration as CalculatedFieldConfiguration; |
|||
this.fieldFormGroup.patchValue({ configuration: { ...restConfig, ['expression'+type]: expression }, ...value }); |
|||
} |
|||
|
|||
private toggleScopeByOutputType(type: OutputType): void { |
|||
this.outputFormGroup.get('scope')[type === OutputType.Attribute? 'enable' : 'disable']({emitEvent: false}); |
|||
} |
|||
} |
|||
@ -0,0 +1,198 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2024 The Thingsboard Authors |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
|||
|
|||
--> |
|||
<div class="w-screen max-w-xl" [formGroup]="argumentFormGroup"> |
|||
<div class="tb-form-panel no-border no-padding mb-2"> |
|||
<div class="tb-form-panel-title">{{ 'calculated-fields.argument-settings' | translate }}</div> |
|||
<div class="tb-form-panel no-border no-padding"> |
|||
<div class="tb-form-row"> |
|||
<div class="fixed-title-width tb-required">{{ 'calculated-fields.argument-name' | translate }}</div> |
|||
<div class="tb-flex no-gap"> |
|||
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic"> |
|||
<input matInput name="value" formControlName="argumentName" maxlength="255" placeholder="{{ 'action.set' | translate }}"/> |
|||
@if (argumentFormGroup.get('argumentName').hasError('required') && argumentFormGroup.get('argumentName').touched) { |
|||
<mat-icon matSuffix |
|||
matTooltipPosition="above" |
|||
matTooltipClass="tb-error-tooltip" |
|||
[matTooltip]="'calculated-fields.hint.argument-name-required' | translate" |
|||
class="tb-error"> |
|||
warning |
|||
</mat-icon> |
|||
} @else if (argumentFormGroup.get('argumentName').hasError('pattern') && argumentFormGroup.get('argumentName').touched) { |
|||
<mat-icon matSuffix |
|||
matTooltipPosition="above" |
|||
matTooltipClass="tb-error-tooltip" |
|||
[matTooltip]="'calculated-fields.hint.argument-name-pattern' | translate" |
|||
class="tb-error"> |
|||
warning |
|||
</mat-icon> |
|||
} @else if (argumentFormGroup.get('argumentName').hasError('maxlength') && argumentFormGroup.get('argumentName').touched) { |
|||
<mat-icon matSuffix |
|||
matTooltipPosition="above" |
|||
matTooltipClass="tb-error-tooltip" |
|||
[matTooltip]="'calculated-fields.hint.argument-name-max-length' | translate" |
|||
class="tb-error"> |
|||
warning |
|||
</mat-icon> |
|||
} |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
<ng-container [formGroup]="refEntityIdFormGroup"> |
|||
<div class="tb-form-row"> |
|||
<div class="fixed-title-width tb-required">{{ 'entity.entity-type' | translate }}</div> |
|||
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic"> |
|||
<mat-select formControlName="entityType"> |
|||
@for (type of argumentEntityTypes; track type) { |
|||
<mat-option [value]="type">{{ ArgumentEntityTypeTranslations.get(type) | translate }}</mat-option> |
|||
} |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
@if (entityType === ArgumentEntityType.Device || entityType === ArgumentEntityType.Asset) { |
|||
<div class="tb-form-row"> |
|||
<div class="fixed-title-width tb-required">{{ 'calculated-fields.device-name' | translate }}</div> |
|||
<tb-entity-autocomplete |
|||
class="entity-autocomplete w-full" |
|||
formControlName="id" |
|||
[withLabel]="false" |
|||
[placeholder]="'action.set' | translate" |
|||
[appearance]="'outline'" |
|||
[required]="true" |
|||
[iconError]="true" |
|||
[subscriptSizing]="'dynamic'" |
|||
[entityType]="EntityType.DEVICE" |
|||
/> |
|||
</div> |
|||
} @else if (entityType === ArgumentEntityType.Customer) { |
|||
<div class="tb-form-row"> |
|||
<div class="fixed-title-width tb-required">{{ 'calculated-fields.customer-name' | translate }}</div> |
|||
<tb-entity-autocomplete |
|||
class="entity-autocomplete w-full" |
|||
formControlName="id" |
|||
[withLabel]="false" |
|||
[placeholder]="'action.set' | translate" |
|||
[appearance]="'outline'" |
|||
[iconError]="true" |
|||
[required]="true" |
|||
[subscriptSizing]="'dynamic'" |
|||
[entityType]="EntityType.CUSTOMER" |
|||
/> |
|||
</div> |
|||
} |
|||
</ng-container> |
|||
<ng-container [formGroup]="refEntityKeyFormGroup"> |
|||
<div class="tb-form-row"> |
|||
<div class="fixed-title-width tb-required">{{ 'calculated-fields.argument-type' | translate }}</div> |
|||
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic"> |
|||
<mat-select formControlName="type"> |
|||
@for (type of argumentTypes; track type) { |
|||
<mat-option [value]="type">{{ ArgumentTypeTranslations.get(type) | translate }}</mat-option> |
|||
} |
|||
</mat-select> |
|||
@if (refEntityKeyFormGroup.get('type').hasError('required') && refEntityKeyFormGroup.get('type').touched) { |
|||
<mat-icon matSuffix |
|||
matTooltipPosition="above" |
|||
matTooltipClass="tb-error-tooltip" |
|||
[matTooltip]="'calculated-fields.hint.argument-type-required' | translate" |
|||
class="tb-error"> |
|||
warning |
|||
</mat-icon> |
|||
} |
|||
</mat-form-field> |
|||
</div> |
|||
@if (refEntityKeyFormGroup.get('type').value !== ArgumentType.Attribute) { |
|||
<div class="tb-form-row"> |
|||
<div class="fixed-title-width tb-required">{{ 'calculated-fields.timeseries-key' | translate }}</div> |
|||
<tb-entity-key-autocomplete formControlName="key" [dataKeyType]="DataKeyType.timeseries" [entityFilter]="entityFilter"/> |
|||
</div> |
|||
} @else { |
|||
<div class="tb-form-row"> |
|||
<div class="fixed-title-width tb-required">{{ 'calculated-fields.attribute-scope' | translate }}</div> |
|||
<mat-form-field appearance="outline" subscriptSizing="dynamic"> |
|||
<mat-select formControlName="scope" class="w-full"> |
|||
<mat-option [value]="AttributeScope.SERVER_SCOPE"> |
|||
{{ 'calculated-fields.server-attributes' | translate }} |
|||
</mat-option> |
|||
@if ((keyEntityType$ | async) === EntityType.DEVICE) { |
|||
<mat-option [value]="AttributeScope.CLIENT_SCOPE"> |
|||
{{ 'calculated-fields.client-attributes' | translate }} |
|||
</mat-option> |
|||
<mat-option [value]="AttributeScope.SHARED_SCOPE"> |
|||
{{ 'calculated-fields.shared-attributes' | translate }} |
|||
</mat-option> |
|||
} |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
<div class="tb-form-row"> |
|||
<div class="fixed-title-width tb-required">{{ 'calculated-fields.attribute-key' | translate }}</div> |
|||
<tb-entity-key-autocomplete |
|||
formControlName="key" |
|||
[dataKeyType]="DataKeyType.attribute" |
|||
[entityFilter]="entityFilter" |
|||
[keyScopeType]="argumentFormGroup.get('refEntityKey').get('scope').value" |
|||
/> |
|||
</div> |
|||
} |
|||
</ng-container> |
|||
@if (refEntityKeyFormGroup.get('type').value !== ArgumentType.Rolling) { |
|||
<div class="tb-form-row"> |
|||
<div class="fixed-title-width">{{ 'calculated-fields.default-value' | translate }}</div> |
|||
<div class="tb-flex no-gap"> |
|||
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic"> |
|||
<input matInput autocomplete="off" name="value" formControlName="defaultValue" placeholder="{{ 'action.set' | translate }}"/> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
} @else { |
|||
<div class="tb-form-row"> |
|||
<div class="fixed-title-width">{{ 'calculated-fields.time-window' | translate }}</div> |
|||
<div class="tb-flex no-gap"> |
|||
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic"> |
|||
<input matInput type="number" min="0" name="value" formControlName="timeWindow" placeholder="{{ 'action.set' | translate }}"/> |
|||
<span class="block pr-2" matSuffix>{{ 'common.suffix.ms' | translate }}</span> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
<div class="tb-form-row"> |
|||
<div class="fixed-title-width">{{ 'calculated-fields.limit' | translate }}</div> |
|||
<div class="tb-flex no-gap"> |
|||
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic"> |
|||
<input matInput type="number" min="0" name="value" formControlName="limit" placeholder="{{ 'action.set' | translate }}"/> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
} |
|||
</div> |
|||
</div> |
|||
<div class="tb-flex flex-end"> |
|||
<button mat-button |
|||
color="primary" |
|||
type="button" |
|||
(click)="cancel()"> |
|||
{{ 'action.cancel' | translate }} |
|||
</button> |
|||
<button mat-raised-button |
|||
color="primary" |
|||
type="button" |
|||
(click)="saveArgument()" |
|||
[disabled]="argumentFormGroup.invalid || !argumentFormGroup.dirty"> |
|||
{{ buttonTitle | translate }} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,29 @@ |
|||
/** |
|||
* Copyright © 2016-2024 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0 |
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
:host { |
|||
.mat-mdc-form-field { |
|||
width: 100%; |
|||
} |
|||
} |
|||
|
|||
:host ::ng-deep { |
|||
.entity-autocomplete { |
|||
.mat-mdc-form-field { |
|||
width: 100%; |
|||
} |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,201 @@ |
|||
///
|
|||
/// Copyright © 2016-2024 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { Component, ElementRef, Input, OnInit, output, ViewChild } from '@angular/core'; |
|||
import { TbPopoverComponent } from '@shared/components/popover.component'; |
|||
import { PageComponent } from '@shared/components/page.component'; |
|||
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; |
|||
import { noLeadTrailSpacesRegex } from '@shared/models/regex.constants'; |
|||
import { |
|||
ArgumentEntityType, |
|||
ArgumentEntityTypeTranslations, |
|||
ArgumentType, |
|||
ArgumentTypeTranslations, |
|||
CalculatedFieldArgumentValue, |
|||
CalculatedFieldType |
|||
} from '@shared/models/calculated-field.models'; |
|||
import { debounceTime, delay, distinctUntilChanged, filter, map, startWith } from 'rxjs/operators'; |
|||
import { EntityType } from '@shared/models/entity-type.models'; |
|||
import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models'; |
|||
import { DatasourceType } from '@shared/models/widget.models'; |
|||
import { EntityId } from '@shared/models/id/entity-id'; |
|||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
|||
import { EntityFilter } from '@shared/models/query/query.models'; |
|||
import { AliasFilterType } from '@shared/models/alias.models'; |
|||
import { merge } from 'rxjs'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-calculated-field-argument-panel', |
|||
templateUrl: './calculated-field-argument-panel.component.html', |
|||
styleUrls: ['./calculated-field-argument-panel.component.scss'] |
|||
}) |
|||
export class CalculatedFieldArgumentPanelComponent extends PageComponent implements OnInit { |
|||
|
|||
@Input() popover: TbPopoverComponent<CalculatedFieldArgumentPanelComponent>; |
|||
@Input() buttonTitle: string; |
|||
@Input() index: number; |
|||
@Input() argument: CalculatedFieldArgumentValue; |
|||
@Input() entityId: EntityId; |
|||
@Input() tenantId: string; |
|||
@Input() calculatedFieldType: CalculatedFieldType; |
|||
|
|||
@ViewChild('timeseriesInput') timeseriesInput: ElementRef; |
|||
|
|||
argumentsDataApplied = output<{ value: CalculatedFieldArgumentValue, index: number }>(); |
|||
|
|||
argumentFormGroup = this.fb.group({ |
|||
argumentName: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex), Validators.maxLength(255)]], |
|||
refEntityId: this.fb.group({ |
|||
entityType: [ArgumentEntityType.Current], |
|||
id: [''] |
|||
}), |
|||
refEntityKey: this.fb.group({ |
|||
type: [ArgumentType.LatestTelemetry, [Validators.required]], |
|||
key: [''], |
|||
scope: [{ value: AttributeScope.SERVER_SCOPE, disabled: true }], |
|||
}), |
|||
defaultValue: ['', [Validators.pattern(noLeadTrailSpacesRegex)]], |
|||
limit: [null], |
|||
timeWindow: [null], |
|||
}); |
|||
|
|||
argumentTypes: ArgumentType[]; |
|||
entityFilter: EntityFilter; |
|||
keyEntityType$ = this.refEntityIdFormGroup.get('entityType').valueChanges |
|||
.pipe( |
|||
startWith(this.refEntityIdFormGroup.get('entityType').value), |
|||
map(type => type === ArgumentEntityType.Current ? this.entityId.entityType : type) |
|||
); |
|||
|
|||
readonly argumentEntityTypes = Object.values(ArgumentEntityType) as ArgumentEntityType[]; |
|||
readonly ArgumentEntityTypeTranslations = ArgumentEntityTypeTranslations; |
|||
readonly ArgumentType = ArgumentType; |
|||
readonly DataKeyType = DataKeyType; |
|||
readonly EntityType = EntityType; |
|||
readonly datasourceType = DatasourceType; |
|||
readonly ArgumentTypeTranslations = ArgumentTypeTranslations; |
|||
readonly AttributeScope = AttributeScope; |
|||
|
|||
constructor( |
|||
private fb: FormBuilder, |
|||
) { |
|||
super(); |
|||
|
|||
this.observeEntityFilterChanges(); |
|||
this.observeEntityTypeChanges() |
|||
this.observeEntityKeyChanges(); |
|||
} |
|||
|
|||
get entityType(): ArgumentEntityType { |
|||
return this.argumentFormGroup.get('refEntityId').get('entityType').value; |
|||
} |
|||
|
|||
get refEntityIdFormGroup(): FormGroup { |
|||
return this.argumentFormGroup.get('refEntityId') as FormGroup; |
|||
} |
|||
|
|||
get refEntityKeyFormGroup(): FormGroup { |
|||
return this.argumentFormGroup.get('refEntityKey') as FormGroup; |
|||
} |
|||
|
|||
ngOnInit(): void { |
|||
this.argumentFormGroup.patchValue(this.argument, {emitEvent: false}); |
|||
this.updateEntityFilter(this.argument.refEntityId?.entityType, true); |
|||
this.toggleByEntityKeyType(this.argument.refEntityKey?.type); |
|||
this.setInitialEntityKeyType(); |
|||
|
|||
this.argumentTypes = Object.values(ArgumentType) |
|||
.filter(type => type !== ArgumentType.Rolling || this.calculatedFieldType === CalculatedFieldType.SCRIPT); |
|||
} |
|||
|
|||
saveArgument(): void { |
|||
this.argumentsDataApplied.emit({ value: this.argumentFormGroup.value as CalculatedFieldArgumentValue, index: this.index }); |
|||
} |
|||
|
|||
cancel(): void { |
|||
this.popover.hide(); |
|||
} |
|||
|
|||
private toggleByEntityKeyType(type: ArgumentType): void { |
|||
const isAttribute = type === ArgumentType.Attribute; |
|||
const isRolling = type === ArgumentType.Rolling; |
|||
this.argumentFormGroup.get('refEntityKey').get('scope')[isAttribute? 'enable' : 'disable']({ emitEvent: false }); |
|||
this.argumentFormGroup.get('limit')[isRolling? 'enable' : 'disable']({ emitEvent: false }); |
|||
this.argumentFormGroup.get('timeWindow')[isRolling? 'enable' : 'disable']({ emitEvent: false }); |
|||
this.argumentFormGroup.get('defaultValue')[isRolling? 'disable' : 'enable']({ emitEvent: false }); |
|||
} |
|||
|
|||
private updateEntityFilter(entityType: ArgumentEntityType, onInit = false): void { |
|||
let entityId: EntityId; |
|||
switch (entityType) { |
|||
case ArgumentEntityType.Current: |
|||
entityId = this.entityId |
|||
break; |
|||
case ArgumentEntityType.Tenant: |
|||
entityId = { |
|||
id: this.tenantId, |
|||
entityType: EntityType.TENANT |
|||
}; |
|||
break; |
|||
default: |
|||
entityId = this.argumentFormGroup.get('refEntityId').value as any; |
|||
} |
|||
if (onInit) { |
|||
this.argumentFormGroup.get('refEntityKey').get('key').setValue(''); |
|||
} |
|||
this.entityFilter = { |
|||
type: AliasFilterType.singleEntity, |
|||
singleEntity: entityId, |
|||
}; |
|||
} |
|||
|
|||
private observeEntityFilterChanges(): void { |
|||
merge( |
|||
this.argumentFormGroup.get('refEntityId').get('entityType').valueChanges, |
|||
this.argumentFormGroup.get('refEntityId').get('id').valueChanges.pipe(filter(Boolean)), |
|||
this.argumentFormGroup.get('refEntityKey').get('scope').valueChanges, |
|||
) |
|||
.pipe(debounceTime(300), delay(50), takeUntilDestroyed()) |
|||
.subscribe(() => this.updateEntityFilter(this.entityType)); |
|||
} |
|||
|
|||
private observeEntityTypeChanges(): void { |
|||
this.argumentFormGroup.get('refEntityId').get('entityType').valueChanges |
|||
.pipe(distinctUntilChanged(), takeUntilDestroyed()) |
|||
.subscribe(type => { |
|||
this.argumentFormGroup.get('refEntityId').get('id').setValue(''); |
|||
this.argumentFormGroup.get('refEntityId') |
|||
.get('id')[type === ArgumentEntityType.Tenant || type === ArgumentEntityType.Current ? 'disable' : 'enable'](); |
|||
}); |
|||
} |
|||
|
|||
private observeEntityKeyChanges(): void { |
|||
this.argumentFormGroup.get('refEntityKey').get('type').valueChanges |
|||
.pipe(takeUntilDestroyed()) |
|||
.subscribe(type => this.toggleByEntityKeyType(type)); |
|||
} |
|||
|
|||
private setInitialEntityKeyType(): void { |
|||
if (this.calculatedFieldType === CalculatedFieldType.SIMPLE && this.argument.refEntityKey?.type === ArgumentType.Rolling) { |
|||
const typeControl = this.argumentFormGroup.get('refEntityKey').get('type'); |
|||
typeControl.setValue(null); |
|||
typeControl.markAsTouched(); |
|||
typeControl.updateValueAndValidity(); |
|||
} |
|||
} |
|||
|
|||
protected readonly ArgumentEntityType = ArgumentEntityType; |
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
///
|
|||
/// Copyright © 2016-2024 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
export * from './dialog/calculated-field-dialog.component'; |
|||
export * from './arguments-table/calculated-field-arguments-table.component'; |
|||
@ -0,0 +1,39 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2024 The Thingsboard Authors |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
|||
|
|||
--> |
|||
<mat-form-field class="tb-flex no-gap !w-full" appearance="outline" subscriptSizing="dynamic"> |
|||
<input matInput type="text" placeholder="{{ 'entity.key-name' | translate }}" |
|||
#keyInput |
|||
[formControl]="keyControl" |
|||
required |
|||
(focusin)="keyInputSubject.next()" |
|||
[matAutocomplete]="keysAutocomplete"> |
|||
@if (keyControl.value) { |
|||
<button type="button" |
|||
matSuffix mat-icon-button aria-label="Clear" |
|||
(click)="clear()"> |
|||
<mat-icon class="material-icons">close</mat-icon> |
|||
</button> |
|||
} |
|||
<mat-autocomplete |
|||
class="tb-autocomplete" |
|||
#keysAutocomplete="matAutocomplete"> |
|||
@for (key of filteredKeys$ | async; track key) { |
|||
<mat-option [value]="key"><span [innerHTML]="key | highlight: searchText"></span></mat-option> |
|||
} |
|||
</mat-autocomplete> |
|||
</mat-form-field> |
|||
@ -0,0 +1,134 @@ |
|||
///
|
|||
/// Copyright © 2016-2024 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { Component, effect, ElementRef, forwardRef, input, ViewChild, } from '@angular/core'; |
|||
import { |
|||
ControlValueAccessor, |
|||
FormBuilder, |
|||
NG_VALIDATORS, |
|||
NG_VALUE_ACCESSOR, |
|||
ValidationErrors, |
|||
Validator, |
|||
Validators |
|||
} from '@angular/forms'; |
|||
import { map, startWith, switchMap } from 'rxjs/operators'; |
|||
import { combineLatest, of, Subject } from 'rxjs'; |
|||
import { EntityService } from '@core/http/entity.service'; |
|||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
|||
import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models'; |
|||
import { EntitiesKeysByQuery } from '@shared/models/entity.models'; |
|||
import { EntityFilter } from '@shared/models/query/query.models'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-entity-key-autocomplete', |
|||
templateUrl: './entity-key-autocomplete.component.html', |
|||
providers: [ |
|||
{ |
|||
provide: NG_VALUE_ACCESSOR, |
|||
useExisting: forwardRef(() => EntityKeyAutocompleteComponent), |
|||
multi: true |
|||
}, |
|||
{ |
|||
provide: NG_VALIDATORS, |
|||
useExisting: forwardRef(() => EntityKeyAutocompleteComponent), |
|||
multi: true |
|||
} |
|||
], |
|||
host: { |
|||
class: 'w-full' |
|||
} |
|||
}) |
|||
export class EntityKeyAutocompleteComponent implements ControlValueAccessor, Validator { |
|||
|
|||
@ViewChild('keyInput') keyInput: ElementRef; |
|||
|
|||
entityFilter = input.required<EntityFilter>(); |
|||
dataKeyType = input.required<DataKeyType>(); |
|||
keyScopeType = input<AttributeScope>(); |
|||
|
|||
keyControl = this.fb.control('', [Validators.required]); |
|||
searchText = ''; |
|||
keyInputSubject = new Subject<void>(); |
|||
|
|||
private onChange: (value: string) => void; |
|||
private cachedResult: EntitiesKeysByQuery; |
|||
|
|||
keys$ = this.keyInputSubject.asObservable() |
|||
.pipe( |
|||
switchMap(() => { |
|||
return this.cachedResult ? of(this.cachedResult) : this.entityService.findEntityKeysByQuery({ |
|||
pageLink: { page: 0, pageSize: 100 }, |
|||
entityFilter: this.entityFilter(), |
|||
}, this.dataKeyType() === DataKeyType.attribute, this.dataKeyType() === DataKeyType.timeseries, this.keyScopeType()); |
|||
}), |
|||
map(result => { |
|||
this.cachedResult = result; |
|||
switch (this.dataKeyType()) { |
|||
case DataKeyType.attribute: |
|||
return result.attribute; |
|||
case DataKeyType.timeseries: |
|||
return result.timeseries; |
|||
default: |
|||
return []; |
|||
} |
|||
}), |
|||
); |
|||
|
|||
filteredKeys$ = combineLatest([this.keys$, this.keyControl.valueChanges.pipe(startWith(''))]) |
|||
.pipe( |
|||
map(([keys, searchText = '']) => { |
|||
this.searchText = searchText; |
|||
return searchText ? keys.filter(item => item.toLowerCase().includes(searchText.toLowerCase())) : keys; |
|||
}) |
|||
); |
|||
|
|||
constructor( |
|||
private fb: FormBuilder, |
|||
private entityService: EntityService, |
|||
) { |
|||
this.keyControl.valueChanges |
|||
.pipe(takeUntilDestroyed()) |
|||
.subscribe(value => this.onChange(value)); |
|||
effect(() => { |
|||
if (this.keyScopeType() || this.entityFilter() && this.dataKeyType()) { |
|||
this.cachedResult = null; |
|||
this.searchText = ''; |
|||
} |
|||
}); |
|||
} |
|||
|
|||
clear(): void { |
|||
this.keyControl.patchValue('', {emitEvent: true}); |
|||
setTimeout(() => { |
|||
this.keyInput.nativeElement.blur(); |
|||
this.keyInput.nativeElement.focus(); |
|||
}, 0); |
|||
} |
|||
|
|||
registerOnChange(onChange: (value: string) => void): void { |
|||
this.onChange = onChange; |
|||
} |
|||
|
|||
registerOnTouched(_): void {} |
|||
|
|||
validate(): ValidationErrors | null { |
|||
return this.keyControl.valid ? null : { keyControl: false }; |
|||
} |
|||
|
|||
writeValue(value: string): void { |
|||
this.keyControl.patchValue(value, {emitEvent: false}); |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
///
|
|||
/// Copyright © 2016-2024 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
export const noLeadTrailSpacesRegex = /^\S+(?: \S+)*$/; |
|||
Loading…
Reference in new issue