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