diff --git a/.github/release.yml b/.github/release.yml index 5e0ddc4300..53b2e3ae65 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -1,5 +1,5 @@ # -# Copyright © 2016-2024 The Thingsboard Authors +# Copyright © 2016-2025 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. diff --git a/.github/workflows/check-configuration-files.yml b/.github/workflows/check-configuration-files.yml index 94bfc85ce3..f280e326c2 100644 --- a/.github/workflows/check-configuration-files.yml +++ b/.github/workflows/check-configuration-files.yml @@ -1,5 +1,5 @@ # -# Copyright © 2016-2024 The Thingsboard Authors +# Copyright © 2016-2025 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. diff --git a/.github/workflows/license-header-format.yml b/.github/workflows/license-header-format.yml index 20b5e7f063..20a1c1350a 100644 --- a/.github/workflows/license-header-format.yml +++ b/.github/workflows/license-header-format.yml @@ -1,5 +1,5 @@ # -# Copyright © 2016-2024 The Thingsboard Authors +# Copyright © 2016-2025 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. diff --git a/application/pom.xml b/application/pom.xml index d97f6d92e3..c6058408f8 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -1,6 +1,6 @@ + + + + Device + + 3 + urn:oma:lwm2m:oma:3 + Single + Mandatory + + + Manufacturer + R + Single + Optional + String + + + + + + Model Number + R + Single + Optional + String + + + + + + Serial Number + R + Single + Optional + String + + + + + + Firmware Version + R + Single + Optional + String + + + + + + Reboot + E + Single + Mandatory + + + + + + + Factory Reset + E + Single + Optional + + + + + + + Available Power Sources + R + Multiple + Optional + Integer + 0-7 + + + + + Power Source Voltage + R + Multiple + Optional + Integer + + mV + + + + Power Source Current + R + Multiple + Optional + Integer + + mA + + + + Battery Level + R + Single + Optional + Integer + 0-100 + % + + + + Memory Free + R + Single + Optional + Integer + + KB + + + + Error Code + R + Multiple + Mandatory + Integer + 0-8 + + + + + Reset Error Code + E + Single + Optional + + + + + + + Current Time + RW + Single + Optional + Time + + + + + + UTC Offset + RW + Single + Optional + String + + + + + + Timezone + RW + Single + Optional + String + + + + + + Supported Binding and Modes + R + Single + Mandatory + String + + + + + Device Type + R + Single + Optional + String + + + + + Hardware Version + R + Single + Optional + String + + + + + Software Version + R + Single + Optional + String + + + + + Battery Status + R + Single + Optional + Integer + 0-6 + + + + Memory Total + R + Single + Optional + Integer + + + + + ExtDevInfo + R + Multiple + Optional + Objlnk + + + + + + + diff --git a/application/src/test/resources/lwm2m/3-1_1.xml b/application/src/test/resources/lwm2m/3-1_1.xml new file mode 100644 index 0000000000..01151a57b7 --- /dev/null +++ b/application/src/test/resources/lwm2m/3-1_1.xml @@ -0,0 +1,331 @@ + + + + + + + Device + + 3 + urn:oma:lwm2m:oma:3:1.1 + 1.1 + 1.1 + Single + Mandatory + + + Manufacturer + R + Single + Optional + String + + + + + + Model Number + R + Single + Optional + String + + + + + + Serial Number + R + Single + Optional + String + + + + + + Firmware Version + R + Single + Optional + String + + + + + + Reboot + E + Single + Mandatory + + + + + + + Factory Reset + E + Single + Optional + + + + + + + Available Power Sources + R + Multiple + Optional + Integer + 0..7 + + + + + Power Source Voltage + R + Multiple + Optional + Integer + + + + + + Power Source Current + R + Multiple + Optional + Integer + + + + + + Battery Level + R + Single + Optional + Integer + 0..100 + % + + + + Memory Free + R + Single + Optional + Integer + + + + + + Error Code + R + Multiple + Mandatory + Integer + 0..8 + + + + + Reset Error Code + E + Single + Optional + + + + + + + Current Time + RW + Single + Optional + Time + + + + + + UTC Offset + RW + Single + Optional + String + + + + + + Timezone + RW + Single + Optional + String + + + + + + Supported Binding and Modes + R + Single + Mandatory + String + + + + + Device Type + R + Single + Optional + String + + + + + Hardware Version + R + Single + Optional + String + + + + + Software Version + R + Single + Optional + String + + + + + Battery Status + R + Single + Optional + Integer + 0..6 + + + + Memory Total + R + Single + Optional + Integer + + + + + ExtDevInfo + R + Multiple + Optional + Objlnk + + + + + + + diff --git a/build.sh b/build.sh index 2c6a7d23fa..22258fbd2e 100755 --- a/build.sh +++ b/build.sh @@ -1,6 +1,6 @@ #!/bin/bash # -# Copyright © 2016-2024 The Thingsboard Authors +# Copyright © 2016-2025 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. diff --git a/build_proto.sh b/build_proto.sh index b27dca325b..f0690e75bb 100755 --- a/build_proto.sh +++ b/build_proto.sh @@ -1,6 +1,6 @@ #!/bin/bash # -# Copyright © 2016-2024 The Thingsboard Authors +# Copyright © 2016-2025 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. diff --git a/common/actor/pom.xml b/common/actor/pom.xml index 9b0053e0b6..71b7e2eb6e 100644 --- a/common/actor/pom.xml +++ b/common/actor/pom.xml @@ -1,6 +1,6 @@ +@if (calculatedFieldsTableConfig) { + +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.scss new file mode 100644 index 0000000000..3feb1e7429 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.scss @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host ::ng-deep { + tb-entities-table { + .mat-drawer-container { + background-color: white; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts new file mode 100644 index 0000000000..be6fb11480 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts @@ -0,0 +1,85 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + effect, + input, + Renderer2, + ViewChild, +} from '@angular/core'; +import { EntityId } from '@shared/models/id/entity-id'; +import { EntitiesTableComponent } from '@home/components/entity/entities-table.component'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { CalculatedFieldsTableConfig } from '@home/components/calculated-fields/calculated-fields-table-config'; +import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { ImportExportService } from '@shared/import-export/import-export.service'; + +@Component({ + selector: 'tb-calculated-fields-table', + templateUrl: './calculated-fields-table.component.html', + styleUrls: ['./calculated-fields-table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CalculatedFieldsTableComponent { + + @ViewChild(EntitiesTableComponent, {static: true}) entitiesTable: EntitiesTableComponent; + + active = input(); + entityId = input(); + entityName = input(); + + calculatedFieldsTableConfig: CalculatedFieldsTableConfig; + + constructor(private calculatedFieldsService: CalculatedFieldsService, + private translate: TranslateService, + private dialog: MatDialog, + private store: Store, + private durationLeft: DurationLeftPipe, + private popoverService: TbPopoverService, + private cd: ChangeDetectorRef, + private renderer: Renderer2, + private importExportService: ImportExportService, + private destroyRef: DestroyRef) { + + effect(() => { + if (this.active()) { + this.calculatedFieldsTableConfig = new CalculatedFieldsTableConfig( + this.calculatedFieldsService, + this.translate, + this.dialog, + this.entityId(), + this.store, + this.durationLeft, + this.popoverService, + this.destroyRef, + this.renderer, + this.entityName(), + this.importExportService + ); + this.cd.markForCheck(); + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html new file mode 100644 index 0000000000..6bab01f976 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html @@ -0,0 +1,140 @@ + +
+
+ + + +
{{ 'common.name' | translate }}
+
+ +
{{ argument.argumentName }}
+
+
+ + + {{ 'entity.entity-type' | translate }} + + +
+ @if (argument.refEntityId?.entityType === ArgumentEntityType.Tenant) { + {{ 'calculated-fields.argument-current-tenant' | translate }} + } @else if (argument.refEntityId?.id) { + {{ entityTypeTranslations.get(argument.refEntityId.entityType).type | translate }} + } @else { + {{ 'calculated-fields.argument-current' | translate }} + } +
+
+
+ + + {{ 'entity-view.target-entity' | translate }} + + +
+ @if (argument.refEntityId?.id) { + + {{ entityNameMap.get(argument.refEntityId.id) ?? '' }} + + } +
+
+
+ + + {{ 'common.type' | translate }} + + +
{{ ArgumentTypeTranslations.get(argument.refEntityKey.type) | translate }}
+
+
+ + + {{ 'entity.key' | translate }} + + + +
{{ argument.refEntityKey.key }}
+
+
+
+ + + +
+ + +
+
+
+ + +
+
+ {{ 'calculated-fields.no-arguments' | translate }} +
+ @if (errorText) { + + } +
+
+ + @if (maxArgumentsPerCF && argumentsFormArray.length >= maxArgumentsPerCF) { +
+ warning + {{ 'calculated-fields.hint.max-args' | translate }} +
+ } +
+
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.scss new file mode 100644 index 0000000000..877a749afa --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.scss @@ -0,0 +1,64 @@ +/** + * Copyright © 2016-2025 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 { + .arguments-table { + min-height: 108px; + + &-with-error { + min-height: 150px; + } + + .mat-mdc-table { + table-layout: fixed; + } + + .key-text { + font-size: 13px; + } + } + + .max-args-warning { + .mat-icon { + color: #FAA405; + } + } + + .tb-form-table-row-cell-buttons { + --mat-badge-legacy-small-size-container-size: 8px; + --mat-badge-small-size-container-overlap-offset: -5px; + --mat-badge-small-size-text-size: 0; + } +} + +:host ::ng-deep { + .mat-mdc-standard-chip { + .mdc-evolution-chip__cell--primary, .mdc-evolution-chip__action--primary, .mdc-evolution-chip__text-label { + overflow: hidden; + } + } + + .arguments-table:not(.arguments-table-with-error) { + .mdc-data-table__row:last-child .mat-mdc-cell { + border-bottom: none; + } + } + + .arguments-table { + .mat-mdc-header-row.mat-row-select .mat-mdc-header-cell.entity-type-header { + padding: 0 28px 0 0; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.ts new file mode 100644 index 0000000000..e3b19503af --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.ts @@ -0,0 +1,278 @@ +/// +/// Copyright © 2016-2025 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 { + AfterViewInit, + ChangeDetectorRef, + Component, + DestroyRef, + forwardRef, + Input, + OnChanges, + Renderer2, + SimpleChanges, + ViewChild, + ViewContainerRef, +} from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, +} from '@angular/forms'; +import { + ArgumentEntityType, + ArgumentType, + ArgumentTypeTranslations, + CalculatedFieldArgument, + CalculatedFieldArgumentValue, + CalculatedFieldType, +} from '@shared/models/calculated-field.models'; +import { CalculatedFieldArgumentPanelComponent } from '@home/components/calculated-fields/components/public-api'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { EntityId } from '@shared/models/id/entity-id'; +import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { getEntityDetailsPageURL, isDefined, isDefinedAndNotNull } from '@core/utils'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { TbTableDatasource } from '@shared/components/table/table-datasource.abstract'; +import { EntityService } from '@core/http/entity.service'; +import { MatSort } from '@angular/material/sort'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; + +@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, OnChanges, AfterViewInit { + + @Input() entityId: EntityId; + @Input() tenantId: string; + @Input() entityName: string; + @Input() calculatedFieldType: CalculatedFieldType; + + @ViewChild(MatSort, { static: true }) sort: MatSort; + + errorText = ''; + argumentsFormArray = this.fb.array([]); + entityNameMap = new Map(); + sortOrder = { direction: 'asc', property: '' }; + dataSource = new CalculatedFieldArgumentDatasource(); + + readonly entityTypeTranslations = entityTypeTranslations; + readonly ArgumentTypeTranslations = ArgumentTypeTranslations; + readonly ArgumentEntityType = ArgumentEntityType; + readonly ArgumentType = ArgumentType; + readonly CalculatedFieldType = CalculatedFieldType; + readonly maxArgumentsPerCF = getCurrentAuthState(this.store).maxArgumentsPerCF; + + private popoverComponent: TbPopoverComponent; + private propagateChange: (argumentsObj: Record) => void = () => {}; + + constructor( + private fb: FormBuilder, + private popoverService: TbPopoverService, + private viewContainerRef: ViewContainerRef, + private cd: ChangeDetectorRef, + private renderer: Renderer2, + private entityService: EntityService, + private destroyRef: DestroyRef, + private store: Store + ) { + this.argumentsFormArray.valueChanges.pipe(takeUntilDestroyed()).subscribe(value => { + this.updateEntityNameMap(value); + this.updateDataSource(value); + this.propagateChange(this.getArgumentsObject(value)); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.calculatedFieldType?.previousValue + && changes.calculatedFieldType.currentValue !== changes.calculatedFieldType.previousValue) { + this.argumentsFormArray.updateValueAndValidity(); + } + } + + ngAfterViewInit(): void { + this.sort.sortChange.asObservable().pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { + this.sortOrder.property = this.sort.active; + this.sortOrder.direction = this.sort.direction; + this.updateDataSource(this.argumentsFormArray.value); + }); + } + + registerOnChange(fn: (argumentsObj: Record) => void): void { + this.propagateChange = fn; + } + + registerOnTouched(_): void {} + + validate(): ValidationErrors | null { + this.updateErrorText(); + return this.errorText ? { argumentsFormArray: false } : null; + } + + onDelete($event: Event, index: number): void { + $event.stopPropagation(); + this.argumentsFormArray.removeAt(index); + this.argumentsFormArray.markAsDirty(); + } + + manageArgument($event: Event, matButton: MatButton, argument = {} as CalculatedFieldArgumentValue, index?: number): void { + $event?.stopPropagation(); + if (this.popoverComponent && !this.popoverComponent.tbHidden) { + this.popoverComponent.hide(); + } + const trigger = matButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const ctx = { + index, + argument, + entityId: this.entityId, + calculatedFieldType: this.calculatedFieldType, + buttonTitle: this.argumentsFormArray.at(index)?.value ? 'action.apply' : 'action.add', + tenantId: this.tenantId, + entityName: this.entityName, + usedArgumentNames: this.argumentsFormArray.value.map(({ argumentName }) => argumentName).filter(name => name !== argument.argumentName), + }; + this.popoverComponent = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, CalculatedFieldArgumentPanelComponent, isDefined(index) ? 'left' : 'right', false, null, + ctx, + {}, + {}, {}, true); + this.popoverComponent.tbComponentRef.instance.argumentsDataApplied.subscribe(({ value, index }) => { + this.popoverComponent.hide(); + const formGroup = this.fb.group(value); + if (isDefinedAndNotNull(index)) { + this.argumentsFormArray.setControl(index, formGroup); + } else { + this.argumentsFormArray.push(formGroup); + } + formGroup.markAsDirty(); + this.cd.markForCheck(); + }); + } + } + + private updateDataSource(value: CalculatedFieldArgumentValue[]): void { + const sortedValue = this.sortData(value); + this.dataSource.loadData(sortedValue); + } + + private updateErrorText(): void { + if (this.calculatedFieldType === CalculatedFieldType.SIMPLE + && this.argumentsFormArray.controls.some(control => control.value.refEntityKey.type === ArgumentType.Rolling)) { + this.errorText = 'calculated-fields.hint.arguments-simple-with-rolling'; + } else if (!this.argumentsFormArray.controls.length) { + this.errorText = 'calculated-fields.hint.arguments-empty'; + } else { + this.errorText = ''; + } + } + + private getArgumentsObject(value: CalculatedFieldArgumentValue[]): Record { + return value.reduce((acc, argumentValue) => { + const { argumentName, ...argument } = argumentValue as CalculatedFieldArgumentValue; + acc[argumentName] = argument; + return acc; + }, {} as Record); + } + + writeValue(argumentsObj: Record): void { + this.argumentsFormArray.clear(); + this.populateArgumentsFormArray(argumentsObj) + } + + getEntityDetailsPageURL(id: string, type: EntityType): string { + return getEntityDetailsPageURL(id, type); + } + + private populateArgumentsFormArray(argumentsObj: Record): void { + Object.keys(argumentsObj).forEach(key => { + const value: CalculatedFieldArgumentValue = { + ...argumentsObj[key], + argumentName: key + }; + this.argumentsFormArray.push(this.fb.group(value), { emitEvent: false }); + }); + this.argumentsFormArray.updateValueAndValidity(); + } + + private updateEntityNameMap(value: CalculatedFieldArgumentValue[]): void { + value.forEach(({ refEntityId = {}}) => { + if (refEntityId.id && !this.entityNameMap.has(refEntityId.id)) { + const { id, entityType } = refEntityId as EntityId; + this.entityService.getEntity(entityType as EntityType, id, { ignoreLoading: true, ignoreErrors: true }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(entity => this.entityNameMap.set(id, entity.name)); + } + }); + } + + private getSortValue(argument: CalculatedFieldArgumentValue, column: string): string { + switch (column) { + case 'entityType': + if (argument.refEntityId?.entityType === ArgumentEntityType.Tenant) { + return 'calculated-fields.argument-current-tenant'; + } else if (argument.refEntityId?.id) { + return entityTypeTranslations.get((argument.refEntityId)?.entityType as unknown as EntityType).type; + } else { + return 'calculated-fields.argument-current'; + } + case 'type': + return ArgumentTypeTranslations.get(argument.refEntityKey.type); + case 'key': + return argument.refEntityKey.key; + default: + return argument.argumentName; + } + } + + private sortData(data: CalculatedFieldArgumentValue[]): CalculatedFieldArgumentValue[] { + return data.sort((a, b) => { + const valA = this.getSortValue(a, this.sortOrder.property) ?? ''; + const valB = this.getSortValue(b, this.sortOrder.property) ?? ''; + return (this.sortOrder.direction === 'asc' ? 1 : -1) * valA.localeCompare(valB); + }); + } +} + +class CalculatedFieldArgumentDatasource extends TbTableDatasource { + constructor() { + super(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.html new file mode 100644 index 0000000000..d9cb7eb021 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.html @@ -0,0 +1,48 @@ + +
+ +

{{ 'calculated-fields.debugging' | translate}}

+ + +
+
+ +
+
+ +
+
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/example-hint.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.scss similarity index 71% rename from ui-ngx/src/app/modules/home/components/rule-node/common/example-hint.component.scss rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.scss index da530c0540..c4798d06f9 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/example-hint.component.scss +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2024 The Thingsboard Authors + * Copyright © 2016-2025 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. @@ -14,18 +14,13 @@ * limitations under the License. */ :host { - .space-between { - display: flex; - justify-content: space-between; - gap: 20px; + .debug-dialog-container { + width: 1080px; + max-width: 100%; - .see-example { - display: flex; - flex-shrink: 0; + .debug-dialog-content { + height: 65vh; + border-radius: 0; } } - - .hint-text { - width: 100%; - } } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.ts new file mode 100644 index 0000000000..8618a11990 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.ts @@ -0,0 +1,60 @@ +/// +/// Copyright © 2016-2025 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 { AfterViewInit, Component, Inject, ViewChild } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { CalculatedFieldEventBody, DebugEventType, EventType } from '@shared/models/event.models'; +import { EventTableComponent } from '@home/components/event/event-table.component'; +import { CalculatedFieldDebugDialogData, CalculatedFieldType } from '@shared/models/calculated-field.models'; + +@Component({ + selector: 'tb-calculated-field-debug-dialog', + styleUrls: ['calculated-field-debug-dialog.component.scss'], + templateUrl: './calculated-field-debug-dialog.component.html', +}) +export class CalculatedFieldDebugDialogComponent extends DialogComponent implements AfterViewInit { + + @ViewChild(EventTableComponent, {static: true}) eventsTable: EventTableComponent; + + readonly DebugEventType = DebugEventType; + readonly debugEventTypes = DebugEventType; + readonly EventType = EventType; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: CalculatedFieldDebugDialogData, + protected dialogRef: MatDialogRef) { + super(store, router, dialogRef); + } + + ngAfterViewInit(): void { + this.eventsTable.entitiesTable.updateData(); + this.eventsTable.entitiesTable.cellActionDescriptors[0].isEnabled = () => this.data.value.type === CalculatedFieldType.SCRIPT; + } + + cancel(): void { + this.dialogRef.close(null); + } + + onDebugEventSelected(event: CalculatedFieldEventBody): void { + this.data.getTestScriptDialogFn(this.data.value, JSON.parse(event.arguments)) + .subscribe(expression => this.dialogRef.close(expression)); + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html new file mode 100644 index 0000000000..7e0f87a12e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html @@ -0,0 +1,195 @@ + +
+ +

{{ 'entity.type-calculated-field' | translate}}

+ +
+ +
+
+
+
+
{{ 'common.general' | translate }}
+
+ + {{ 'entity-field.title' | translate }} + + @if (fieldFormGroup.get('name').errors && fieldFormGroup.get('name').touched) { + + @if (fieldFormGroup.get('name').hasError('required')) { + {{ 'common.hint.title-required' | translate }} + } @else if (fieldFormGroup.get('name').hasError('pattern')) { + {{ 'common.hint.title-pattern' | translate }} + } @else if (fieldFormGroup.get('name').hasError('maxlength')) { + {{ 'common.hint.title-max-length' | translate }} + } + + } + + +
+ + {{ 'common.type' | translate }} + + @for (type of fieldTypes; track type) { + {{ CalculatedFieldTypeTranslations.get(type) | translate}} + } + + +
+ +
+
{{ 'calculated-fields.arguments' | translate }}
+ +
+
+
{{ 'calculated-fields.expression' | translate }}
+ @if (fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE) { + + + @if (configFormGroup.get('expressionSIMPLE').errors && configFormGroup.get('expressionSIMPLE').touched) { + + @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 }} + } + + } @else { + {{ 'calculated-fields.hint.expression' | translate }} + } + + } @else { + +
{{ 'api-usage.tbel' | translate }}
+ +
+
+ +
+ } +
+
+
{{ 'calculated-fields.output' | translate }}
+
+ + {{ 'calculated-fields.output-type' | translate }} + + @for (type of outputTypes; track type) { + {{ OutputTypeTranslations.get(type) | translate}} + } + + + @if (outputFormGroup.get('type').value === OutputType.Attribute + && (data.entityId.entityType === EntityType.DEVICE || data.entityId.entityType === EntityType.DEVICE_PROFILE)) { + + {{ 'calculated-fields.attribute-scope' | translate }} + + + {{ 'calculated-fields.server-attributes' | translate }} + + + {{ 'calculated-fields.shared-attributes' | translate }} + + + + } +
+ @if (fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE) { + + + {{ (outputFormGroup.get('type').value === OutputType.Timeseries + ? 'calculated-fields.timeseries-key' + : 'calculated-fields.attribute-key') + | translate }} + + + @if (outputFormGroup.get('name').errors && outputFormGroup.get('name').touched) { + + @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 }} + } + + } + + } +
+
+
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss new file mode 100644 index 0000000000..0e994ed825 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss @@ -0,0 +1,52 @@ +/** + * Copyright © 2016-2025 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 { + .dialog-container { + width: 869px; + max-width: 100%; + } + + .script-lang-chip { + line-height: 20px; + font-size: 14px; + font-weight: 500; + color: white; + border-radius: 100px; + width: 70px; + display: flex; + justify-content: center; + margin-top: 2px; + margin-right: 4px; + } +} + +:host ::ng-deep { + .expression-edit { + .ace_tb { + &.ace_calculated-field { + &-key { + color: #C52F00; + } + &-ts, &-time-window, &-values, &-value, &-func { + color: #7214D0; + } + &-start-ts, &-end-ts, &-limit { + color: #185F2A; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts new file mode 100644 index 0000000000..c3e3d52a72 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts @@ -0,0 +1,194 @@ +/// +/// Copyright © 2016-2025 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, DestroyRef, Inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { + CalculatedField, + CalculatedFieldConfiguration, + CalculatedFieldDialogData, + CalculatedFieldType, + CalculatedFieldTypeTranslations, + getCalculatedFieldArgumentsEditorCompleter, + getCalculatedFieldArgumentsHighlights, + OutputType, + OutputTypeTranslations +} from '@shared/models/calculated-field.models'; +import { noLeadTrailSpacesRegex } from '@shared/models/regex.constants'; +import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { map, startWith } from 'rxjs/operators'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ScriptLanguage } from '@shared/models/rule-node.models'; +import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; + +@Component({ + selector: 'tb-calculated-field-dialog', + templateUrl: './calculated-field-dialog.component.html', + styleUrls: ['./calculated-field-dialog.component.scss'], +}) +export class CalculatedFieldDialogComponent extends DialogComponent { + + fieldFormGroup = this.fb.group({ + name: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex), Validators.maxLength(255)]], + type: [CalculatedFieldType.SIMPLE], + debugSettings: [], + configuration: this.fb.group({ + arguments: this.fb.control({}), + expressionSIMPLE: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex), Validators.maxLength(255)]], + expressionSCRIPT: [], + output: this.fb.group({ + name: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex), Validators.maxLength(255)]], + scope: [{ value: AttributeScope.SERVER_SCOPE, disabled: true }], + type: [OutputType.Timeseries] + }), + }), + }); + + functionArgs$ = this.configFormGroup.get('arguments').valueChanges + .pipe( + startWith(this.data.value?.configuration?.arguments ?? {}), + map(argumentsObj => Object.keys(argumentsObj)) + ); + + argumentsEditorCompleter$ = this.configFormGroup.get('arguments').valueChanges + .pipe( + startWith(this.data.value?.configuration?.arguments ?? {}), + map(argumentsObj => getCalculatedFieldArgumentsEditorCompleter(argumentsObj)) + ); + + argumentsHighlightRules$ = this.configFormGroup.get('arguments').valueChanges + .pipe( + startWith(this.data.value?.configuration?.arguments ?? {}), + map(argumentsObj => getCalculatedFieldArgumentsHighlights(argumentsObj)) + ); + + additionalDebugActionConfig = this.data.value?.id ? { + ...this.data.additionalDebugActionConfig, + action: () => this.data.additionalDebugActionConfig.action({ id: this.data.value.id, ...this.fromGroupValue }), + } : null; + + readonly OutputTypeTranslations = OutputTypeTranslations; + readonly OutputType = OutputType; + readonly AttributeScope = AttributeScope; + readonly EntityType = EntityType; + readonly CalculatedFieldType = CalculatedFieldType; + readonly ScriptLanguage = ScriptLanguage; + readonly fieldTypes = Object.values(CalculatedFieldType) as CalculatedFieldType[]; + readonly outputTypes = Object.values(OutputType) as OutputType[]; + readonly CalculatedFieldTypeTranslations = CalculatedFieldTypeTranslations; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: CalculatedFieldDialogData, + protected dialogRef: MatDialogRef, + private calculatedFieldsService: CalculatedFieldsService, + private destroyRef: DestroyRef, + private fb: FormBuilder) { + super(store, router, dialogRef); + this.observeIsLoading(); + this.applyDialogData(); + this.observeTypeChanges(); + } + + get configFormGroup(): FormGroup { + return this.fieldFormGroup.get('configuration') as FormGroup; + } + + get outputFormGroup(): FormGroup { + return this.fieldFormGroup.get('configuration').get('output') as FormGroup; + } + + get fromGroupValue(): CalculatedField { + const { configuration, type, ...rest } = this.fieldFormGroup.value; + const { expressionSIMPLE, expressionSCRIPT, ...restConfig } = configuration; + return { configuration: { ...restConfig, type, expression: configuration['expression'+type] }, ...rest, type } as CalculatedField; + } + + cancel(): void { + this.dialogRef.close(null); + } + + add(): void { + if (this.fieldFormGroup.valid) { + this.calculatedFieldsService.saveCalculatedField({ entityId: this.data.entityId, ...(this.data.value ?? {}), ...this.fromGroupValue}) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(calculatedField => this.dialogRef.close(calculatedField)); + } + } + + onTestScript(): void { + this.data.getTestScriptDialogFn(this.fromGroupValue, null, false).subscribe(expression => { + this.configFormGroup.get('expressionSCRIPT').setValue(expression); + this.configFormGroup.get('expressionSCRIPT').markAsDirty(); + }); + } + + private applyDialogData(): void { + const { configuration = {}, type = CalculatedFieldType.SIMPLE, debugSettings = { failuresEnabled: true, allEnabled: true }, ...value } = this.data.value ?? {}; + const { expression, ...restConfig } = configuration as CalculatedFieldConfiguration; + const updatedConfig = { ...restConfig , ['expression'+type]: expression }; + this.fieldFormGroup.patchValue({ configuration: updatedConfig, type, debugSettings, ...value }, {emitEvent: false}); + } + + private observeTypeChanges(): void { + this.toggleKeyByCalculatedFieldType(this.fieldFormGroup.get('type').value); + this.toggleScopeByOutputType(this.outputFormGroup.get('type').value); + + this.outputFormGroup.get('type').valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(type => this.toggleScopeByOutputType(type)); + this.fieldFormGroup.get('type').valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(type => this.toggleKeyByCalculatedFieldType(type)); + } + + private toggleScopeByOutputType(type: OutputType): void { + this.outputFormGroup.get('scope')[type === OutputType.Attribute? 'enable' : 'disable']({emitEvent: false}); + } + + private toggleKeyByCalculatedFieldType(type: CalculatedFieldType): void { + if (type === CalculatedFieldType.SIMPLE) { + this.outputFormGroup.get('name').enable({emitEvent: false}); + this.configFormGroup.get('expressionSIMPLE').enable({emitEvent: false}); + this.configFormGroup.get('expressionSCRIPT').disable({emitEvent: false}); + } else { + this.outputFormGroup.get('name').disable({emitEvent: false}); + this.configFormGroup.get('expressionSIMPLE').disable({emitEvent: false}); + this.configFormGroup.get('expressionSCRIPT').enable({emitEvent: false}); + } + } + + private observeIsLoading(): void { + this.isLoading$.pipe(takeUntilDestroyed()).subscribe(loading => { + if (loading) { + this.fieldFormGroup.disable({emitEvent: false}); + } else { + this.fieldFormGroup.enable({emitEvent: false}); + this.toggleScopeByOutputType(this.outputFormGroup.get('type').value); + this.toggleKeyByCalculatedFieldType(this.fieldFormGroup.get('type').value); + if (this.data.isDirty) { + this.fieldFormGroup.markAsDirty(); + } + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.html new file mode 100644 index 0000000000..566a433545 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.html @@ -0,0 +1,200 @@ + +
+
+
{{ 'calculated-fields.argument-settings' | translate }}
+
+
+
{{ 'calculated-fields.argument-name' | translate }}
+ + + @if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('required')) { + + warning + + } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('duplicateName')) { + + warning + + } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('pattern')) { + + warning + + } @else if (argumentFormGroup.get('argumentName').touched && argumentFormGroup.get('argumentName').hasError('maxlength')) { + + warning + + } + +
+ +
+
{{ 'entity.entity-type' | translate }}
+ + + @for (type of argumentEntityTypes; track type) { + {{ ArgumentEntityTypeTranslations.get(type) | translate }} + } + + +
+ @if (ArgumentEntityTypeParamsMap.has(entityType)) { +
+
{{ ArgumentEntityTypeParamsMap.get(entityType).title | translate }}
+ +
+ } +
+ +
+
{{ 'calculated-fields.argument-type' | translate }}
+ + + @for (type of argumentTypes; track type) { + {{ ArgumentTypeTranslations.get(type) | translate }} + } + + @if (refEntityKeyFormGroup.get('type').hasError('required') && refEntityKeyFormGroup.get('type').touched) { + + warning + + } + +
+ @if (entityFilter.singleEntity?.id || entityType === ArgumentEntityType.Current || entityType === ArgumentEntityType.Tenant) { + @if (refEntityKeyFormGroup.get('type').value !== ArgumentType.Attribute) { +
+
{{ 'calculated-fields.timeseries-key' | translate }}
+ @if (refEntityKeyFormGroup.get('type').value === ArgumentType.LatestTelemetry) { + + } @else { + + } + + + +
+ } @else { + @if (enableAttributeScopeSelection) { +
+
{{ 'calculated-fields.attribute-scope' | translate }}
+ + + + {{ 'calculated-fields.server-attributes' | translate }} + + + {{ 'calculated-fields.client-attributes' | translate }} + + + {{ 'calculated-fields.shared-attributes' | translate }} + + + +
+ } +
+
{{ 'calculated-fields.attribute-key' | translate }}
+ +
+ } + } +
+ @if (refEntityKeyFormGroup.get('type').value !== ArgumentType.Rolling) { +
+
{{ 'calculated-fields.default-value' | translate }}
+ + + +
+ } @else { +
+
{{ 'calculated-fields.time-window' | translate }}
+ +
+ @if (maxDataPointsPerRollingArg) { +
+
{{ 'calculated-fields.limit' | translate }}
+
+ + + + + + +
+
+ } + } +
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.scss new file mode 100644 index 0000000000..773489ee60 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.scss @@ -0,0 +1,51 @@ +/** + * Copyright © 2016-2025 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. + */ +@use '../../../../../../../scss/constants' as constants; + +$panel-width: 520px; + +:host { + display: flex; + width: $panel-width; + max-width: 100%; + max-height: 100vh; + + .fixed-title-width { + @media #{constants.$mat-xs} { + min-width: 120px; + } + } + + .limit-field-row { + @media screen and (max-width: $panel-width) { + display: flex; + flex-direction: column; + + .fixed-title-width { + align-self: flex-start; + padding-top: 8px; + } + } + } +} + +:host ::ng-deep { + .time-interval-field { + .advanced-input { + flex-direction: column; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.ts new file mode 100644 index 0000000000..9d9614ba28 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.ts @@ -0,0 +1,246 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { ChangeDetectorRef, Component, Input, OnInit, output } from '@angular/core'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'; +import { charsWithNumRegex, noLeadTrailSpacesRegex } from '@shared/models/regex.constants'; +import { + ArgumentEntityType, + ArgumentEntityTypeParamsMap, + ArgumentEntityTypeTranslations, + ArgumentType, + ArgumentTypeTranslations, + CalculatedFieldArgumentValue, + CalculatedFieldType, + getCalculatedFieldCurrentEntityFilter +} from '@shared/models/calculated-field.models'; +import { debounceTime, delay, distinctUntilChanged, filter } from 'rxjs/operators'; +import { EntityType } from '@shared/models/entity-type.models'; +import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { DatasourceType } from '@shared/models/widget.models'; +import { EntityId } from '@shared/models/id/entity-id'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { EntityFilter } from '@shared/models/query/query.models'; +import { AliasFilterType } from '@shared/models/alias.models'; +import { merge } from 'rxjs'; +import { MINUTE } from '@shared/models/time/time.models'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { AppState } from '@core/core.state'; +import { Store } from '@ngrx/store'; + +@Component({ + selector: 'tb-calculated-field-argument-panel', + templateUrl: './calculated-field-argument-panel.component.html', + styleUrls: ['./calculated-field-argument-panel.component.scss'] +}) +export class CalculatedFieldArgumentPanelComponent implements OnInit { + + @Input() buttonTitle: string; + @Input() index: number; + @Input() argument: CalculatedFieldArgumentValue; + @Input() entityId: EntityId; + @Input() tenantId: string; + @Input() entityName: string; + @Input() calculatedFieldType: CalculatedFieldType; + @Input() usedArgumentNames: string[]; + + argumentsDataApplied = output<{ value: CalculatedFieldArgumentValue, index: number }>(); + + readonly maxDataPointsPerRollingArg = getCurrentAuthState(this.store).maxDataPointsPerRollingArg; + readonly defaultLimit = Math.floor(this.maxDataPointsPerRollingArg / 10); + + argumentFormGroup = this.fb.group({ + argumentName: ['', [Validators.required, this.uniqNameRequired(), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]], + refEntityId: this.fb.group({ + entityType: [ArgumentEntityType.Current], + id: [''] + }), + refEntityKey: this.fb.group({ + type: [ArgumentType.LatestTelemetry, [Validators.required]], + key: [''], + scope: [{ value: AttributeScope.SERVER_SCOPE, disabled: true }, [Validators.required]], + }), + defaultValue: ['', [Validators.pattern(noLeadTrailSpacesRegex)]], + limit: [{ value: this.defaultLimit, disabled: !this.maxDataPointsPerRollingArg }], + timeWindow: [MINUTE * 15], + }); + + argumentTypes: ArgumentType[]; + entityFilter: EntityFilter; + + readonly argumentEntityTypes = Object.values(ArgumentEntityType) as ArgumentEntityType[]; + readonly ArgumentEntityTypeTranslations = ArgumentEntityTypeTranslations; + readonly ArgumentType = ArgumentType; + readonly DataKeyType = DataKeyType; + readonly EntityType = EntityType; + readonly datasourceType = DatasourceType; + readonly ArgumentTypeTranslations = ArgumentTypeTranslations; + readonly AttributeScope = AttributeScope; + readonly ArgumentEntityType = ArgumentEntityType; + readonly ArgumentEntityTypeParamsMap = ArgumentEntityTypeParamsMap; + + private currentEntityFilter: EntityFilter; + + constructor( + private fb: FormBuilder, + private cd: ChangeDetectorRef, + private popover: TbPopoverComponent, + private store: Store + ) { + this.observeEntityFilterChanges(); + this.observeEntityTypeChanges() + this.observeEntityKeyChanges(); + this.observeUpdatePosition(); + } + + 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; + } + + get enableAttributeScopeSelection(): boolean { + return this.entityType === ArgumentEntityType.Device + || (this.entityType === ArgumentEntityType.Current + && (this.entityId.entityType === EntityType.DEVICE || this.entityId.entityType === EntityType.DEVICE_PROFILE)) + } + + ngOnInit(): void { + this.argumentFormGroup.patchValue(this.argument, {emitEvent: false}); + this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.entityName, this.entityId); + this.updateEntityFilter(this.argument.refEntityId?.entityType, true); + this.toggleByEntityKeyType(this.argument.refEntityKey?.type); + this.setInitialEntityKeyType(); + + this.argumentTypes = Object.values(ArgumentType) + .filter(type => type !== ArgumentType.Rolling || this.calculatedFieldType === CalculatedFieldType.SCRIPT); + } + + saveArgument(): void { + const { refEntityId, ...restConfig } = this.argumentFormGroup.value; + const value = (refEntityId.entityType === ArgumentEntityType.Current ? restConfig : { refEntityId, ...restConfig }) as CalculatedFieldArgumentValue; + if (refEntityId.entityType === ArgumentEntityType.Tenant) { + refEntityId.id = this.tenantId; + } + this.argumentsDataApplied.emit({ value, index: this.index }); + } + + cancel(): void { + this.popover.hide(); + } + + private toggleByEntityKeyType(type: ArgumentType): void { + const isAttribute = type === ArgumentType.Attribute; + const isRolling = type === ArgumentType.Rolling; + this.argumentFormGroup.get('refEntityKey').get('scope')[isAttribute? 'enable' : 'disable']({ emitEvent: false }); + this.argumentFormGroup.get('limit')[isRolling? 'enable' : 'disable']({ emitEvent: false }); + this.argumentFormGroup.get('timeWindow')[isRolling? 'enable' : 'disable']({ emitEvent: false }); + this.argumentFormGroup.get('defaultValue')[isRolling? 'disable' : 'enable']({ emitEvent: false }); + } + + private updateEntityFilter(entityType: ArgumentEntityType = ArgumentEntityType.Current, onInit = false): void { + let entityFilter: EntityFilter; + switch (entityType) { + case ArgumentEntityType.Current: + entityFilter = this.currentEntityFilter; + break; + case ArgumentEntityType.Tenant: + entityFilter = { + type: AliasFilterType.singleEntity, + singleEntity: { + id: this.tenantId, + entityType: EntityType.TENANT + }, + }; + break; + default: + entityFilter = { + type: AliasFilterType.singleEntity, + singleEntity: this.argumentFormGroup.get('refEntityId').value as unknown as EntityId, + }; + } + if (!onInit) { + this.argumentFormGroup.get('refEntityKey').get('key').setValue(''); + } + this.entityFilter = entityFilter; + this.cd.markForCheck(); + } + + private observeEntityFilterChanges(): void { + merge( + this.refEntityIdFormGroup.get('entityType').valueChanges, + this.refEntityKeyFormGroup.get('type').valueChanges, + this.refEntityIdFormGroup.get('id').valueChanges.pipe(filter(Boolean)), + this.refEntityKeyFormGroup.get('scope').valueChanges, + ) + .pipe(debounceTime(50), takeUntilDestroyed()) + .subscribe(() => this.updateEntityFilter(this.entityType)); + } + + private observeEntityTypeChanges(): void { + this.argumentFormGroup.get('refEntityId').get('entityType').valueChanges + .pipe(distinctUntilChanged(), takeUntilDestroyed()) + .subscribe(type => { + this.argumentFormGroup.get('refEntityId').get('id').setValue(''); + this.argumentFormGroup.get('refEntityId') + .get('id')[type === ArgumentEntityType.Tenant || type === ArgumentEntityType.Current ? 'disable' : 'enable'](); + if (!this.enableAttributeScopeSelection) { + this.refEntityKeyFormGroup.get('scope').setValue(AttributeScope.SERVER_SCOPE); + } + }); + } + + private uniqNameRequired(): ValidatorFn { + return (control: FormControl) => { + const newName = control.value.trim().toLowerCase(); + const isDuplicate = this.usedArgumentNames?.some(name => name.toLowerCase() === newName); + + return isDuplicate ? { duplicateName: true } : null; + }; + } + + 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(); + } + } + + private observeUpdatePosition(): void { + merge( + this.refEntityIdFormGroup.get('entityType').valueChanges, + this.refEntityKeyFormGroup.get('type').valueChanges, + this.argumentFormGroup.get('timeWindow').valueChanges, + this.refEntityIdFormGroup.get('id').valueChanges.pipe(filter(Boolean)), + ) + .pipe(delay(50), takeUntilDestroyed()) + .subscribe(() => this.popover.updatePosition()); + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/public-api.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/public-api.ts new file mode 100644 index 0000000000..9e3c52bc4f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/public-api.ts @@ -0,0 +1,21 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +export * from './dialog/calculated-field-dialog.component'; +export * from './arguments-table/calculated-field-arguments-table.component'; +export * from './panel/calculated-field-argument-panel.component'; +export * from './debug-dialog/calculated-field-debug-dialog.component'; +export * from './test-dialog/calculated-field-script-test-dialog.component'; diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.html new file mode 100644 index 0000000000..f393a9130b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.html @@ -0,0 +1,58 @@ + +
+
{{ 'calculated-fields.arguments' | translate }}
+
+
+
{{ 'common.name' | translate }}
+
{{ 'common.type' | translate }}
+
{{ 'common.data' | translate }}
+
+
+ @for (group of argumentsFormArray.controls; track group) { +
+ + + + + + + {{ ArgumentTypeTranslations.get(argumentsTypeMap.get(group.get('argumentName').value)) | translate }} + + + +
+ @if (argumentsTypeMap.get(group.get('argumentName').value) === ArgumentType.Rolling) { + + + + } @else { + + + + + } + +
+
+ } +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.scss new file mode 100644 index 0000000000..cbca3002aa --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.scss @@ -0,0 +1,33 @@ +/** + * Copyright © 2016-2025 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. + */ +@use '../../../../../../../scss/constants' as constants; + +:host { + .tb-form-table { + min-width: 700px; + } +} + +:host::ng-deep { + .tb-form-table-row { + .argument-value { + .tb-value-type.row { + width: 120px; + min-width: 120px; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.ts new file mode 100644 index 0000000000..7c5580f11a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.ts @@ -0,0 +1,146 @@ +/// +/// Copyright © 2016-2025 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, forwardRef, Input } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + Validator, + ValidationErrors, + FormBuilder, + FormGroup +} from '@angular/forms'; +import { PageComponent } from '@shared/components/page.component'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { entityTypeTranslations } from '@shared/models/entity-type.models'; +import { + ArgumentType, + ArgumentTypeTranslations, + CalculatedFieldArgumentEventValue, + CalculatedFieldRollingTelemetryArgumentValue, + CalculatedFieldSingleArgumentValue, + CalculatedFieldEventArguments, + CalculatedFieldType +} from '@shared/models/calculated-field.models'; +import { + JsonObjectEditDialogComponent, + JsonObjectEditDialogData +} from '@shared/components/dialog/json-object-edit-dialog.component'; +import { filter } from 'rxjs/operators'; +import { MatDialog } from '@angular/material/dialog'; + +@Component({ + selector: 'tb-calculated-field-test-arguments', + templateUrl: './calculated-field-test-arguments.component.html', + styleUrls: ['./calculated-field-test-arguments.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CalculatedFieldTestArgumentsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CalculatedFieldTestArgumentsComponent), + multi: true, + } + ] +}) +export class CalculatedFieldTestArgumentsComponent extends PageComponent implements ControlValueAccessor, Validator { + + @Input() argumentsTypeMap: Map; + + argumentsFormArray = this.fb.array([]); + + readonly entityTypeTranslations = entityTypeTranslations; + readonly ArgumentTypeTranslations = ArgumentTypeTranslations; + readonly ArgumentType = ArgumentType; + readonly CalculatedFieldType = CalculatedFieldType; + + private propagateChange: (value: CalculatedFieldEventArguments) => void = () => {}; + + constructor(private fb: FormBuilder, private dialog: MatDialog) { + super(); + this.argumentsFormArray.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(() => this.propagateChange(this.getValue())); + } + + registerOnChange(propagateChange: (value: CalculatedFieldEventArguments) => void): void { + this.propagateChange = propagateChange; + } + + registerOnTouched(_): void { + } + + writeValue(argumentsObj: CalculatedFieldEventArguments): void { + this.argumentsFormArray.clear(); + Object.keys(argumentsObj).forEach(key => { + const value = { ...argumentsObj[key], argumentName: key } as CalculatedFieldArgumentEventValue; + this.argumentsFormArray.push(this.argumentsTypeMap.get(key) === ArgumentType.Rolling + ? this.getRollingArgumentFormGroup(value as CalculatedFieldRollingTelemetryArgumentValue) + : this.getSimpleArgumentFormGroup(value as CalculatedFieldSingleArgumentValue) + ); + }); + } + + validate(): ValidationErrors | null { + return this.argumentsFormArray.valid ? null : { arguments: { valid: false } }; + } + + openEditJSONDialog(group: FormGroup): void { + this.dialog.open(JsonObjectEditDialogComponent, { + disableClose: true, + height: '760px', + maxHeight: '70vh', + minWidth: 'min(700px, 100%)', + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + jsonValue: this.argumentsTypeMap.get(group.get('argumentName').value) === ArgumentType.Rolling ? group.value.rollingJson : group.value, + required: true, + fillHeight: true + } + }).afterClosed() + .pipe(filter(Boolean)) + .subscribe(result => this.argumentsTypeMap.get(group.get('argumentName').value) === ArgumentType.Rolling + ? group.get('rollingJson').patchValue({ values: (result as CalculatedFieldRollingTelemetryArgumentValue).values, timeWindow: (result as CalculatedFieldRollingTelemetryArgumentValue).timeWindow }) + : group.patchValue({ ts: (result as CalculatedFieldSingleArgumentValue).ts, value: (result as CalculatedFieldSingleArgumentValue).value }) ); + } + + private getSimpleArgumentFormGroup({ argumentName, ts, value }: CalculatedFieldSingleArgumentValue): FormGroup { + return this.fb.group({ + argumentName: [{ value: argumentName, disabled: true}], + ts: [ts], + value: [value] + }) as FormGroup; + } + + private getRollingArgumentFormGroup({ argumentName, timeWindow, values }: CalculatedFieldRollingTelemetryArgumentValue): FormGroup { + return this.fb.group({ + argumentName: [{ value: argumentName, disabled: true }], + rollingJson: [{ values: values ?? [], timeWindow: timeWindow ?? {} }] + }) as FormGroup; + } + + private getValue(): CalculatedFieldEventArguments { + return this.argumentsFormArray.getRawValue().reduce((acc, rowItem) => { + const { argumentName, rollingJson = {}, ...value } = rowItem; + acc[argumentName] = { ...rollingJson, ...value }; + return acc; + }, {}); + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.html new file mode 100644 index 0000000000..cda99f6c09 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.html @@ -0,0 +1,103 @@ + +
+ +

{{ 'calculated-fields.test-script-function' | translate }} ({{ 'api-usage.tbel' | translate }})

+ +
+
+
+
+
+
+
+ {{ 'calculated-fields.expression' | translate }} +
+ +
+
+
+
+
+
+ {{ 'calculated-fields.arguments' | translate }} +
+ +
+
+
+
+
+ common.output +
+ +
+
+
+
+
+
+
+ + + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.scss new file mode 100644 index 0000000000..2187b47e0d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.scss @@ -0,0 +1,90 @@ +/** + * Copyright © 2016-2025 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 { + .test-dialog-container { + .block-label { + padding: 4px; + color: #00acc1; + background: rgba(220, 220, 220, .35); + border-radius: 5px; + } + + .test-block-content { + padding-top: 5px; + padding-left: 5px; + border: 1px solid #c0c0c0; + overflow: scroll; + } + + .block-label-container { + position: absolute; + z-index: 10; + font-size: 12px; + font-weight: bold; + + &.left { + right: 112px; + top: 9px; + } + + &.right-bottom { + right: 40px; + top: 6px; + } + + &.right-top { + right: 8px; + top: 2px; + } + } + } +} + +:host::ng-deep { + .test-dialog-container { + .gutter { + background-color: #eee; + background-repeat: no-repeat; + background-position: 50%; + } + + .gutter.gutter-horizontal { + cursor: col-resize; + background-image: url("../../../../../../../assets/split.js/grips/horizontal.png"); + } + + .gutter.gutter-vertical { + cursor: row-resize; + background-image: url("../../../../../../../assets/split.js/grips/vertical.png"); + } + } + + .expression-edit { + .ace_tb { + &.ace_calculated-field { + &-key { + color: #C52F00; + } + &-ts, &-time-window, &-values, &-value, &-func { + color: #7214D0; + } + &-start-ts, &-end-ts, &-limit { + color: #185F2A; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.ts new file mode 100644 index 0000000000..78c3b17bcb --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.ts @@ -0,0 +1,228 @@ +/// +/// Copyright © 2016-2025 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 { + AfterViewInit, + Component, + DestroyRef, + ElementRef, + Inject, + OnDestroy, + ViewChild, +} from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, Validators } from '@angular/forms'; +import { NEVER, Observable, of, switchMap } from 'rxjs'; +import { Router } from '@angular/router'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { ContentType } from '@shared/models/constants'; +import { JsonContentComponent } from '@shared/components/json-content.component'; +import { ScriptLanguage } from '@shared/models/rule-node.models'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { beautifyJs } from '@shared/models/beautify.models'; +import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { filter } from 'rxjs/operators'; +import { + ArgumentType, + CalculatedFieldEventArguments, + CalculatedFieldTestScriptDialogData, + TestArgumentTypeMap +} from '@shared/models/calculated-field.models'; + +@Component({ + selector: 'tb-calculated-field-script-test-dialog', + templateUrl: './calculated-field-script-test-dialog.component.html', + styleUrls: ['./calculated-field-script-test-dialog.component.scss'], +}) +export class CalculatedFieldScriptTestDialogComponent extends DialogComponent implements AfterViewInit, OnDestroy { + + @ViewChild('leftPanel', {static: true}) leftPanelElmRef: ElementRef; + @ViewChild('rightPanel', {static: true}) rightPanelElmRef: ElementRef; + @ViewChild('topRightPanel', {static: true}) topRightPanelElmRef: ElementRef; + @ViewChild('bottomRightPanel', {static: true}) bottomRightPanelElmRef: ElementRef; + @ViewChild('testScriptContainer', {static: true}) testScriptContainer: ElementRef; + + @ViewChild('expressionContent', {static: true}) expressionContent: JsonContentComponent; + + calculatedFieldScriptTestFormGroup = this.fb.group({ + expression: ['', Validators.required], + arguments: [], + output: [] + }); + argumentsTypeMap = new Map(); + + readonly ContentType = ContentType; + readonly ScriptLanguage = ScriptLanguage; + readonly functionArgs = Object.keys(this.data.arguments); + + private testScriptResize: ResizeObserver; + private splitObjects: SplitObject[] = []; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: CalculatedFieldTestScriptDialogData, + protected dialogRef: MatDialogRef, + private dialog: MatDialog, + private fb: FormBuilder, + private destroyRef: DestroyRef, + private calculatedFieldService: CalculatedFieldsService) { + super(store, router, dialogRef); + beautifyJs(this.data.expression, {indent_size: 4}).pipe(filter(Boolean), takeUntilDestroyed()).subscribe( + (res) => this.calculatedFieldScriptTestFormGroup.get('expression').patchValue(res, {emitEvent: false}) + ); + this.calculatedFieldScriptTestFormGroup.get('arguments').patchValue(this.getArgumentsValue()); + } + + ngAfterViewInit(): void { + this.observeResize(); + } + + ngOnDestroy(): void { + super.ngOnDestroy(); + this.testScriptResize.disconnect(); + } + + cancel(): void { + this.dialogRef.close(null); + } + + onTestScript(): void { + this.testScript() + .pipe( + switchMap(output => beautifyJs(output, {indent_size: 4})), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(output => this.calculatedFieldScriptTestFormGroup.get('output').setValue(output)); + } + + save(): void { + this.testScript(true).pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { + this.calculatedFieldScriptTestFormGroup.get('expression').markAsPristine(); + this.dialogRef.close(this.calculatedFieldScriptTestFormGroup.get('expression').value); + }); + } + + private testScript(onSave = false): Observable { + if (this.checkInputParamErrors()) { + return this.calculatedFieldService.testScript({ + expression: this.calculatedFieldScriptTestFormGroup.get('expression').value, + arguments: this.getTestArguments() + }).pipe( + switchMap(result => { + if (result.error) { + this.store.dispatch(new ActionNotificationShow( + { + message: result.error, + type: 'error' + })); + return NEVER; + } else { + if (onSave && this.data.openCalculatedFieldEdit) { + this.dialog.closeAll(); + } + return of(result.output); + } + }), + ); + } else { + return NEVER; + } + } + + private checkInputParamErrors(): boolean { + this.expressionContent.validateOnSubmit(); + return !this.calculatedFieldScriptTestFormGroup.get('expression').invalid; + } + + private observeResize(): void { + this.testScriptResize = new ResizeObserver(() => { + this.updateSizes(); + }); + + this.testScriptResize.observe(this.testScriptContainer.nativeElement); + } + + private updateSizes(): void { + this.initSplitLayout(this.testScriptContainer.nativeElement.clientWidth <= 960); + } + + private getTestArguments(): CalculatedFieldEventArguments { + const argumentsValue = this.calculatedFieldScriptTestFormGroup.get('arguments').value; + return Object.keys(argumentsValue) + .reduce((acc, key) => { + acc[key] = argumentsValue[key]; + acc[key].type = TestArgumentTypeMap.get(this.argumentsTypeMap.get(key)); + return acc; + }, {}); + } + + private getArgumentsValue(): CalculatedFieldEventArguments { + return Object.keys(this.data.arguments) + .reduce((acc, key) => { + const { type, ...argumentObj } = this.data.arguments[key]; + this.argumentsTypeMap.set(key, type); + acc[key] = argumentObj; + return acc; + }, {}); + } + + private initSplitLayout(smallMode = false): void { + const [leftPanel, rightPanel, topRightPanel, bottomRightPanel] = [ + this.leftPanelElmRef.nativeElement, + this.rightPanelElmRef.nativeElement, + this.topRightPanelElmRef.nativeElement, + this.bottomRightPanelElmRef.nativeElement + ] as unknown as string[]; + + this.splitObjects.forEach(obj => obj.destroy()); + this.splitObjects = []; + + if (smallMode) { + this.splitObjects.push( + Split([leftPanel, rightPanel], { + sizes: [33, 67], + gutterSize: 8, + cursor: 'row-resize', + direction: 'vertical' + }), + Split([topRightPanel, bottomRightPanel], { + sizes: [50, 50], + gutterSize: 8, + cursor: 'row-resize', + direction: 'vertical' + }), + ); + } else { + this.splitObjects.push( + Split([leftPanel, rightPanel], { + sizes: [50, 50], + gutterSize: 8, + cursor: 'col-resize' + }), + Split([topRightPanel, bottomRightPanel], { + sizes: [50, 50], + gutterSize: 8, + cursor: 'row-resize', + direction: 'vertical' + }) + ); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.html index 3fbfcdaa9c..dacfa60d22 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.html @@ -1,6 +1,6 @@ - {{ subEntity.name | customTranslate }} diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-chips.component.scss b/ui-ngx/src/app/modules/home/components/entity/entity-chips.component.scss index 72098fff51..a689dd371e 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-chips.component.scss +++ b/ui-ngx/src/app/modules/home/components/entity/entity-chips.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2024 The Thingsboard Authors + * Copyright © 2016-2025 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. @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - :host { display: flex; flex-wrap: wrap; diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-chips.component.ts b/ui-ngx/src/app/modules/home/components/entity/entity-chips.component.ts index 6aad28c638..f45c2d01b5 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-chips.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entity-chips.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2024 The Thingsboard Authors +/// Copyright © 2016-2025 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. @@ -18,7 +18,7 @@ import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; import { BaseData } from '@shared/models/base-data'; import { EntityId } from '@shared/models/id/entity-id'; import { baseDetailsPageByEntityType, EntityType } from '@app/shared/public-api'; -import { isEqual, isObject } from '@core/utils'; +import { isEqual, isNotEmptyStr, isObject } from '@core/utils'; @Component({ selector: 'tb-entity-chips', @@ -33,6 +33,9 @@ export class EntityChipsComponent implements OnChanges { @Input() key: string; + @Input() + detailsPagePrefixUrl: string; + entityDetailsPrefixUrl: string; subEntities: Array> = []; @@ -52,7 +55,9 @@ export class EntityChipsComponent implements OnChanges { if (isObject(entitiesList) && !Array.isArray(entitiesList)) { entitiesList = [entitiesList]; } - if (Array.isArray(entitiesList)) { + if (isNotEmptyStr(this.detailsPagePrefixUrl)) { + this.entityDetailsPrefixUrl = this.detailsPagePrefixUrl; + } else if (Array.isArray(entitiesList)) { if (entitiesList.length) { this.entityDetailsPrefixUrl = baseDetailsPageByEntityType.get(entitiesList[0].id.entityType as EntityType); } diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.html b/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.html index 49a868a7e7..1f9de30cb8 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.html +++ b/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.html @@ -1,6 +1,6 @@ +
+
{{ title }}
+ + rule-node-config.save-time-series.strategy + + @for (strategy of processingStrategies; track strategy) { + {{ ProcessingTypeTranslationMap.get(strategy) | translate }} + } + + + @if (processingSettingRowForm.get('type').value === ProcessingType.DEDUPLICATE) { + + + } +
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.ts new file mode 100644 index 0000000000..ac4210fa8a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.ts @@ -0,0 +1,114 @@ +/// +/// Copyright © 2016-2025 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, forwardRef, Input } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator +} from '@angular/forms'; +import { + AdvancedProcessingConfig, + defaultAdvancedProcessingConfig, + maxDeduplicateTimeSecs, + ProcessingType, + ProcessingTypeTranslationMap +} from '@home/components/rule-node/action/timeseries-config.models'; +import { isDefinedAndNotNull } from '@core/utils'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +@Component({ + selector: 'tb-advanced-processing-setting-row', + templateUrl: './advanced-processing-setting-row.component.html', + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AdvancedProcessingSettingRowComponent), + multi: true + },{ + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AdvancedProcessingSettingRowComponent), + multi: true + }] +}) +export class AdvancedProcessingSettingRowComponent implements ControlValueAccessor, Validator { + + @Input() + title: string; + + processingSettingRowForm = this.fb.group({ + type: [defaultAdvancedProcessingConfig.type], + deduplicationIntervalSecs: [{value: 60, disabled: true}] + }); + + ProcessingType = ProcessingType; + processingStrategies = [ProcessingType.ON_EVERY_MESSAGE, ProcessingType.DEDUPLICATE, ProcessingType.SKIP]; + ProcessingTypeTranslationMap = ProcessingTypeTranslationMap; + + maxDeduplicateTime = maxDeduplicateTimeSecs; + + private propagateChange: (value: any) => void = () => {}; + + constructor(private fb: FormBuilder) { + this.processingSettingRowForm.get('type').valueChanges.pipe( + takeUntilDestroyed() + ).subscribe(() => this.updatedValidation()); + + this.processingSettingRowForm.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((value) => this.propagateChange(value)); + } + + registerOnChange(fn: any) { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any) { + } + + setDisabledState(isDisabled: boolean) { + if (isDisabled) { + this.processingSettingRowForm.disable({emitEvent: false}); + } else { + this.processingSettingRowForm.enable({emitEvent: false}); + this.updatedValidation(); + } + } + + validate(): ValidationErrors | null { + return this.processingSettingRowForm.valid ? null : { + processingSettingRow: false + }; + } + + writeValue(value: AdvancedProcessingConfig) { + if (isDefinedAndNotNull(value)) { + this.processingSettingRowForm.patchValue(value, {emitEvent: false}); + } else { + this.processingSettingRowForm.patchValue(defaultAdvancedProcessingConfig); + } + } + + private updatedValidation() { + if (this.processingSettingRowForm.get('type').value === ProcessingType.DEDUPLICATE) { + this.processingSettingRowForm.get('deduplicationIntervalSecs').enable({emitEvent: false}); + } else { + this.processingSettingRowForm.get('deduplicationIntervalSecs').disable({emitEvent: false}) + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html new file mode 100644 index 0000000000..d72e7f1e43 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html @@ -0,0 +1,40 @@ + +
+ + + + + + +
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.ts new file mode 100644 index 0000000000..9e204c0219 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.ts @@ -0,0 +1,83 @@ +/// +/// Copyright © 2016-2025 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 { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator +} from '@angular/forms'; +import { Component, forwardRef } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AdvancedProcessingStrategy } from '@home/components/rule-node/action/timeseries-config.models'; + +@Component({ + selector: 'tb-advanced-processing-settings', + templateUrl: './advanced-processing-setting.component.html', + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AdvancedProcessingSettingComponent), + multi: true + },{ + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AdvancedProcessingSettingComponent), + multi: true + }] +}) +export class AdvancedProcessingSettingComponent implements ControlValueAccessor, Validator { + + processingForm = this.fb.group({ + timeseries: [null], + latest: [null], + webSockets: [null], + calculatedFields: [null] + }); + + private propagateChange: (value: any) => void = () => {}; + + constructor(private fb: FormBuilder) { + this.processingForm.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe(value => this.propagateChange(value)); + } + + registerOnChange(fn: any) { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any) { + } + + setDisabledState(isDisabled: boolean) { + if (isDisabled) { + this.processingForm.disable({emitEvent: false}); + } else { + this.processingForm.enable({emitEvent: false}); + } + } + + validate(): ValidationErrors | null { + return this.processingForm.valid ? null : { + processingForm: false + }; + } + + writeValue(value: AdvancedProcessingStrategy) { + this.processingForm.patchValue(value, {emitEvent: false}); + } +} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/assign-customer-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/assign-customer-config.component.html index 4ec27d83e7..bb0076c444 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/assign-customer-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/assign-customer-config.component.html @@ -1,6 +1,6 @@
- - rule-node-config.default-ttl - - - help - - - {{ 'rule-node-config.default-ttl-required' | translate }} - - - {{ 'rule-node-config.min-default-ttl-message' | translate }} - - -
-
- - {{ 'rule-node-config.use-server-ts' | translate }} - -
-
- - {{ 'rule-node-config.skip-latest-persistence' | translate }} - +
+
+
+ rule-node-config.save-time-series.processing-settings +
+ + {{ 'rule-node-config.basic-mode' | translate}} + {{ 'rule-node-config.advanced-mode' | translate }} +
+ @if(!timeseriesConfigForm.get('processingSettings.isAdvanced').value) { + + rule-node-config.save-time-series.strategy + + @for (strategy of processingStrategies; track strategy) { + {{ ProcessingTypeTranslationMap.get(strategy) | translate }} + } + + + + @if(timeseriesConfigForm.get('processingSettings.type').value === ProcessingType.DEDUPLICATE) { + + + } + } @else { + + }
+
+ + + rule-node-config.advanced-settings + + +
+ + {{ 'rule-node-config.use-server-ts' | translate }} + +
+ + + help + + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.component.ts index d480ec4d39..7f27bca241 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2024 The Thingsboard Authors +/// Copyright © 2016-2025 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. @@ -15,8 +15,18 @@ /// import { Component } from '@angular/core'; -import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; -import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/models/rule-node.models'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { RuleNodeConfigurationComponent } from '@shared/models/rule-node.models'; +import { + defaultAdvancedProcessingStrategy, + maxDeduplicateTimeSecs, + ProcessingSettings, + ProcessingSettingsForm, + ProcessingType, + ProcessingTypeTranslationMap, + TimeseriesNodeConfiguration, + TimeseriesNodeConfigurationForm +} from '@home/components/rule-node/action/timeseries-config.models'; @Component({ selector: 'tb-action-node-timeseries-config', @@ -25,21 +35,98 @@ import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/m }) export class TimeseriesConfigComponent extends RuleNodeConfigurationComponent { - timeseriesConfigForm: UntypedFormGroup; + timeseriesConfigForm: FormGroup; - constructor(private fb: UntypedFormBuilder) { + ProcessingType = ProcessingType; + processingStrategies = [ProcessingType.ON_EVERY_MESSAGE, ProcessingType.DEDUPLICATE, ProcessingType.WEBSOCKETS_ONLY]; + ProcessingTypeTranslationMap = ProcessingTypeTranslationMap; + + maxDeduplicateTime = maxDeduplicateTimeSecs + + constructor(private fb: FormBuilder) { super(); } - protected configForm(): UntypedFormGroup { + protected configForm(): FormGroup { return this.timeseriesConfigForm; } - protected onConfigurationSet(configuration: RuleNodeConfiguration) { + protected validatorTriggers(): string[] { + return ['processingSettings.isAdvanced', 'processingSettings.type']; + } + + protected prepareInputConfig(config: TimeseriesNodeConfiguration): TimeseriesNodeConfigurationForm { + let processingSettings: ProcessingSettingsForm; + if (config?.processingSettings) { + const isAdvanced = config?.processingSettings?.type === ProcessingType.ADVANCED; + processingSettings = { + type: isAdvanced ? ProcessingType.ON_EVERY_MESSAGE : config.processingSettings.type, + isAdvanced: isAdvanced, + deduplicationIntervalSecs: config.processingSettings?.deduplicationIntervalSecs ?? 60, + advanced: isAdvanced ? config.processingSettings : defaultAdvancedProcessingStrategy + } + } else { + processingSettings = { + type: ProcessingType.ON_EVERY_MESSAGE, + isAdvanced: false, + deduplicationIntervalSecs: 60, + advanced: defaultAdvancedProcessingStrategy + }; + } + return { + ...config, + processingSettings: processingSettings + } + } + + protected prepareOutputConfig(config: TimeseriesNodeConfigurationForm): TimeseriesNodeConfiguration { + let processingSettings: ProcessingSettings; + if (config.processingSettings.isAdvanced) { + processingSettings = { + ...config.processingSettings.advanced, + type: ProcessingType.ADVANCED + }; + } else { + processingSettings = { + type: config.processingSettings.type, + deduplicationIntervalSecs: config.processingSettings?.deduplicationIntervalSecs + }; + } + return { + ...config, + processingSettings + }; + } + + protected onConfigurationSet(config: TimeseriesNodeConfigurationForm) { this.timeseriesConfigForm = this.fb.group({ - defaultTTL: [configuration ? configuration.defaultTTL : null, [Validators.required, Validators.min(0)]], - skipLatestPersistence: [configuration ? configuration.skipLatestPersistence : false, []], - useServerTs: [configuration ? configuration.useServerTs : false, []] + processingSettings: this.fb.group({ + isAdvanced: [config?.processingSettings?.isAdvanced ?? false], + type: [config?.processingSettings?.type ?? ProcessingType.ON_EVERY_MESSAGE], + deduplicationIntervalSecs: [ + {value: config?.processingSettings?.deduplicationIntervalSecs ?? 60, disabled: true}, + [Validators.required, Validators.max(maxDeduplicateTimeSecs)] + ], + advanced: [{value: null, disabled: true}] + }), + defaultTTL: [config?.defaultTTL ?? null, [Validators.required, Validators.min(0)]], + useServerTs: [config?.useServerTs ?? false] }); } + + protected updateValidators(emitEvent: boolean, _trigger?: string) { + const processingForm = this.timeseriesConfigForm.get('processingSettings') as FormGroup; + const isAdvanced: boolean = processingForm.get('isAdvanced').value; + const type: ProcessingType = processingForm.get('type').value; + if (!isAdvanced && type === ProcessingType.DEDUPLICATE) { + processingForm.get('deduplicationIntervalSecs').enable({emitEvent}); + } else { + processingForm.get('deduplicationIntervalSecs').disable({emitEvent}); + } + if (isAdvanced) { + processingForm.get('advanced').enable({emitEvent}); + } else { + processingForm.get('advanced').disable({emitEvent}); + } + } } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.models.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.models.ts new file mode 100644 index 0000000000..9785125999 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.models.ts @@ -0,0 +1,80 @@ +/// +/// Copyright © 2016-2025 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 { DAY, SECOND } from '@shared/models/time/time.models'; + +export const maxDeduplicateTimeSecs = DAY / SECOND; + +export interface TimeseriesNodeConfiguration { + processingSettings: ProcessingSettings; + defaultTTL: number; + useServerTs: boolean; +} + +export interface TimeseriesNodeConfigurationForm extends Omit { + processingSettings: ProcessingSettingsForm +} + +export type ProcessingSettings = BasicProcessingSettings & Partial & Partial; + +export type ProcessingSettingsForm = Omit & { + isAdvanced: boolean; + advanced?: Partial; + type: ProcessingType; +}; + +export enum ProcessingType { + ON_EVERY_MESSAGE = 'ON_EVERY_MESSAGE', + DEDUPLICATE = 'DEDUPLICATE', + WEBSOCKETS_ONLY = 'WEBSOCKETS_ONLY', + ADVANCED = 'ADVANCED', + SKIP = 'SKIP' +} + +export const ProcessingTypeTranslationMap = new Map([ + [ProcessingType.ON_EVERY_MESSAGE, 'rule-node-config.save-time-series.strategy-type.every-message'], + [ProcessingType.DEDUPLICATE, 'rule-node-config.save-time-series.strategy-type.deduplicate'], + [ProcessingType.WEBSOCKETS_ONLY, 'rule-node-config.save-time-series.strategy-type.web-sockets-only'], + [ProcessingType.SKIP, 'rule-node-config.save-time-series.strategy-type.skip'], +]) + +export interface BasicProcessingSettings { + type: ProcessingType; +} + +export interface DeduplicateProcessingStrategy extends BasicProcessingSettings { + deduplicationIntervalSecs: number; +} + +export interface AdvancedProcessingStrategy extends BasicProcessingSettings { + timeseries: AdvancedProcessingConfig; + latest: AdvancedProcessingConfig; + webSockets: AdvancedProcessingConfig; + calculatedFields: AdvancedProcessingConfig; +} + +export type AdvancedProcessingConfig = WithOptional; + +export const defaultAdvancedProcessingConfig: AdvancedProcessingConfig = { + type: ProcessingType.ON_EVERY_MESSAGE +} + +export const defaultAdvancedProcessingStrategy: Omit = { + timeseries: defaultAdvancedProcessingConfig, + latest: defaultAdvancedProcessingConfig, + webSockets: defaultAdvancedProcessingConfig, + calculatedFields: defaultAdvancedProcessingConfig, +} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/unassign-customer-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/unassign-customer-config.component.html index 4b2df342ab..ffa18da35d 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/unassign-customer-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/unassign-customer-config.component.html @@ -1,6 +1,6 @@ -
+
-
+
+
+ + {{ labelText }} + +
+ +
+ + + {{ requiredText }} + + + {{ minErrorText }} + + + {{ maxErrorText }} + +
+ + rule-node-config.units + + @for (timeUnit of timeUnits; track timeUnit) { + {{ timeUnitTranslations.get(timeUnit) | translate }} + } + + +
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts new file mode 100644 index 0000000000..54bb7fa9b8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts @@ -0,0 +1,199 @@ +/// +/// Copyright © 2016-2025 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, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { TimeUnit, timeUnitTranslations } from '../rule-node-config.models'; +import { isDefinedAndNotNull, isNumeric } from '@core/utils'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { coerceBoolean, coerceNumber } from '@shared/decorators/coercion'; +import { DAY, HOUR, MINUTE, SECOND } from '@shared/models/time/time.models'; +import { SubscriptSizing } from '@angular/material/form-field'; + +interface TimeUnitInputModel { + time: number; + timeUnit: TimeUnit +} + +@Component({ + selector: 'tb-time-unit-input', + templateUrl: './time-unit-input.component.html', + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeUnitInputComponent), + multi: true + },{ + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TimeUnitInputComponent), + multi: true + }] +}) +export class TimeUnitInputComponent implements ControlValueAccessor, Validator, OnInit { + + @Input() + labelText: string; + + @Input() + @coerceBoolean() + required: boolean; + + @Input() + requiredText: string; + + @Input() + @coerceNumber() + minTime = 0; + + @Input() + minErrorText: string; + + @Input() + @coerceNumber() + maxTime: number; + + @Input() + maxErrorText: string; + + @Input() + subscriptSizing: SubscriptSizing = 'fixed'; + + timeUnits = Object.values(TimeUnit).filter(item => item !== TimeUnit.MILLISECONDS) as TimeUnit[]; + + timeUnitTranslations = timeUnitTranslations; + + timeInputForm = this.fb.group({ + time: [0], + timeUnit: [TimeUnit.SECONDS] + }); + + private timeIntervalsInSec = new Map([ + [TimeUnit.DAYS, DAY/SECOND], + [TimeUnit.HOURS, HOUR/SECOND], + [TimeUnit.MINUTES, MINUTE/SECOND], + [TimeUnit.SECONDS, SECOND/SECOND], + ]); + + private modelValue: number; + + private propagateChange: (value: any) => void = () => {}; + + constructor(private fb: FormBuilder, + private destroyRef: DestroyRef) { + } + + ngOnInit() { + if(this.required || this.maxTime) { + const timeControl = this.timeInputForm.get('time'); + const validators = [Validators.pattern(/^\d*$/)]; + if (this.required) { + validators.push(Validators.required); + } + if (this.maxTime) { + validators.push((control: AbstractControl) => + Validators.max(Math.floor(this.maxTime / this.timeIntervalsInSec.get(this.timeInputForm.get('timeUnit').value)))(control) + ); + } + if (isDefinedAndNotNull(this.minTime)) { + validators.push(Validators.min(this.minTime)); + } + + timeControl.setValidators(validators); + timeControl.updateValueAndValidity({ emitEvent: false }); + } + + this.timeInputForm.get('timeUnit').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.timeInputForm.get('time').updateValueAndValidity({onlySelf: true}); + this.timeInputForm.get('time').markAsTouched({onlySelf: true}); + }); + + this.timeInputForm.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(value => { + this.updatedModel(value); + }); + } + + registerOnChange(fn: any) { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any) { + } + + setDisabledState(isDisabled: boolean) { + if (isDisabled) { + this.timeInputForm.disable({emitEvent: false}); + } else { + this.timeInputForm.enable({emitEvent: false}); + if(this.timeInputForm.invalid) { + setTimeout(() => this.updatedModel(this.timeInputForm.value, true)) + } + } + } + + writeValue(sec: number) { + if (sec !== this.modelValue) { + if (isDefinedAndNotNull(sec) && isNumeric(sec) && Number(sec) !== 0) { + this.timeInputForm.patchValue(this.parseTime(sec), {emitEvent: false}); + this.modelValue = sec; + } else { + this.timeInputForm.patchValue({ + time: 0, + timeUnit: TimeUnit.SECONDS + }, {emitEvent: false}); + this.modelValue = 0; + } + } + } + + validate(): ValidationErrors | null { + return this.timeInputForm.valid ? null : { + timeInput: false + }; + } + + private updatedModel(value: Partial, forceUpdated = false) { + const time = value.time * this.timeIntervalsInSec.get(value.timeUnit); + if (this.modelValue !== time || forceUpdated) { + this.modelValue = time; + this.propagateChange(time); + } + } + + private parseTime(value: number): TimeUnitInputModel { + for (const [timeUnit, timeValue] of this.timeIntervalsInSec) { + const calc = value / timeValue; + if (Number.isInteger(calc)) { + return { + time: calc, + timeUnit: timeUnit + } + } + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/empty-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/empty-config.component.ts index 48f350157f..91eff802f2 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/empty-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/empty-config.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2024 The Thingsboard Authors +/// Copyright © 2016-2025 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. diff --git a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/calculate-delta-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/calculate-delta-config.component.html index 6d9924f4f8..ef62b2daf1 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/calculate-delta-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/calculate-delta-config.component.html @@ -1,6 +1,6 @@ + + +
+
widgets.value-stepper.behavior
+
+
widgets.value-stepper.initial-state
+ +
+
+
widgets.value-stepper.left-button-click
+ +
+
+
widgets.value-stepper.right-button-click
+ +
+
+
widgets.button-state.disabled-state
+ +
+
+
+
widget-config.appearance
+ + + {{ valueStepperTypeTranslationMap.get(type) | translate }} + + +
+ + {{ 'widgets.value-stepper.auto-scale' | translate }} + +
+ +
+
{{ 'widgets.value-stepper.value-range' | translate }}
+
+
widgets.value-stepper.min-range
+ + + +
widgets.value-stepper.max-range
+ + + +
+
+
+
{{ 'widgets.value-stepper.value-increment-decrement-step' | translate }}
+ + + +
+
+ + {{ 'widgets.value-stepper.value' | translate }} + +
+ + + +
widget-config.decimals-suffix
+
+ + + + +
+
+ +
+
{{ 'widgets.value-stepper.value-box-background' | translate }}
+ + +
+
+ + {{ 'widgets.value-stepper.border' | translate }} + +
+ + +
px
+
+ + +
+
+
+
+
+
widgets.value-stepper.button-appearance
+ + {{ 'widgets.value-stepper.left' | translate }} + {{ 'widgets.value-stepper.right' | translate }} + +
+
+
+ + {{ 'widgets.value-stepper.left-button' | translate }} + +
+
+
{{ 'widgets.value-stepper.icon' | translate }}
+
+ + + + + + +
+
+
+
{{ 'widgets.value-stepper.button-on-colors' | translate }}
+
+
+
widgets.value-stepper.main
+ + +
+ +
+
widgets.value-stepper.background
+ + +
+
+
+
+
{{ 'widgets.value-stepper.disabled-colors' | translate }}
+
+
+
widgets.value-stepper.main
+ + +
+ +
+
widgets.value-stepper.background
+ + +
+
+
+
+
+
+ + {{ 'widgets.value-stepper.right-button' | translate }} + +
+
+
{{ 'widgets.value-stepper.icon' | translate }}
+
+ + + + + + +
+
+
+
{{ 'widgets.value-stepper.button-on-colors' | translate }}
+
+
+
widgets.value-stepper.main
+ + +
+ +
+
widgets.value-stepper.background
+ + +
+
+
+
+
{{ 'widgets.value-stepper.disabled-colors' | translate }}
+
+
+
widgets.value-stepper.main
+ + +
+ +
+
widgets.value-stepper.background
+ + +
+
+
+
+
+
+
widget-config.card-appearance
+
+
{{ 'widgets.background.background' | translate }}
+ + +
+
+
widget-config.show-card-buttons
+ + {{ 'fullscreen.fullscreen' | translate }} + +
+
+
{{ 'widget-config.card-border-radius' | translate }}
+ + + +
+
+
{{ 'widget-config.card-padding' | translate }}
+ + + +
+
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/value-stepper-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/value-stepper-basic-config.component.ts new file mode 100644 index 0000000000..763816b0f9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/value-stepper-basic-config.component.ts @@ -0,0 +1,225 @@ +/// +/// Copyright © 2016-2025 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 } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models'; +import { WidgetConfigComponentData } from '@home/models/widget-component.models'; +import { TargetDevice, WidgetConfig, } from '@shared/models/widget.models'; +import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; +import { formatValue, isUndefined } from '@core/utils'; +import { ValueType } from '@shared/models/constants'; +import { + valueStepperDefaultSettings, + valueStepperTypeImages, + valueStepperTypes, + valueStepperTypeTranslations, + ValueStepperWidgetSettings +} from '@home/components/widget/lib/rpc/value-stepper-widget.models'; + +type ButtonAppearanceType = 'left' | 'right'; + +@Component({ + selector: 'tb-value-stepper-basic-config', + templateUrl: './value-stepper-basic-config.component.html', + styleUrls: ['../basic-config.scss'] +}) +export class ValueStepperBasicConfigComponent extends BasicWidgetConfigComponent { + + get targetDevice(): TargetDevice { + return this.valueStepperWidgetConfigForm.get('targetDevice').value; + } + + valueStepperTypeTranslationMap = valueStepperTypeTranslations; + valueStepperTypes = valueStepperTypes; + valueStepperTypeImageMap = valueStepperTypeImages; + + buttonAppearanceType: ButtonAppearanceType = 'left'; + + valueType = ValueType; + + valueStepperWidgetConfigForm: UntypedFormGroup; + + valuePreviewFn = this._valuePreviewFn.bind(this); + + constructor(protected store: Store, + protected widgetConfigComponent: WidgetConfigComponent, + private fb: UntypedFormBuilder) { + super(store, widgetConfigComponent); + } + + protected configForm(): UntypedFormGroup { + return this.valueStepperWidgetConfigForm; + } + + protected onConfigSet(configData: WidgetConfigComponentData) { + const settings: ValueStepperWidgetSettings = {...valueStepperDefaultSettings, ...(configData.config.settings || {})}; + this.valueStepperWidgetConfigForm = this.fb.group({ + targetDevice: [configData.config.targetDevice, []], + + initialState: [settings.initialState, []], + leftButtonClick: [settings.leftButtonClick, []], + rightButtonClick: [settings.rightButtonClick, []], + disabledState: [settings.disabledState, []], + + appearance: this.fb.group({ + type: [settings.appearance.type, []], + autoScale: [settings.appearance.autoScale, []], + minValueRange: [settings.appearance.minValueRange, []], + maxValueRange: [settings.appearance.maxValueRange, []], + valueStep: [settings.appearance.valueStep, [Validators.min(0)]], + showValueBox: [settings.appearance.showValueBox, []], + valueUnits: [settings.appearance.valueUnits, []], + valueDecimals: [settings.appearance.valueDecimals, []], + valueFont: [settings.appearance.valueFont, []], + valueColor: [settings.appearance.valueColor], + valueBoxBackground: [settings.appearance.valueBoxBackground, []], + showBorder: [settings.appearance.showBorder, []], + borderWidth: [settings.appearance.borderWidth, []], + borderColor: [settings.appearance.borderColor, []] + }), + + buttonAppearance: this.fb.group({ + leftButton: this.fb.group({ + showButton: [settings.buttonAppearance.leftButton.showButton], + icon: [settings.buttonAppearance.leftButton.icon], + iconSize: [settings.buttonAppearance.leftButton.iconSize], + iconSizeUnit: [settings.buttonAppearance.leftButton.iconSizeUnit], + mainColorOn: [settings.buttonAppearance.leftButton.mainColorOn, []], + backgroundColorOn: [settings.buttonAppearance.leftButton.backgroundColorOn, []], + mainColorDisabled: [settings.buttonAppearance.leftButton.mainColorDisabled, []], + backgroundColorDisabled: [settings.buttonAppearance.leftButton.backgroundColorDisabled, []] + }), + rightButton: this.fb.group({ + showButton: [settings.buttonAppearance.rightButton.showButton], + icon: [settings.buttonAppearance.rightButton.icon], + iconSize: [settings.buttonAppearance.rightButton.iconSize], + iconSizeUnit: [settings.buttonAppearance.rightButton.iconSizeUnit], + mainColorOn: [settings.buttonAppearance.rightButton.mainColorOn, []], + backgroundColorOn: [settings.buttonAppearance.rightButton.backgroundColorOn, []], + mainColorDisabled: [settings.buttonAppearance.rightButton.mainColorDisabled, []], + backgroundColorDisabled: [settings.buttonAppearance.rightButton.backgroundColorDisabled, []] + }) + }), + + background: [settings.background, []], + cardButtons: [this.getCardButtons(configData.config), []], + borderRadius: [configData.config.borderRadius, []], + padding: [settings.padding, []], + + actions: [configData.config.actions || {}, []] + }); + } + + + protected prepareOutputConfig(config: any): WidgetConfigComponentData { + this.widgetConfig.config.targetDevice = config.targetDevice; + this.widgetConfig.config.settings = this.widgetConfig.config.settings || {}; + this.widgetConfig.config.settings.initialState = config.initialState; + this.widgetConfig.config.settings.disabledState = config.disabledState; + this.widgetConfig.config.settings.leftButtonClick = config.leftButtonClick; + this.widgetConfig.config.settings.rightButtonClick = config.rightButtonClick; + this.widgetConfig.config.settings.appearance = config.appearance; + this.widgetConfig.config.settings.buttonAppearance = config.buttonAppearance; + + this.widgetConfig.config.settings.background = config.background; + this.setCardButtons(config.cardButtons, this.widgetConfig.config); + this.widgetConfig.config.borderRadius = config.borderRadius; + this.widgetConfig.config.settings.padding = config.padding; + this.widgetConfig.config.actions = config.actions; + + return this.widgetConfig; + } + + + protected validatorTriggers(): string[] { + return ['appearance.showValueBox', 'appearance.showBorder', + 'buttonAppearance.leftButton.showButton', 'buttonAppearance.rightButton.showButton']; + } + + protected updateValidators(emitEvent: boolean, trigger?: string) { + const showValueBox: boolean = this.valueStepperWidgetConfigForm.get('appearance').get('showValueBox').value; + const showBorder: boolean = this.valueStepperWidgetConfigForm.get('appearance').get('showBorder').value; + const showLeftButton: boolean = this.valueStepperWidgetConfigForm.get('buttonAppearance').get('leftButton').get('showButton').value; + const showRightButton: boolean = this.valueStepperWidgetConfigForm.get('buttonAppearance').get('rightButton').get('showButton').value; + if (showValueBox) { + this.valueStepperWidgetConfigForm.get('appearance').get('valueUnits').enable(); + this.valueStepperWidgetConfigForm.get('appearance').get('valueDecimals').enable(); + this.valueStepperWidgetConfigForm.get('appearance').get('valueFont').enable(); + this.valueStepperWidgetConfigForm.get('appearance').get('valueColor').enable(); + this.valueStepperWidgetConfigForm.get('appearance').get('valueBoxBackground').enable(); + this.valueStepperWidgetConfigForm.get('appearance').get('showBorder').enable({emitEvent: false}); + if (showBorder) { + this.valueStepperWidgetConfigForm.get('appearance').get('borderWidth').enable(); + this.valueStepperWidgetConfigForm.get('appearance').get('borderColor').enable(); + } else { + this.valueStepperWidgetConfigForm.get('appearance').get('borderWidth').disable(); + this.valueStepperWidgetConfigForm.get('appearance').get('borderColor').disable(); + } + } else { + this.valueStepperWidgetConfigForm.get('appearance').get('valueUnits').disable(); + this.valueStepperWidgetConfigForm.get('appearance').get('valueDecimals').disable(); + this.valueStepperWidgetConfigForm.get('appearance').get('valueFont').disable(); + this.valueStepperWidgetConfigForm.get('appearance').get('valueColor').disable(); + this.valueStepperWidgetConfigForm.get('appearance').get('valueBoxBackground').disable(); + this.valueStepperWidgetConfigForm.get('appearance').get('showBorder').disable({emitEvent: false}); + this.valueStepperWidgetConfigForm.get('appearance').get('borderWidth').disable(); + this.valueStepperWidgetConfigForm.get('appearance').get('borderColor').disable(); + } + this.buttonValidators(showLeftButton, 'leftButton'); + this.buttonValidators(showRightButton, 'rightButton'); + } + + private buttonValidators(showButtonValue: boolean, button: string) { + if (showButtonValue) { + this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('icon').enable() + this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('iconSize').enable() + this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('iconSizeUnit').enable() + this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('mainColorOn').enable() + this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('backgroundColorOn').enable() + this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('mainColorDisabled').enable() + this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('backgroundColorDisabled').enable() + } else { + this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('icon').disable() + this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('iconSize').disable() + this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('iconSizeUnit').disable() + this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('mainColorOn').disable() + this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('backgroundColorOn').disable() + this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('mainColorDisabled').disable() + this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('backgroundColorDisabled').disable() + } + } + + private getCardButtons(config: WidgetConfig): string[] { + const buttons: string[] = []; + if (isUndefined(config.enableFullscreen) || config.enableFullscreen) { + buttons.push('fullscreen'); + } + return buttons; + } + + private setCardButtons(buttons: string[], config: WidgetConfig) { + config.enableFullscreen = buttons.includes('fullscreen'); + } + + private _valuePreviewFn(): string { + const units: string = this.valueStepperWidgetConfigForm.get('appearance').get('valueUnits').value; + const decimals: number = this.valueStepperWidgetConfigForm.get('appearance').get('valueDecimals').value; + return formatValue(48, decimals, units, false); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-symbol-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-symbol-basic-config.component.html index d3e31fe189..4b5cb39d81 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-symbol-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-symbol-basic-config.component.html @@ -1,6 +1,6 @@ +
+
+ +
+
+
+
+
{{ valueText }}
+
+
+
+
+ +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.component.scss new file mode 100644 index 0000000000..687f969f3b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.component.scss @@ -0,0 +1,86 @@ +/** + * Copyright © 2016-2025 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. + */ +.tb-value-stepper-panel { + width: 100%; + height: 100%; + position: relative; + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px 24px 24px 24px; + > div:not(.tb-value-stepper-overlay) { + z-index: 1; + } + .tb-value-stepper-overlay { + position: absolute; + top: 12px; + left: 12px; + bottom: 12px; + right: 12px; + } + div.tb-widget-title { + padding: 0; + } + .tb-value-stepper-content { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + min-width: 0; + min-height: 0; + height: 100%; + .tb-value-stepper-value-box { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + height: 32px; + padding: 0 12px; + border-radius: 4px; + &-disabled { + border-color: rgba(0, 0, 0, 0.38); + } + } + .tb-button-shape { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + &-left { + margin-right: 12px; + } + &-right { + margin-left: 12px; + } + svg { + .tb-small-shadow { + filter: drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.2)); + } + .tb-shadow { + filter: drop-shadow(0px 0px 10px rgba(0, 0, 0, 0.15)); + } + } + &.tb-button-pointer { + svg { + .tb-hover-circle { + cursor: pointer; + } + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.component.ts new file mode 100644 index 0000000000..92f6ea6907 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.component.ts @@ -0,0 +1,391 @@ +/// +/// Copyright © 2016-2025 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 { + AfterViewInit, + ChangeDetectorRef, + Component, + DestroyRef, + ElementRef, + inject, + NgZone, + OnDestroy, + OnInit, + Renderer2, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { BasicActionWidgetComponent, ValueSetter } from '@home/components/widget/lib/action/action-widget.models'; +import { backgroundStyle, ComponentStyle, overlayStyle, textStyle } from '@shared/models/widget-settings.models'; +import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { DomSanitizer } from '@angular/platform-browser'; +import { ValueType } from '@shared/models/constants'; +import { + PowerButtonLayout, + PowerButtonShape, + powerButtonShapeSize, + PowerButtonWidgetSettings +} from '@home/components/widget/lib/rpc/power-button-widget.models'; +import { SVG, Svg } from '@svgdotjs/svg.js'; +import { MatIconRegistry } from '@angular/material/icon'; +import { formatValue, isDefinedAndNotNull, isNumeric } from '@core/utils'; +import { + valueStepperDefaultSettings, + ValueStepperWidgetSettings +} from '@home/components/widget/lib/rpc/value-stepper-widget.models'; +import { UtilsService } from '@core/services/utils.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +@Component({ + selector: 'tb-value-stepper-widget', + templateUrl: './value-stepper-widget.component.html', + styleUrls: ['../action/action-widget.scss', './value-stepper-widget.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class ValueStepperWidgetComponent extends + BasicActionWidgetComponent implements OnInit, AfterViewInit, OnDestroy { + + @ViewChild('leftButton', {static: true}) + leftButton: ElementRef; + + @ViewChild('rightButton', {static: true}) + rightButton: ElementRef; + + @ViewChild('stepperContent', {static: true}) + stepperContent: ElementRef; + + @ViewChild('valueBoxContainer', {static: true}) + valueBox: ElementRef; + + @ViewChild('value', {static: true}) + valueElement: ElementRef; + + settings: ValueStepperWidgetSettings; + + backgroundStyle$: Observable; + overlayStyle: ComponentStyle = {}; + padding: string; + + valueStyle: ComponentStyle = {}; + valueStyleColor = ''; + disabledColor = 'rgba(0, 0, 0, 0.38)'; + value: number = null; + + autoScale = false; + + showValueBox = true; + showLeftButton = true; + showRightButton = true; + + valueText = 'N/A'; + + disabledState$ = new BehaviorSubject(false); + + private prevValue: number = null; + private shapeResize$: ResizeObserver; + private drawSvgShapePending = false; + private svgShapeLeft: Svg; + private svgShapeRight: Svg; + private powerButtonSvgShapeLeft: PowerButtonShape; + private powerButtonSvgShapeRight: PowerButtonShape; + + private disabledState = false; + public leftDisabledState = false; + public rightDisabledState = false; + + private valueSetterLeft: ValueSetter; + private valueSetterRight: ValueSetter; + + private leftDisabledState$ = new BehaviorSubject(false); + private rightDisabledState$ = new BehaviorSubject(false); + + protected destroyRef = inject(DestroyRef); + + constructor(protected imagePipe: ImagePipe, + protected sanitizer: DomSanitizer, + private renderer: Renderer2, + private iconRegistry: MatIconRegistry, + private utils: UtilsService, + private elementRef: ElementRef, + protected cd: ChangeDetectorRef, + protected zone: NgZone) { + super(cd); + } + + ngOnInit(): void { + super.ngOnInit(); + this.settings = {...valueStepperDefaultSettings, ...this.ctx.settings}; + + this.autoScale = this.settings.appearance.autoScale; + + this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); + this.overlayStyle = overlayStyle(this.settings.background.overlay); + this.padding = this.settings.background.overlay.enabled ? undefined : this.settings.padding; + + this.showValueBox = this.settings.appearance.showValueBox; + this.showLeftButton = this.settings.buttonAppearance.leftButton.showButton; + this.showRightButton = this.settings.buttonAppearance.rightButton.showButton; + this.valueStyle = textStyle(this.settings.appearance.valueFont); + this.valueStyleColor = this.settings.appearance.valueColor; + + if (this.showValueBox) { + const valueBoxCss = `.tb-value-stepper-value-box {\n`+ + `border: ${this.settings.appearance.showBorder ? + `${this.settings.appearance.borderWidth}px solid ${this.settings.appearance.borderColor}` : + 'none'};\n`+ + `background-color: ${this.settings.appearance.valueBoxBackground}` + + `}`; + this.utils.applyCssToElement(this.renderer, this.elementRef.nativeElement, 'tb-value-stepper-value-box', valueBoxCss); + } + + const getInitialStateSettings = + {...this.settings.initialState, actionLabel: this.ctx.translate.instant('widgets.slider.initial-value')}; + this.createValueGetter(getInitialStateSettings, ValueType.INTEGER, { + next: (value) => this.onValue(value) + }); + const disabledStateSettings = + {...this.settings.disabledState, actionLabel: this.ctx.translate.instant('widgets.rpc-state.disabled-state')}; + this.createValueGetter(disabledStateSettings, ValueType.BOOLEAN, { + next: (value) => this.disabledState$.next(value) + }); + + const leftButtonClick = {...this.settings.leftButtonClick, + actionLabel: this.ctx.translate.instant('widgets.slider.on-value-change')}; + this.valueSetterLeft = this.createValueSetter(leftButtonClick); + + const rightButtonClick = {...this.settings.rightButtonClick, + actionLabel: this.ctx.translate.instant('widgets.slider.on-value-change')}; + this.valueSetterRight = this.createValueSetter(rightButtonClick); + + combineLatest([ + this.loading$, + this.disabledState$.asObservable(), + this.leftDisabledState$.asObservable() + ]).pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(value => { + const state = value.includes(true); + this.updateLeftDisabledState(state) + }); + + combineLatest([ + this.loading$, + this.disabledState$.asObservable(), + this.rightDisabledState$.asObservable() + ]).pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(value => { + const state = value.includes(true); + this.updateRightDisabledState(state) + }); + } + + ngAfterViewInit(): void { + if (this.drawSvgShapePending) { + this.drawSvg(); + } + super.ngAfterViewInit(); + } + + ngOnDestroy() { + if (this.shapeResize$) { + this.shapeResize$.disconnect(); + } + super.ngOnDestroy(); + } + + public onInit() { + super.onInit(); + const borderRadius = this.ctx.$widgetElement.css('borderRadius'); + this.overlayStyle = {...this.overlayStyle, ...{borderRadius}}; + if (this.leftButton || this.rightButton) { + this.drawSvg(); + } else { + this.drawSvgShapePending = true; + } + this.cd.detectChanges(); + } + + private onValue(value: number): void { + this.value = value; + this.prevValue = value; + if ((this.value + this.settings.appearance.valueStep) > this.settings.appearance.maxValueRange) { + this.rightDisabledState$.next(true); + } else { + this.rightDisabledState$.next(false); + } + if ((this.value - this.settings.appearance.valueStep) < this.settings.appearance.minValueRange) { + this.leftDisabledState$.next(true); + } else { + this.leftDisabledState$.next(false); + } + this.updateValueText(); + this.cd.markForCheck(); + } + + private updateValueText() { + if (isDefinedAndNotNull(this.value) && isNumeric(this.value)) { + this.valueText = formatValue(this.value, this.settings.appearance.valueDecimals, this.settings.appearance.valueUnits, false); + } else { + this.valueText = 'N/A'; + } + } + + private onClick(rightButtonClick: boolean = false) { + this.updateValueText(); + if (!this.ctx.isEdit && !this.ctx.isPreview && !this.disabledState) { + const prevValue = this.prevValue; + const targetValue = rightButtonClick ? + (this.value + this.settings.appearance.valueStep) : + (this.value - this.settings.appearance.valueStep); + this.updateValue(rightButtonClick ? this.valueSetterRight : this.valueSetterLeft, targetValue, { + next: () => this.onValue(targetValue), + error: () => this.onValue(prevValue) + }); + } + } + + private drawSvg() { + let leftButtonSetting: PowerButtonWidgetSettings; + let rightButtonSetting: PowerButtonWidgetSettings; + if (this.showLeftButton) { + this.svgShapeLeft = SVG().addTo(this.leftButton.nativeElement).size(powerButtonShapeSize, powerButtonShapeSize); + this.renderer.setStyle(this.svgShapeLeft.node, 'overflow', 'visible'); + this.renderer.setStyle(this.svgShapeLeft.node, 'user-select', 'none'); + leftButtonSetting = { + layout: PowerButtonLayout[this.settings.appearance.type], + onButtonIcon: { + showIcon: true, + icon: this.settings.buttonAppearance.leftButton.icon, + iconSize: this.settings.buttonAppearance.leftButton.iconSize * 1.7, + iconSizeUnit: this.settings.buttonAppearance.leftButton.iconSizeUnit + }, + offButtonIcon: { + showIcon: true, + icon: this.settings.buttonAppearance.leftButton.icon, + iconSize: this.settings.buttonAppearance.leftButton.iconSize * 1.7, + iconSizeUnit: this.settings.buttonAppearance.leftButton.iconSizeUnit + }, + mainColorOn: this.settings.buttonAppearance.leftButton.mainColorOn, + backgroundColorOn: this.settings.buttonAppearance.leftButton.backgroundColorOn, + mainColorOff: this.settings.buttonAppearance.leftButton.mainColorOff, + backgroundColorOff: this.settings.buttonAppearance.leftButton.backgroundColorOff, + mainColorDisabled: this.settings.buttonAppearance.leftButton.mainColorDisabled, + backgroundColorDisabled: this.settings.buttonAppearance.leftButton.backgroundColorDisabled + }; + } + if (this.showRightButton) { + this.svgShapeRight = SVG().addTo(this.rightButton.nativeElement).size(powerButtonShapeSize, powerButtonShapeSize); + this.renderer.setStyle(this.svgShapeRight.node, 'overflow', 'visible'); + this.renderer.setStyle(this.svgShapeRight.node, 'user-select', 'none'); + + rightButtonSetting = { + layout: PowerButtonLayout[this.settings.appearance.type], + onButtonIcon: { + showIcon: true, + icon: this.settings.buttonAppearance.rightButton.icon, + iconSize: this.settings.buttonAppearance.rightButton.iconSize * 1.7, + iconSizeUnit: this.settings.buttonAppearance.rightButton.iconSizeUnit + }, + offButtonIcon: { + showIcon: true, + icon: this.settings.buttonAppearance.rightButton.icon, + iconSize: this.settings.buttonAppearance.rightButton.iconSize * 1.7, + iconSizeUnit: this.settings.buttonAppearance.rightButton.iconSizeUnit + }, + mainColorOn: this.settings.buttonAppearance.rightButton.mainColorOn, + backgroundColorOn: this.settings.buttonAppearance.rightButton.backgroundColorOn, + mainColorOff: this.settings.buttonAppearance.rightButton.mainColorOff, + backgroundColorOff: this.settings.buttonAppearance.rightButton.backgroundColorOff, + mainColorDisabled: this.settings.buttonAppearance.rightButton.mainColorDisabled, + backgroundColorDisabled: this.settings.buttonAppearance.rightButton.backgroundColorDisabled + }; + } + + this.zone.run(() => { + if (this.showLeftButton) { + this.powerButtonSvgShapeLeft = PowerButtonShape.fromSettings(this.ctx, this.svgShapeLeft, this.iconRegistry, + leftButtonSetting , true, this.leftDisabledState, () => this.onClick()); + } + if (this.showRightButton) { + this.powerButtonSvgShapeRight = PowerButtonShape.fromSettings(this.ctx, this.svgShapeRight, this.iconRegistry, + rightButtonSetting, true, this.rightDisabledState, () => this.onClick(true)); + } + }); + + this.shapeResize$ = new ResizeObserver(() => { + this.onResize(); + }); + if (this.autoScale) { + this.shapeResize$.observe(this.stepperContent.nativeElement); + } + if (this.showLeftButton) { + this.shapeResize$.observe(this.leftButton.nativeElement); + } + if (this.showRightButton) { + this.shapeResize$.observe(this.rightButton.nativeElement); + } + this.onResize(); + } + + private updateLeftDisabledState(disabled: boolean) { + this.leftDisabledState = disabled; + this.powerButtonSvgShapeLeft?.setDisabled(this.leftDisabledState); + this.cd.markForCheck(); + } + + + private updateRightDisabledState(disabled: boolean) { + this.rightDisabledState = disabled; + this.powerButtonSvgShapeRight?.setDisabled(this.rightDisabledState); + this.cd.markForCheck(); + } + + private onResize() { + const panelWidth = this.stepperContent.nativeElement.getBoundingClientRect().width; + const panelHeight = this.stepperContent.nativeElement.getBoundingClientRect().height; + + const minAspect = 0.2; + const avgContentHeight = 32; + const targetHeight = panelWidth * Math.min(panelHeight / panelWidth, minAspect); + const multiplier = targetHeight / avgContentHeight; + const size = avgContentHeight * multiplier; + + if (this.showValueBox) { + this.renderer.setStyle(this.valueBox?.nativeElement, 'height', `${size}px`); + this.renderer.setStyle(this.valueElement?.nativeElement, 'font-size', `${this.settings.appearance.valueFont.size * multiplier}px`); + } + if (this.showLeftButton) { + this.renderer.setStyle(this.leftButton?.nativeElement, 'width', `${size}px`); + this.renderer.setStyle(this.leftButton?.nativeElement, 'height', `${size}px`); + } + if (this.showRightButton) { + this.renderer.setStyle(this.rightButton?.nativeElement, 'width', `${size}px`); + this.renderer.setStyle(this.rightButton?.nativeElement, 'height', `${size}px`); + } + if (size) { + const scale = size / powerButtonShapeSize; + if (this.showLeftButton) { + this.renderer.setStyle(this.svgShapeLeft?.node, 'transform', `scale(${scale})`); + } + if (this.showRightButton) { + this.renderer.setStyle(this.svgShapeRight?.node, 'transform', `scale(${scale})`); + } + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.models.ts new file mode 100644 index 0000000000..e0603de4a5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.models.ts @@ -0,0 +1,252 @@ +/// +/// Copyright © 2016-2025 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 { + DataToValueType, + GetValueAction, + GetValueSettings, + SetValueAction, + SetValueSettings, + ValueToDataType +} from '@shared/models/action-widget-settings.models'; +import { WidgetButtonCustomStyles } from '@shared/components/button/widget-button.models'; +import { BackgroundSettings, BackgroundType, cssUnit, Font } from '@shared/models/widget-settings.models'; +import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; + +const defaultMainColor = '#305680'; + +export enum ValueStepperType { + simplified = 'simplified', + default = 'default', + default_volume = 'default_volume' +} + +export const valueStepperTypes = Object.keys(ValueStepperType) as ValueStepperType[]; + +export const valueStepperTypeTranslations = new Map( + [ + [ValueStepperType.simplified, 'widgets.value-stepper.simplified'], + [ValueStepperType.default, 'widgets.value-stepper.filled'], + [ValueStepperType.default_volume, 'widgets.value-stepper.volume'] + ] +); + +export const valueStepperTypeImages = new Map( + [ + [ValueStepperType.simplified, 'assets/widget/value-stepper/simplified.svg'], + [ValueStepperType.default, 'assets/widget/value-stepper/filled.svg'], + [ValueStepperType.default_volume, 'assets/widget/value-stepper/volume.svg'] + ] +); + +export interface ValueStepperWidgetSettings { + initialState: GetValueSettings; + leftButtonClick: SetValueSettings; + rightButtonClick: SetValueSettings; + disabledState: GetValueSettings; + + appearance: ValueStepperAppearance; + buttonAppearance: { + leftButton: ValueStepperButtonAppearance; + rightButton: ValueStepperButtonAppearance; + } + + background: BackgroundSettings; + padding: string; +} + +export interface ValueStepperAppearance { + type: ValueStepperType; + autoScale: boolean; + minValueRange: number; + maxValueRange: number; + valueStep: number; + showValueBox: boolean; + valueUnits: string; + valueDecimals: number; + valueFont: Font; + valueColor: string; + valueBoxBackground: string; + showBorder: boolean; + borderWidth: number; + borderColor: string; +} + +export interface ValueStepperButtonAppearance { + showButton: boolean; + icon: string; + iconSize: number; + iconSizeUnit: cssUnit; + mainColorOn: string; + backgroundColorOn: string; + mainColorOff: string; + backgroundColorOff: string; + mainColorDisabled: string; + backgroundColorDisabled: string; + customStyle: WidgetButtonCustomStyles; +} + +export const valueStepperDefaultAppearance: ValueStepperAppearance = { + type: ValueStepperType.simplified, + autoScale: true, + minValueRange: -100, + maxValueRange: 100, + valueStep: 0.5, + showValueBox: true, + valueUnits: '', + valueDecimals: 1, + valueFont: { + family: 'Roboto', + weight: '500', + style: 'normal', + size: 16, + sizeUnit: 'px', + lineHeight: '24px' + }, + valueColor: '#000', + valueBoxBackground: 'rgba(0, 0, 0, 0.12)', + showBorder: true, + borderWidth: 1, + borderColor: defaultMainColor +} + +export const valueStepperButtonDefaultAppearance: ValueStepperButtonAppearance = { + showButton: true, + icon: '', + iconSize: 24, + iconSizeUnit: 'px', + + mainColorOn: '#3F52DD', + backgroundColorOn: '#FFFFFF', + mainColorOff: '#A2A2A2', + backgroundColorOff: '#FFFFFF', + mainColorDisabled: 'rgba(0,0,0,0.12)', + backgroundColorDisabled: '#FFFFFF', + customStyle: { + enabled: null, + hovered: null, + pressed: null, + activated: null, + disabled: null + } +} + +export const valueStepperDefaultSettings: ValueStepperWidgetSettings = { + initialState: { + action: GetValueAction.EXECUTE_RPC, + defaultValue: 0, + executeRpc: { + method: 'getState', + requestTimeout: 5000, + requestPersistent: false, + persistentPollingInterval: 1000 + }, + getAttribute: { + key: 'state', + scope: null + }, + getTimeSeries: { + key: 'state' + }, + getAlarmStatus: { + severityList: null, + typeList: null + }, + dataToValue: { + type: DataToValueType.NONE, + compareToValue: true, + dataToValueFunction: '/* Should return integer value */\nreturn data;' + } + }, + disabledState: { + action: GetValueAction.DO_NOTHING, + defaultValue: false, + getAttribute: { + key: 'state', + scope: null + }, + getTimeSeries: { + key: 'state' + }, + getAlarmStatus: { + severityList: null, + typeList: null + }, + dataToValue: { + type: DataToValueType.NONE, + compareToValue: true, + dataToValueFunction: '/* Should return boolean value */\nreturn data;' + } + }, + leftButtonClick: { + action: SetValueAction.EXECUTE_RPC, + executeRpc: { + method: 'setState', + requestTimeout: 5000, + requestPersistent: false, + persistentPollingInterval: 1000 + }, + setAttribute: { + key: 'state', + scope: AttributeScope.SERVER_SCOPE + }, + putTimeSeries: { + key: 'state' + }, + valueToData: { + type: ValueToDataType.VALUE, + constantValue: 0, + valueToDataFunction: '/* Convert input integer value to RPC parameters or attribute/time-series value */\nreturn value;' + } + }, + rightButtonClick: { + action: SetValueAction.EXECUTE_RPC, + executeRpc: { + method: 'setState', + requestTimeout: 5000, + requestPersistent: false, + persistentPollingInterval: 1000 + }, + setAttribute: { + key: 'state', + scope: AttributeScope.SERVER_SCOPE + }, + putTimeSeries: { + key: 'state' + }, + valueToData: { + type: ValueToDataType.VALUE, + constantValue: 0, + valueToDataFunction: '/* Convert input integer value to RPC parameters or attribute/time-series value */\nreturn value;' + } + }, + appearance: valueStepperDefaultAppearance, + buttonAppearance: { + leftButton: {...valueStepperButtonDefaultAppearance, icon: 'arrow_back_ios_new'}, + rightButton: {...valueStepperButtonDefaultAppearance, icon: 'arrow_forward_ios'} + }, + background: { + type: BackgroundType.color, + color: '#fff', + overlay: { + enabled: false, + color: 'rgba(255,255,255,0.72)', + blur: 3 + } + }, + padding: '12px' +}; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.html index da2b80c7ce..efea328e04 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.html @@ -1,6 +1,6 @@ -
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source.component.ts index 28bcb1dd4b..9b688ac455 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2024 The Thingsboard Authors +/// Copyright © 2016-2025 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. diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-font.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-font.component.html index f192352d83..fa38ac6d4d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-font.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-font.component.html @@ -1,6 +1,6 @@ + +
+
widgets.value-stepper.behavior
+
+
widgets.value-stepper.initial-state
+ +
+
+
widgets.value-stepper.left-button-click
+ +
+
+
widgets.value-stepper.right-button-click
+ +
+
+
widgets.button-state.disabled-state
+ +
+
+
+
widget-config.appearance
+ + + {{ valueStepperTypeTranslationMap.get(type) | translate }} + + +
+ + {{ 'widgets.value-stepper.auto-scale' | translate }} + +
+ +
+
{{ 'widgets.value-stepper.value-range' | translate }}
+
+
widgets.value-stepper.min-range
+ + + +
widgets.value-stepper.max-range
+ + + +
+
+
+
{{ 'widgets.value-stepper.value-increment-decrement-step' | translate }}
+ + + +
+
+ + {{ 'widgets.value-stepper.value' | translate }} + +
+ + + +
widget-config.decimals-suffix
+
+ + + + +
+
+ +
+
{{ 'widgets.value-stepper.value-box-background' | translate }}
+ + +
+
+ + {{ 'widgets.value-stepper.border' | translate }} + +
+ + +
px
+
+ + +
+
+
+
+
+
widgets.value-stepper.button-appearance
+ + {{ 'widgets.value-stepper.left' | translate }} + {{ 'widgets.value-stepper.right' | translate }} + +
+
+
+ + {{ 'widgets.value-stepper.left-button' | translate }} + +
+
+
{{ 'widgets.value-stepper.icon' | translate }}
+
+ + + + + + +
+
+
+
{{ 'widgets.power-button.power-on-colors' | translate }}
+
+
+
widgets.power-button.main
+ + +
+ +
+
widgets.power-button.background
+ + +
+
+
+
+
{{ 'widgets.power-button.disabled-colors' | translate }}
+
+
+
widgets.power-button.main
+ + +
+ +
+
widgets.power-button.background
+ + +
+
+
+
+
+
+ + {{ 'widgets.value-stepper.right-button' | translate }} + +
+
+
{{ 'widgets.value-stepper.icon' | translate }}
+
+ + + + + + +
+
+
+
{{ 'widgets.power-button.power-on-colors' | translate }}
+
+
+
widgets.power-button.main
+ + +
+ +
+
widgets.power-button.background
+ + +
+
+
+
+
{{ 'widgets.power-button.disabled-colors' | translate }}
+
+
+
widgets.power-button.main
+ + +
+ +
+
widgets.power-button.background
+ + +
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/value-stepper-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/value-stepper-widget-settings.component.ts new file mode 100644 index 0000000000..b0e3dc4bf6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/value-stepper-widget-settings.component.ts @@ -0,0 +1,191 @@ +/// +/// Copyright © 2016-2025 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 } from '@angular/core'; +import { TargetDevice, WidgetSettings, WidgetSettingsComponent, widgetType } from '@shared/models/widget.models'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ValueType } from '@shared/models/constants'; +import { getTargetDeviceFromDatasources } from '@shared/models/widget-settings.models'; +import { + valueStepperDefaultSettings, + valueStepperTypeImages, + valueStepperTypes, + valueStepperTypeTranslations +} from '@home/components/widget/lib/rpc/value-stepper-widget.models'; +import { formatValue } from '@core/utils'; + +type ButtonAppearanceType = 'left' | 'right'; + +@Component({ + selector: 'tb-value-stepper-widget-settings', + templateUrl: './value-stepper-widget-settings.component.html', + styleUrls: ['../widget-settings.scss'] +}) +export class ValueStepperWidgetSettingsComponent extends WidgetSettingsComponent { + + get targetDevice(): TargetDevice { + const datasources = this.widgetConfig?.config?.datasources; + return getTargetDeviceFromDatasources(datasources); + } + + get widgetType(): widgetType { + return this.widgetConfig?.widgetType; + } + get borderRadius(): string { + return this.widgetConfig?.config?.borderRadius; + } + + valueType = ValueType; + + valueStepperWidgetSettingsForm: UntypedFormGroup; + + valueStepperTypeTranslationMap = valueStepperTypeTranslations; + valueStepperTypes = valueStepperTypes; + valueStepperTypeImageMap = valueStepperTypeImages; + + buttonAppearanceType: ButtonAppearanceType = 'left'; + + valuePreviewFn = this._valuePreviewFn.bind(this); + + constructor(protected store: Store, + private fb: UntypedFormBuilder) { + super(store); + } + + protected settingsForm(): UntypedFormGroup { + return this.valueStepperWidgetSettingsForm; + } + + protected defaultSettings(): WidgetSettings { + return {...valueStepperDefaultSettings}; + } + + protected onSettingsSet(settings: WidgetSettings) { + this.valueStepperWidgetSettingsForm = this.fb.group({ + initialState: [settings.initialState, []], + leftButtonClick: [settings.leftButtonClick, []], + rightButtonClick: [settings.rightButtonClick, []], + disabledState: [settings.disabledState, []], + + appearance: this.fb.group({ + type: [settings.appearance.type, []], + autoScale: [settings.appearance.autoScale, []], + minValueRange: [settings.appearance.minValueRange, []], + maxValueRange: [settings.appearance.maxValueRange, []], + valueStep: [settings.appearance.valueStep, [Validators.min(0)]], + showValueBox: [settings.appearance.showValueBox, []], + valueUnits: [settings.appearance.valueUnits, []], + valueDecimals: [settings.appearance.valueDecimals, []], + valueFont: [settings.appearance.valueFont, []], + valueColor: [settings.appearance.valueColor], + valueBoxBackground: [settings.appearance.valueBoxBackground, []], + showBorder: [settings.appearance.showBorder, []], + borderWidth: [settings.appearance.borderWidth, []], + borderColor: [settings.appearance.borderColor, []] + }), + + buttonAppearance: this.fb.group({ + leftButton: this.fb.group({ + showButton: [settings.buttonAppearance.leftButton.showButton], + icon: [settings.buttonAppearance.leftButton.icon], + iconSize: [settings.buttonAppearance.leftButton.iconSize], + iconSizeUnit: [settings.buttonAppearance.leftButton.iconSizeUnit], + mainColorOn: [settings.buttonAppearance.leftButton.mainColorOn, []], + backgroundColorOn: [settings.buttonAppearance.leftButton.backgroundColorOn, []], + mainColorDisabled: [settings.buttonAppearance.leftButton.mainColorDisabled, []], + backgroundColorDisabled: [settings.buttonAppearance.leftButton.backgroundColorDisabled, []] + }), + rightButton: this.fb.group({ + showButton: [settings.buttonAppearance.rightButton.showButton], + icon: [settings.buttonAppearance.rightButton.icon], + iconSize: [settings.buttonAppearance.rightButton.iconSize], + iconSizeUnit: [settings.buttonAppearance.rightButton.iconSizeUnit], + mainColorOn: [settings.buttonAppearance.rightButton.mainColorOn, []], + backgroundColorOn: [settings.buttonAppearance.rightButton.backgroundColorOn, []], + mainColorDisabled: [settings.buttonAppearance.rightButton.mainColorDisabled, []], + backgroundColorDisabled: [settings.buttonAppearance.rightButton.backgroundColorDisabled, []] + }) + }) + }); + } + + + protected validatorTriggers(): string[] { + return ['appearance.showValueBox', 'appearance.showBorder', + 'buttonAppearance.leftButton.showButton', 'buttonAppearance.rightButton.showButton']; + } + + protected updateValidators(_emitEvent: boolean): void { + const showValueBox: boolean = this.valueStepperWidgetSettingsForm.get('appearance').get('showValueBox').value; + const showBorder: boolean = this.valueStepperWidgetSettingsForm.get('appearance').get('showBorder').value; + const showLeftButton: boolean = this.valueStepperWidgetSettingsForm.get('buttonAppearance').get('leftButton').get('showButton').value; + const showRightButton: boolean = this.valueStepperWidgetSettingsForm.get('buttonAppearance').get('rightButton').get('showButton').value; + if (showValueBox) { + this.valueStepperWidgetSettingsForm.get('appearance').get('valueUnits').enable(); + this.valueStepperWidgetSettingsForm.get('appearance').get('valueDecimals').enable(); + this.valueStepperWidgetSettingsForm.get('appearance').get('valueFont').enable(); + this.valueStepperWidgetSettingsForm.get('appearance').get('valueColor').enable(); + this.valueStepperWidgetSettingsForm.get('appearance').get('valueBoxBackground').enable(); + this.valueStepperWidgetSettingsForm.get('appearance').get('showBorder').enable({emitEvent: false}); + if (showBorder) { + this.valueStepperWidgetSettingsForm.get('appearance').get('borderWidth').enable(); + this.valueStepperWidgetSettingsForm.get('appearance').get('borderColor').enable(); + } else { + this.valueStepperWidgetSettingsForm.get('appearance').get('borderWidth').disable(); + this.valueStepperWidgetSettingsForm.get('appearance').get('borderColor').disable(); + } + } else { + this.valueStepperWidgetSettingsForm.get('appearance').get('valueUnits').disable(); + this.valueStepperWidgetSettingsForm.get('appearance').get('valueDecimals').disable(); + this.valueStepperWidgetSettingsForm.get('appearance').get('valueFont').disable(); + this.valueStepperWidgetSettingsForm.get('appearance').get('valueColor').disable(); + this.valueStepperWidgetSettingsForm.get('appearance').get('valueBoxBackground').disable(); + this.valueStepperWidgetSettingsForm.get('appearance').get('showBorder').disable({emitEvent: false}); + this.valueStepperWidgetSettingsForm.get('appearance').get('borderWidth').disable(); + this.valueStepperWidgetSettingsForm.get('appearance').get('borderColor').disable(); + } + this.buttonValidators(showLeftButton, 'leftButton'); + this.buttonValidators(showRightButton, 'rightButton'); + } + + private buttonValidators(showButtonValue: boolean, button: string) { + if (showButtonValue) { + this.valueStepperWidgetSettingsForm.get('buttonAppearance').get(button).get('icon').enable() + this.valueStepperWidgetSettingsForm.get('buttonAppearance').get(button).get('iconSize').enable() + this.valueStepperWidgetSettingsForm.get('buttonAppearance').get(button).get('iconSizeUnit').enable() + this.valueStepperWidgetSettingsForm.get('buttonAppearance').get(button).get('mainColorOn').enable() + this.valueStepperWidgetSettingsForm.get('buttonAppearance').get(button).get('backgroundColorOn').enable() + this.valueStepperWidgetSettingsForm.get('buttonAppearance').get(button).get('mainColorDisabled').enable() + this.valueStepperWidgetSettingsForm.get('buttonAppearance').get(button).get('backgroundColorDisabled').enable() + } else { + this.valueStepperWidgetSettingsForm.get('buttonAppearance').get(button).get('icon').disable() + this.valueStepperWidgetSettingsForm.get('buttonAppearance').get(button).get('iconSize').disable() + this.valueStepperWidgetSettingsForm.get('buttonAppearance').get(button).get('iconSizeUnit').disable() + this.valueStepperWidgetSettingsForm.get('buttonAppearance').get(button).get('mainColorOn').disable() + this.valueStepperWidgetSettingsForm.get('buttonAppearance').get(button).get('backgroundColorOn').disable() + this.valueStepperWidgetSettingsForm.get('buttonAppearance').get(button).get('mainColorDisabled').disable() + this.valueStepperWidgetSettingsForm.get('buttonAppearance').get(button).get('backgroundColorDisabled').disable() + } + } + + private _valuePreviewFn(): string { + const units: string = this.valueStepperWidgetSettingsForm.get('appearance').get('valueUnits').value; + const decimals: number = this.valueStepperWidgetSettingsForm.get('appearance').get('valueDecimals').value; + return formatValue(48, decimals, units, false); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/date/date-range-navigator-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/date/date-range-navigator-widget-settings.component.html index f373fffba0..7a5cd5330a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/date/date-range-navigator-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/date/date-range-navigator-widget-settings.component.html @@ -1,6 +1,6 @@ -
-
+
widgets.liquid-level-card.shape
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/liquid-level-card-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/liquid-level-card-widget-settings.component.ts index 70a163ac1a..59f8c177c6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/liquid-level-card-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/liquid-level-card-widget-settings.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2024 The Thingsboard Authors +/// Copyright © 2016-2025 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. @@ -56,7 +56,7 @@ import { EntityService } from '@core/http/entity.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'liquid-level-card-widget-settings', + selector: 'tb-liquid-level-card-widget-settings', templateUrl: './liquid-level-card-widget-settings.component.html', styleUrls: [] }) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/signal-strength-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/signal-strength-widget-settings.component.html index 2cc8624bea..2dd9428dd3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/signal-strength-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/indicator/signal-strength-widget-settings.component.html @@ -1,6 +1,6 @@ + + + diff --git a/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.ts index 55c72a6efa..07aee49ab3 100644 --- a/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2024 The Thingsboard Authors +/// Copyright © 2016-2025 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. diff --git a/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile.module.ts b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile.module.ts index 29c2e0c6a7..b85c83f281 100644 --- a/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile.module.ts +++ b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile.module.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2024 The Thingsboard Authors +/// Copyright © 2016-2025 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. diff --git a/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profiles-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profiles-table-config.resolver.ts index f6fa52567b..64a6241353 100644 --- a/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profiles-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profiles-table-config.resolver.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2024 The Thingsboard Authors +/// Copyright © 2016-2025 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. diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset-routing.module.ts b/ui-ngx/src/app/modules/home/pages/asset/asset-routing.module.ts index 05dec3b260..51e6ceb971 100644 --- a/ui-ngx/src/app/modules/home/pages/asset/asset-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/asset/asset-routing.module.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2024 The Thingsboard Authors +/// Copyright © 2016-2025 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. diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset-table-header.component.html b/ui-ngx/src/app/modules/home/pages/asset/asset-table-header.component.html index 686baaf21c..69d54143f7 100644 --- a/ui-ngx/src/app/modules/home/pages/asset/asset-table-header.component.html +++ b/ui-ngx/src/app/modules/home/pages/asset/asset-table-header.component.html @@ -1,6 +1,6 @@
- - - - - -
-
-
- - -
+
+
+ +
diff --git a/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.ts index a0ffb6b373..d6f733ade7 100644 --- a/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2024 The Thingsboard Authors +/// Copyright © 2016-2025 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. @@ -30,6 +30,7 @@ export interface JsonObjectEditDialogData { title?: string; saveLabel?: string; cancelLabel?: string; + fillHeight?: boolean; } @Component({ diff --git a/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.html b/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.html index ee416c1133..6b72f80452 100644 --- a/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.html +++ b/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.html @@ -1,6 +1,6 @@ - - {{ label | translate }} + {{ label | translate }} - + [class.!hidden]="showEntityLink"> + {{ displayEntityFn(selectEntityFormGroup.get('entity').value) }} + + warning +
- + {{ requiredErrorText | translate }} diff --git a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts index 417fc35bf4..5e4ddae6a0 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2024 The Thingsboard Authors +/// Copyright © 2016-2025 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. @@ -16,6 +16,7 @@ import { AfterViewInit, + booleanAttribute, Component, ElementRef, EventEmitter, @@ -27,11 +28,10 @@ import { } from '@angular/core'; import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; -import { merge, Observable, of, Subject } from 'rxjs'; +import { firstValueFrom, merge, Observable, of, Subject } from 'rxjs'; import { catchError, debounceTime, map, share, switchMap, tap } from 'rxjs/operators'; import { Store } from '@ngrx/store'; import { AppState } from '@app/core/core.state'; -import { TranslateService } from '@ngx-translate/core'; import { AliasEntityType, EntityType } from '@shared/models/entity-type.models'; import { BaseData } from '@shared/models/base-data'; import { EntityId } from '@shared/models/id/entity-id'; @@ -51,22 +51,22 @@ import { coerceArray, coerceBoolean } from '@shared/decorators/coercion'; multi: true }] }) -export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit, AfterViewInit { +export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit { selectEntityFormGroup: UntypedFormGroup; - modelValue: string | EntityId | null; + private modelValue: string | EntityId | null; - entityTypeValue: EntityType | AliasEntityType; + private entityTypeValue: EntityType | AliasEntityType; - entitySubtypeValue: string; + private entitySubtypeValue: string; - entityText: string; + private entityText: string; noEntitiesMatchingText: string; notFoundEntities = 'entity.no-entities-text'; - entityRequiredText: string; + private entityRequiredText: string; filteredEntities: Observable>>; @@ -78,7 +78,7 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit private refresh$ = new Subject>>(); - private propagateChange = (v: any) => { }; + private propagateChange: (value: any) => void = () => { }; @Input() set entityType(entityType: EntityType) { @@ -135,6 +135,12 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit @coerceBoolean() allowCreateNew: boolean; + @Input({ transform: booleanAttribute }) hideLabel = false; + + @Input({ transform: booleanAttribute }) inlineField = false; + + @Input() placeholder: string; + @Input() subscriptSizing: SubscriptSizing = 'fixed'; @@ -166,7 +172,6 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit constructor(private store: Store, - public translate: TranslateService, private entityService: EntityService, private fb: UntypedFormBuilder) { this.selectEntityFormGroup = this.fb.group({ @@ -178,7 +183,7 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit this.propagateChange = fn; } - registerOnTouched(fn: any): void { + registerOnTouched(_fn: any): void { } ngOnInit() { @@ -188,7 +193,7 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit .pipe( debounceTime(150), tap(value => { - let modelValue; + let modelValue: string | EntityId; if (typeof value === 'string' || !value) { modelValue = null; } else { @@ -207,9 +212,7 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit ); } - ngAfterViewInit(): void {} - - load(): void { + private load(): void { if (this.entityTypeValue) { switch (this.entityTypeValue) { case EntityType.ASSET: @@ -327,7 +330,7 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit } } - getCurrentEntity(): BaseData | null { + private getCurrentEntity(): BaseData | null { const currentEntity = this.selectEntityFormGroup.get('entity').value; if (currentEntity && typeof currentEntity !== 'string') { return currentEntity as BaseData; @@ -359,16 +362,17 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit } let entity: BaseData = null; try { - entity = await this.entityService.getEntity(targetEntityType, id, {ignoreLoading: true, ignoreErrors: true}).toPromise(); + entity = await firstValueFrom(this.entityService.getEntity(targetEntityType, id, {ignoreLoading: true, ignoreErrors: true})); } catch (e) { this.propagateChange(null); } this.modelValue = entity !== null ? (this.useFullEntityId ? entity.id : entity.id.id) : null; - this.entityURL = getEntityDetailsPageURL(this.modelValue as string, targetEntityType); + this.entityURL = !entity ? '' : getEntityDetailsPageURL(entity.id.id, targetEntityType); this.selectEntityFormGroup.get('entity').patchValue(entity !== null ? entity : '', {emitEvent: false}); this.entityChanged.emit(entity); } else { this.modelValue = null; + this.entityURL = ''; this.selectEntityFormGroup.get('entity').patchValue('', {emitEvent: false}); } this.dirty = true; @@ -381,13 +385,14 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit } } - reset() { + private reset() { this.selectEntityFormGroup.get('entity').patchValue('', {emitEvent: false}); } - updateView(value: string | null, entity: BaseData | null) { + private updateView(value: string | EntityId | null, entity: BaseData | null) { if (!isEqual(this.modelValue, value)) { this.modelValue = value; + this.entityURL = !entity ? '' : getEntityDetailsPageURL(entity.id.id, entity.id.entityType as EntityType); this.propagateChange(this.modelValue); this.entityChanged.emit(entity); } @@ -397,7 +402,7 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit return entity ? entity.name : undefined; } - fetchEntities(searchText?: string): Observable>> { + private fetchEntities(searchText?: string): Observable>> { this.searchText = searchText; const targetEntityType = this.checkEntityType(this.entityTypeValue); return this.entityService.getEntitiesByNameFilter(targetEntityType, searchText, @@ -432,7 +437,7 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit }, 0); } - checkEntityType(entityType: EntityType | AliasEntityType): EntityType { + private checkEntityType(entityType: EntityType | AliasEntityType): EntityType { if (entityType === AliasEntityType.CURRENT_CUSTOMER) { return EntityType.CUSTOMER; } else if (entityType === AliasEntityType.CURRENT_TENANT) { @@ -454,4 +459,8 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit $event.stopPropagation(); this.createNew.emit(); } + + get showEntityLink(): boolean { + return this.selectEntityFormGroup.get('entity').value && this.disabled && this.entityURL !== ''; + } } diff --git a/ui-ngx/src/app/shared/components/entity/entity-gateway-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-gateway-select.component.html index 4ba9b83d69..a34f268d6a 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-gateway-select.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-gateway-select.component.html @@ -1,6 +1,6 @@ + + + @if (keyControl.value) { + + } @else if (keyControl.hasError('required') && keyControl.touched) { + + warning + + } + + @for (key of filteredKeys$ | async; track key) { + + } @empty { + @if (!this.keyControl.value) { + {{ 'entity.no-keys-found' | translate }} + } + } + + diff --git a/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.ts b/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.ts new file mode 100644 index 0000000000..84d723d114 --- /dev/null +++ b/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.ts @@ -0,0 +1,145 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, effect, ElementRef, forwardRef, input, OnChanges, SimpleChanges, ViewChild, } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { map, startWith, switchMap } from 'rxjs/operators'; +import { combineLatest, of, Subject } from 'rxjs'; +import { EntityService } from '@core/http/entity.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { EntitiesKeysByQuery } from '@shared/models/entity.models'; +import { EntityFilter } from '@shared/models/query/query.models'; +import { isEqual } from '@core/utils'; + +@Component({ + selector: 'tb-entity-key-autocomplete', + templateUrl: './entity-key-autocomplete.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityKeyAutocompleteComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => EntityKeyAutocompleteComponent), + multi: true + } + ], +}) +export class EntityKeyAutocompleteComponent implements ControlValueAccessor, Validator, OnChanges { + + @ViewChild('keyInput', {static: true}) keyInput: ElementRef; + + entityFilter = input.required(); + dataKeyType = input.required(); + keyScopeType = input(); + + keyControl = this.fb.control('', [Validators.required]); + searchText = ''; + keyInputSubject = new Subject(); + + private propagateChange: (value: string) => void; + private cachedResult: EntitiesKeysByQuery; + + keys$ = this.keyInputSubject.asObservable() + .pipe( + switchMap(() => { + return this.cachedResult ? of(this.cachedResult) : this.entityService.findEntityKeysByQuery({ + pageLink: { page: 0, pageSize: 100 }, + entityFilter: this.entityFilter(), + }, this.dataKeyType() === DataKeyType.attribute, this.dataKeyType() === DataKeyType.timeseries, this.keyScopeType()); + }), + map(result => { + this.cachedResult = result; + switch (this.dataKeyType()) { + case DataKeyType.attribute: + return result.attribute; + case DataKeyType.timeseries: + return result.timeseries; + default: + return []; + } + }), + ); + + filteredKeys$ = combineLatest([this.keys$, this.keyControl.valueChanges.pipe(startWith(''))]) + .pipe( + map(([keys, searchText = '']) => { + this.searchText = searchText; + return searchText ? keys.filter(item => item.toLowerCase().includes(searchText.toLowerCase())) : keys; + }) + ); + + constructor( + private fb: FormBuilder, + private entityService: EntityService, + ) { + this.keyControl.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe(value => this.propagateChange(value)); + effect(() => { + if (this.keyScopeType() || this.entityFilter() && this.dataKeyType()) { + this.cachedResult = null; + this.searchText = ''; + } + }); + } + + ngOnChanges(changes: SimpleChanges): void { + const filterChanged = changes.entityFilter?.previousValue && + !isEqual(changes.entityFilter.currentValue, changes.entityFilter.previousValue); + const keyScopeChanged = changes.keyScopeType?.previousValue && + changes.keyScopeType.currentValue !== changes.keyScopeType.previousValue; + const keyTypeChanged = changes.dataKeyType?.previousValue && + changes.dataKeyType.currentValue !== changes.dataKeyType.previousValue; + + if (filterChanged || keyScopeChanged || keyTypeChanged) { + this.keyControl.setValue('', {emitEvent: false}); + } + } + + clear(): void { + this.keyControl.patchValue('', {emitEvent: true}); + setTimeout(() => { + this.keyInput.nativeElement.blur(); + this.keyInput.nativeElement.focus(); + }, 0); + } + + registerOnChange(onChange: (value: string) => void): void { + this.propagateChange = onChange; + } + + registerOnTouched(_): void {} + + validate(): ValidationErrors | null { + return this.keyControl.valid ? null : { keyControl: false }; + } + + writeValue(value: string): void { + this.keyControl.patchValue(value, {emitEvent: false}); + } +} diff --git a/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.html index d6c08b1979..7d1c2e192c 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.html @@ -1,6 +1,6 @@ -

{{title}}

-
-
- +

{{configuration.title}}

+
+
+
@@ -28,14 +28,14 @@ [indeterminate]="dataSource.selection.hasValue() && (dataSource.isAllSelected() | async) === false"> - + + (change)="$event ? dataSource.selection.toggle(resource) : null" + [checked]="dataSource.selection.isSelected(resource)"> - + {{ image.title }} @@ -43,30 +43,30 @@ - - {{ 'image.name' | translate }} + + {{ 'resource.title' | translate }} - - {{ translate.get('image.selected-images', {count: dataSource.selection.selected.length}) | async }} + + {{ translate.get(configuration.selectedText, {count: dataSource.selection.selected.length}) | async }} - - {{ image.title }} + + {{ resource.title }} - + + (click)="toggleShowReferences($event, resource, showReferencesButton)">{{ 'image.references' | translate }} - + + [class.mat-selected]="dataSource.selection.isSelected(resource)" + *matRowDef="let resource; columns: configuration.columns;">
@@ -76,7 +76,7 @@
- + -
+
diff --git a/ui-ngx/src/app/shared/components/image/images-in-use-dialog.component.scss b/ui-ngx/src/app/shared/components/resource/resources-in-use-dialog.component.scss similarity index 92% rename from ui-ngx/src/app/shared/components/image/images-in-use-dialog.component.scss rename to ui-ngx/src/app/shared/components/resource/resources-in-use-dialog.component.scss index 682f3a1d0c..380a6070eb 100644 --- a/ui-ngx/src/app/shared/components/image/images-in-use-dialog.component.scss +++ b/ui-ngx/src/app/shared/components/resource/resources-in-use-dialog.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2024 The Thingsboard Authors + * Copyright © 2016-2025 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. @@ -14,7 +14,7 @@ * limitations under the License. */ :host { - .tb-images-in-use-content { + .tb-resources-in-use-content { display: flex; flex-direction: column; gap: 24px; diff --git a/ui-ngx/src/app/shared/components/image/images-in-use-dialog.component.ts b/ui-ngx/src/app/shared/components/resource/resources-in-use-dialog.component.ts similarity index 65% rename from ui-ngx/src/app/shared/components/image/images-in-use-dialog.component.ts rename to ui-ngx/src/app/shared/components/resource/resources-in-use-dialog.component.ts index 20b551c872..6f83fded7d 100644 --- a/ui-ngx/src/app/shared/components/image/images-in-use-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/resource/resources-in-use-dialog.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2024 The Thingsboard Authors +/// Copyright © 2016-2025 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. @@ -20,37 +20,50 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { DialogComponent } from '@shared/components/dialog.component'; import { Router } from '@angular/router'; -import { ImageReferences, ImageResourceInfo, ImageResourceInfoWithReferences } from '@shared/models/resource.models'; -import { ImagesDatasource } from '@shared/components/image/images-datasource'; +import { + ResourceReferences, + ResourceInfoWithReferences, + ResourceInfo +} from '@shared/models/resource.models'; import { MatButton } from '@angular/material/button'; import { TbPopoverService } from '@shared/components/popover.service'; import { ImageReferencesComponent } from '@shared/components/image/image-references.component'; import { TranslateService } from '@ngx-translate/core'; +import { Datasource } from "@shared/models/widget.models"; -export interface ImagesInUseDialogData { +interface ResourcesInUseDialogDataConfiguration { + title: string; + message: string; + columns: string[]; + deleteText: string; + selectedText: string; + datasource?: Datasource; +} + +export interface ResourcesInUseDialogData { multiple: boolean; - images: ImageResourceInfoWithReferences[]; + resources: ResourceInfoWithReferences[]; + configuration: ResourcesInUseDialogDataConfiguration; } @Component({ - selector: 'tb-images-in-use-dialog', - templateUrl: './images-in-use-dialog.component.html', - styleUrls: ['./images-in-use-dialog.component.scss'] + selector: 'tb-resources-in-use-dialog', + templateUrl: './resources-in-use-dialog.component.html', + styleUrls: ['./resources-in-use-dialog.component.scss'] }) -export class ImagesInUseDialogComponent extends - DialogComponent implements OnInit { - - title: string; - message: string; +export class ResourcesInUseDialogComponent extends + DialogComponent implements OnInit { - references: ImageReferences; + displayPreview: boolean; + configuration: ResourcesInUseDialogDataConfiguration; + references: ResourceReferences; - dataSource: ImagesDatasource; + dataSource: Datasource; constructor(protected store: Store, protected router: Router, - @Inject(MAT_DIALOG_DATA) public data: ImagesInUseDialogData, - public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: ResourcesInUseDialogData, + public dialogRef: MatDialogRef, public translate: TranslateService, private renderer: Renderer2, private viewContainerRef: ViewContainerRef, @@ -59,14 +72,12 @@ export class ImagesInUseDialogComponent extends } ngOnInit(): void { + this.configuration = this.data.configuration; + this.displayPreview = this.data.configuration.columns.includes('preview'); if (this.data.multiple) { - this.title = this.translate.instant('image.images-are-in-use'); - this.message = this.translate.instant('image.images-are-in-use-text'); - this.dataSource = new ImagesDatasource(null, this.data.images, entity => true); + this.dataSource = this.data.configuration.datasource; } else { - this.title = this.translate.instant('image.image-is-in-use'); - this.message = this.translate.instant('image.image-is-in-use-text', {title: this.data.images[0].title}); - this.references = this.data.images[0].references; + this.references = this.data.resources[0].references; } } @@ -78,11 +89,11 @@ export class ImagesInUseDialogComponent extends if (this.data.multiple) { this.dialogRef.close(this.dataSource.selection.selected); } else { - this.dialogRef.close(this.data.images); + this.dialogRef.close(this.data.resources); } } - toggleShowReferences($event: Event, image: ImageResourceInfoWithReferences, referencesButton: MatButton) { + toggleShowReferences($event: Event, resource: ResourceInfoWithReferences, referencesButton: MatButton) { if ($event) { $event.stopPropagation(); } @@ -93,7 +104,7 @@ export class ImagesInUseDialogComponent extends const referencesPopover = this.popoverService.displayPopover(trigger, this.renderer, this.viewContainerRef, ImageReferencesComponent, 'top', true, null, { - references: image.references + references: resource.references }, {}, {}, {}, false, visible => { diff --git a/ui-ngx/src/app/shared/components/rule-chain/rule-chain-select-panel.component.html b/ui-ngx/src/app/shared/components/rule-chain/rule-chain-select-panel.component.html index 799abc1ee3..d1b7f58de2 100644 --- a/ui-ngx/src/app/shared/components/rule-chain/rule-chain-select-panel.component.html +++ b/ui-ngx/src/app/shared/components/rule-chain/rule-chain-select-panel.component.html @@ -1,6 +1,6 @@ -
+
datetime.from - + datetime.to - +
diff --git a/ui-ngx/src/app/shared/components/time/datetime-period.component.scss b/ui-ngx/src/app/shared/components/time/datetime-period.component.scss index 6fa2806335..9c4a2d556f 100644 --- a/ui-ngx/src/app/shared/components/time/datetime-period.component.scss +++ b/ui-ngx/src/app/shared/components/time/datetime-period.component.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2024 The Thingsboard Authors + * Copyright © 2016-2025 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. @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - :host { .tb-form-row { gap: 8px; diff --git a/ui-ngx/src/app/shared/components/time/datetime-period.component.ts b/ui-ngx/src/app/shared/components/time/datetime-period.component.ts index c0fbaea21e..251f9e03b9 100644 --- a/ui-ngx/src/app/shared/components/time/datetime-period.component.ts +++ b/ui-ngx/src/app/shared/components/time/datetime-period.component.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2024 The Thingsboard Authors +/// Copyright © 2016-2025 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. @@ -14,10 +14,17 @@ /// limitations under the License. /// -import { Component, forwardRef, Input, OnInit } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { FixedWindow } from '@shared/models/time/time.models'; +import { Component, forwardRef, Input } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { DAY, FixedWindow, MINUTE } from '@shared/models/time/time.models'; import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { distinctUntilChanged } from 'rxjs/operators'; + +interface DateTimePeriod { + startDate: Date; + endDate: Date; +} @Component({ selector: 'tb-datetime-period', @@ -31,7 +38,7 @@ import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form- } ] }) -export class DatetimePeriodComponent implements OnInit, ControlValueAccessor { +export class DatetimePeriodComponent implements ControlValueAccessor { @Input() disabled: boolean; @@ -41,25 +48,45 @@ export class DatetimePeriodComponent implements OnInit, ControlValueAccessor { @Input() appearance: MatFormFieldAppearance = 'fill'; - modelValue: FixedWindow; - - startDate: Date; - endDate: Date; - - endTime: any; + private modelValue: FixedWindow; maxStartDate: Date; - minEndDate: Date; maxEndDate: Date; - changePending = false; + private maxStartDateTs: number; + private minEndDateTs: number; + private maxStartTs: number; + private maxEndTs: number; - private propagateChange = null; + private timeShiftMs = MINUTE; - constructor() { - } + dateTimePeriodFormGroup = this.fb.group({ + startDate: this.fb.control(null), + endDate: this.fb.control(null) + }); + + private changePending = false; - ngOnInit(): void { + private propagateChange = null; + + constructor(private fb: FormBuilder) { + this.dateTimePeriodFormGroup.valueChanges.pipe( + distinctUntilChanged((prevDateTimePeriod, dateTimePeriod) => + prevDateTimePeriod.startDate === dateTimePeriod.startDate && prevDateTimePeriod.endDate === dateTimePeriod.endDate), + takeUntilDestroyed() + ).subscribe((dateTimePeriod: DateTimePeriod) => { + this.updateMinMaxDates(dateTimePeriod); + this.updateView(); + }); + + this.dateTimePeriodFormGroup.get('startDate').valueChanges.pipe( + distinctUntilChanged(), + takeUntilDestroyed() + ).subscribe(startDate => this.onStartDateChange(startDate)); + this.dateTimePeriodFormGroup.get('endDate').valueChanges.pipe( + distinctUntilChanged(), + takeUntilDestroyed() + ).subscribe(endDate => this.onEndDateChange(endDate)); } registerOnChange(fn: any): void { @@ -75,35 +102,38 @@ export class DatetimePeriodComponent implements OnInit, ControlValueAccessor { setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; + if (this.disabled) { + this.dateTimePeriodFormGroup.disable({emitEvent: false}); + } else { + this.dateTimePeriodFormGroup.enable({emitEvent: false}); + } } writeValue(datePeriod: FixedWindow): void { this.modelValue = datePeriod; if (this.modelValue) { - this.startDate = new Date(this.modelValue.startTimeMs); - this.endDate = new Date(this.modelValue.endTimeMs); + this.dateTimePeriodFormGroup.patchValue({ + startDate: new Date(this.modelValue.startTimeMs), + endDate: new Date(this.modelValue.endTimeMs) + }, {emitEvent: false}); } else { const date = new Date(); - this.startDate = new Date( - date.getFullYear(), - date.getMonth(), - date.getDate() - 1, - date.getHours(), - date.getMinutes(), - date.getSeconds(), - date.getMilliseconds()); - this.endDate = date; + this.dateTimePeriodFormGroup.patchValue({ + startDate: new Date(date.getTime() - DAY), + endDate: date + }, {emitEvent: false}); this.updateView(); } - this.updateMinMaxDates(); + this.updateMinMaxDates(this.dateTimePeriodFormGroup.value); } - updateView() { + private updateView() { let value: FixedWindow = null; - if (this.startDate && this.endDate) { + const dateTimePeriod = this.dateTimePeriodFormGroup.value; + if (dateTimePeriod.startDate && dateTimePeriod.endDate) { value = { - startTimeMs: this.startDate.getTime(), - endTimeMs: this.endDate.getTime() + startTimeMs: dateTimePeriod.startDate.getTime(), + endTimeMs: dateTimePeriod.endDate.getTime() }; } this.modelValue = value; @@ -114,32 +144,40 @@ export class DatetimePeriodComponent implements OnInit, ControlValueAccessor { } } - updateMinMaxDates() { - this.maxStartDate = new Date(this.endDate.getTime() - 1000); - this.minEndDate = new Date(this.startDate.getTime() + 1000); + private updateMinMaxDates(dateTimePeriod: Partial) { this.maxEndDate = new Date(); + this.maxEndTs = this.maxEndDate.getTime(); + this.maxStartTs = this.maxEndTs - this.timeShiftMs; + this.maxStartDate = new Date(this.maxStartTs); + + if (dateTimePeriod.endDate) { + this.maxStartDateTs = dateTimePeriod.endDate.getTime() - this.timeShiftMs; + } + if (dateTimePeriod.startDate) { + this.minEndDateTs = dateTimePeriod.startDate.getTime() + this.timeShiftMs; + } } - onStartDateChange() { - if (this.startDate) { - if (this.startDate.getTime() > this.maxStartDate.getTime()) { - this.startDate = new Date(this.maxStartDate.getTime()); + private onStartDateChange(startDate: Date) { + if (startDate) { + if (startDate.getTime() > this.maxStartTs) { + this.dateTimePeriodFormGroup.get('startDate').patchValue(new Date(this.maxStartTs), { emitEvent: false }); + } + if (startDate.getTime() > this.maxStartDateTs) { + this.dateTimePeriodFormGroup.get('endDate').patchValue(new Date(startDate.getTime() + this.timeShiftMs), { emitEvent: false }); } - this.updateMinMaxDates(); } - this.updateView(); } - onEndDateChange() { - if (this.endDate) { - if (this.endDate.getTime() < this.minEndDate.getTime()) { - this.endDate = new Date(this.minEndDate.getTime()); - } else if (this.endDate.getTime() > this.maxEndDate.getTime()) { - this.endDate = new Date(this.maxEndDate.getTime()); + private onEndDateChange(endDate: Date) { + if (endDate) { + if (endDate.getTime() > this.maxEndTs) { + this.dateTimePeriodFormGroup.get('endDate').patchValue(new Date(this.maxEndTs), { emitEvent: false }); + } + if (endDate.getTime() < this.minEndDateTs) { + this.dateTimePeriodFormGroup.get('startDate').patchValue(new Date(endDate.getTime() - this.timeShiftMs), { emitEvent: false }); } - this.updateMinMaxDates(); } - this.updateView(); } } diff --git a/ui-ngx/src/app/shared/components/time/datetime.component.html b/ui-ngx/src/app/shared/components/time/datetime.component.html index f0965deaf8..41598ff329 100644 --- a/ui-ngx/src/app/shared/components/time/datetime.component.html +++ b/ui-ngx/src/app/shared/components/time/datetime.component.html @@ -1,6 +1,6 @@ diff --git a/ui-ngx/src/assets/help/en_US/rulenode/save_timeseries_node_advanced.md b/ui-ngx/src/assets/help/en_US/rulenode/save_timeseries_node_advanced.md new file mode 100644 index 0000000000..18c0f60cdb --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/rulenode/save_timeseries_node_advanced.md @@ -0,0 +1,34 @@ +#### Potential unexpected behavior with mixed processing strategies + +When configuring the processing strategies, certain combinations can lead to unexpected behavior. Consider the following scenarios: + +- **Skipping database storage** + + Choosing to disable one or more persistence actions (for instance, skipping database storage for Time series or Latest values while keeping WS updates enabled) introduces the risk of having only partial data available: + - If a message is processed only for real-time notifications (WebSockets) and not stored in the database, historical queries may not match data on the dashboard. + - When processing strategies for Time series and Latest values are out-of-sync, telemetry data may be stored in one table (e.g., Time series) while the same data is absent in the other (e.g., Latest values). + +- **Disabling WebSocket (WS) updates** + + If WS updates are disabled, any changes to the time series data won’t be pushed to dashboards (or other WS subscriptions). + This means that even if a database is updated, dashboards may not display the updated data until browser page is reloaded. + +- **Skipping calculated field recalculation** + + If telemetry data is saved to the database while bypassing calculated field recalculation, the aggregated value may not update to reflect the latest data. + Conversely, if the calculated field is recalculated with new data but the corresponding telemetry value is not persisted in the database, the calculated field's value might include data that isn’t stored. + +- **Different deduplication intervals across actions** + + When you configure different deduplication intervals for actions, the same incoming message might be processed differently for each action. + For example, a message might be stored immediately in the Time series table (if set to *On every message*) while not being stored in the Latest values table because its deduplication interval hasn’t elapsed. + Also, if the WebSocket updates are configured with a different interval, dashboards might show updates that do not match what is stored in the database. + +- **Deduplication cache clearing** + + The deduplication mechanism uses a cache to track processed messages within each interval. + For performance and system stability reasons, this cache is periodically cleared. + As a result, if a cache entry is removed during the deduplication period, messages from the same originator may be processed more than once within that interval. + This means deduplication should be used as a performance optimization rather than an absolute guarantee of single processing per interval. + +We recommend using deduplication only when the occasional repeated processing is acceptable and won't cause system correctness issue or data inconsistencies. diff --git a/ui-ngx/src/assets/help/en_US/widget/config/parse_value_get_dashboard_state_object_fn.md b/ui-ngx/src/assets/help/en_US/widget/config/parse_value_get_dashboard_state_object_fn.md new file mode 100644 index 0000000000..ddc1ed7bc8 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/config/parse_value_get_dashboard_state_object_fn.md @@ -0,0 +1,40 @@ +#### Parse value function + +
+
+ +*function (data): boolean* + +A JavaScript function that converts the current dashboard state object into a boolean value. + +**Parameters:** + +
+ +**Returns:** + +`true` if the widget should be in an activated state, `false` otherwise. + +
+ +##### Examples + +* Check if the current dashboard state id is "default": + +```javascript +return data.id === 'default' ? true : false; +{:copy-code} +``` + +* Check if the current dashboard state parameters are empty: + +```javascript +return Object.keys(data.params).length ? true : false; +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 1a08cd5ba8..02c604d8ae 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -65,6 +65,7 @@ "next-with-label": "Next: {{label}}", "read-more": "Read more", "hide": "Hide", + "test": "Test", "done": "Done", "print": "Print", "restore": "Restore", @@ -996,6 +997,7 @@ "failures": "Failures", "entity": "entity", "rule-node": "rule node", + "calculated-field": "calculated field", "hint": { "main": "All node debug messages rate limited with:", "main-limited": "All {{entity}} debug messages will be rate-limited, with a maximum of {{msg}} messages allowed per {{time}}.", @@ -1003,6 +1005,74 @@ "all-messages": "Save all debug events during time limit." } }, + "calculated-fields": { + "expression": "Expression", + "no-found": "No calculated fields found", + "list": "{ count, plural, =1 {One calculated field} other {List of # calculated fields} }", + "selected-fields": "{ count, plural, =1 {1 calculated field} other {# calculated fields} } selected", + "type": { + "simple": "Simple", + "script": "Script" + }, + "arguments": "Arguments", + "debugging": "Calculated field debugging", + "argument-name": "Argument name", + "datasource": "Datasource", + "add-argument": "Add argument", + "test-script-function": "Test script function", + "no-arguments": "No arguments configured", + "argument-settings": "Argument settings", + "argument-current": "Current entity", + "argument-current-tenant": "Current tenant", + "argument-device": "Device", + "argument-asset": "Asset", + "argument-customer": "Customer", + "argument-tenant": "Current tenant", + "argument-type": "Argument type", + "see-debug-events": "See debug events", + "attribute": "Attribute", + "timeseries-key": "Time series key", + "device-name": "Device name", + "latest-telemetry": "Latest telemetry", + "rolling": "Time series rolling", + "attribute-scope": "Attribute scope", + "server-attributes": "Server attributes", + "client-attributes": "Client attributes", + "shared-attributes": "Shared attributes", + "attribute-key": "Attribute key", + "default-value": "Default value", + "limit": "Max values", + "time-window": "Time window", + "customer-name": "Customer name", + "asset-name": "Asset name", + "timeseries": "Time series", + "output": "Output", + "create": "Create new calculated field", + "file": "Calculated field file", + "invalid-file-error": "Invalid file format. Please make sure the file is a valid JSON file.", + "import": "Import calculated field", + "export": "Export calculated field", + "export-failed-error": "Unable to export calculated field: {{error}}", + "output-type": "Output type", + "delete-title": "Are you sure you want to delete the calculated field '{{title}}'?", + "delete-text": "Be careful, after the confirmation the calculated field and all related data will become unrecoverable.", + "delete-multiple-title": "Are you sure you want to delete { count, plural, =1 {1 calculated field} other {# calculated fields} }?", + "delete-multiple-text": "Be careful, after the confirmation all selected calculated fields will be removed and all related data will become unrecoverable.", + "hint": { + "arguments-simple-with-rolling": "Simple type calculated field should not contain keys with time series rolling type.", + "arguments-empty": "Arguments should not be empty.", + "expression-required": "Expression is required.", + "expression-invalid": "Expression is invalid", + "expression-max-length": "Expression length should be less than 255 characters.", + "argument-name-required": "Argument name is required.", + "argument-name-pattern": "Argument name is invalid.", + "argument-name-duplicate": "Argument with such name already exists.", + "argument-name-max-length": "Argument name should be less than 256 characters.", + "argument-type-required": "Argument type is required.", + "max-args": "Maximum number of arguments reached.", + "expression": "Default expression demonstrates how to transform a temperature from Fahrenheit to Celsius." + } + }, "confirm-on-exit": { "message": "You have unsaved changes. Are you sure you want to leave this page?", "html-message": "You have unsaved changes.
Are you sure you want to leave this page?", @@ -1027,8 +1097,13 @@ "city-max-length": "Specified city should be less than 256" }, "common": { + "name": "Name", + "type": "Type", + "general": "General", "username": "Username", "password": "Password", + "data": "Data", + "timestamp": "Timestamp", "enter-username": "Enter username", "enter-password": "Enter password", "enter-search": "Enter search", @@ -1038,8 +1113,27 @@ "proceed": "Proceed", "open-details-page": "Open details page", "not-found": "Not found", + "value": "Value", "documentation": "Documentation", - "time-left": "{{time}} left" + "time-left": "{{time}} left", + "output": "Output", + "test-function": "Test function", + "test-with-this-message": "{{test}} with this message", + "suffix": { + "s": "s", + "ms": "ms" + }, + "hint": { + "name-required": "Name is required.", + "name-pattern": "Name is invalid.", + "name-max-length": "Name should be less than 256 characters.", + "title-required": "Title is required.", + "title-pattern": "Title is invalid.", + "title-max-length": "Title should be less than 256 characters.", + "key-required": "Key is required.", + "key-pattern": "Key is invalid.", + "key-max-length": "Key should be less than 256 characters." + } }, "content-type": { "json": "Json", @@ -2427,6 +2521,8 @@ "type-current-tenant": "Current Tenant", "type-current-user": "Current User", "type-current-user-owner": "Current User Owner", + "type-calculated-field": "Calculated Field", + "type-calculated-fields": "Calculated Fields", "type-widgets-bundle": "Widgets bundle", "type-widgets-bundles": "Widgets bundles", "list-of-widgets-bundles": "{ count, plural, =1 {One widgets bundle} other {List of # widget bundles} }", @@ -2627,6 +2723,9 @@ "type-stats": "Statistics", "type-debug-rule-node": "Debug", "type-debug-rule-chain": "Debug", + "type-debug-calculated-field": "Debug", + "arguments": "Arguments", + "result": "Result", "no-events-prompt": "No events found", "error": "Error", "alarm": "Alarm", @@ -3218,6 +3317,7 @@ "bottom-fluid-color": "Bottom fluid color", "display": "Display", "value": "Value", + "decimals": "Decimals", "units": "Units", "flow-meter-value-hint": "Double value showing on flow meter display", "value-hint": "Double value indicating the current value", @@ -3640,6 +3740,8 @@ "add-bundle": "Add bundle", "title": "Title", "title-required": "Title is required", + "title-cannot-contain-only-spaces": "Title cannot contain only spaces", + "title-max-length": "Title should be less than 256", "oauth-clients": "OAuth 2.0 clients", "android-app": "Android App", "android-application": "Android Application", @@ -3671,6 +3773,7 @@ "page-name": "Page name", "page-name-required": "Page name is required.", "page-name-cannot-contain-only-spaces": "Page name cannot contain only spaces.", + "page-name-max-length": "Page name should be less than 256", "page-type": "Page type", "pages-types": { "dashboard": "Dashboard", @@ -4261,6 +4364,7 @@ "delete-javascript-resources-action-title": "Delete JavaScript { count, plural, =1 {1 resource} other {# resources} }", "delete-javascript-resources-text": "Please note that the selected JavaScript resources, even if they are used in JavaScript functions, will be deleted.", "delete-javascript-resources-title": "Are you sure you want to delete JavaScript { count, plural, =1 {1 resource} other {# resources} }?", + "delete-javascript-resource-in-use-text": "If you still want to delete the JavaScript resource, click the Delete anyway button.", "download": "Download JavaScript resource", "upload-from-file": "Upload JavaScript from file", "resource-file": "JavaScript resource file", @@ -4269,6 +4373,10 @@ "javascript-library": "JavaScript library", "javascript-type": "JavaScript type", "javascript-resource-details": "JavaScript resource details", + "javascript-resource-is-in-use": "JavaScript resource is used by other entities", + "javascript-resources-are-in-use": "JavaScript resources are used by other entities", + "javascript-resource-is-in-use-text": "The JavaScript resource '{{title}}' was not deleted because it is used by the following entities:", + "javascript-resources-are-in-use-text": "Not all JavaScript resources have been deleted because they are used by other entities.
You can view referenced entities by clicking the References button in the corresponding resource row.
If you still want to delete these JavaScript resources, select them in the table below and click the Delete selected button.", "search": "Search JavaScript resources", "selected-javascript-resources": "{ count, plural, =1 {1 JavaScript resource} other {# JavaScript resources} } selected", "no-javascript-resource-text": "No JavaScript resources found", @@ -4604,7 +4712,7 @@ "originator-entity": "Entity by name pattern", "clone-message": "Clone message", "transform": "Transform", - "default-ttl": "Default TTL in seconds", + "default-ttl": "Default TTL", "default-ttl-required": "Default TTL is required.", "default-ttl-hint": "Rule node will fetch Time-to-Live (TTL) value from the message metadata. If no value is present, it defaults to the TTL specified in the configuration. If the value is set to 0, the TTL from the tenant profile configuration will be applied.", "default-ttl-zero-hint": "TTL will not be applied if its value is set to 0.", @@ -4969,10 +5077,8 @@ "general-pattern-hint": "Use ${metadataKey} for value from metadata, $[messageKey] for value from message body.", "alarm-severity-pattern-hint": "Use ${metadataKey} for value from metadata, $[messageKey] for value from message body. Alarm severity should be system (CRITICAL, MAJOR etc.)", "output-node-name-hint": "The rule node name corresponds to the relation type of the output message, and it is used to forward messages to other rule nodes in the caller rule chain.", - "skip-latest-persistence": "Skip latest persistence", - "skip-latest-persistence-hint": "Rule node will not update values for incoming keys for the latest time series data. Useful for highly loaded use-cases to decrease the pressure on the DB.", - "use-server-ts": "Use server ts", - "use-server-ts-hint": "Rule node will use the timestamp of message processing instead of the timestamp from the message. Useful for all sorts of sequential processing if you merge messages from multiple sources (devices, assets, etc).", + "use-server-ts": "Use server timestamp", + "use-server-ts-hint": "Use the server’s current timestamp for time series data that lacks an explicit timestamp. This helps maintain proper ordering when processing messages from multiple sources or when messages arrive out of sequence.", "kv-map-pattern-hint": "All input fields support templatization. Use $[messageKey] to extract value from the message and ${metadataKey} to extract value from the metadata.", "kv-map-single-pattern-hint": "Input field support templatization. Use $[messageKey] to extract value from the message and ${metadataKey} to extract value from the metadata.", "shared-scope": "Shared scope", @@ -5130,8 +5236,30 @@ "request-timeout-required": "Request timeout is required", "request-timeout-min": "Min request timeout is 0", "request-timeout-hint": "The amount of time to wait in seconds for the request to complete before giving up and timing out. A value of 0 means infinity, and is not recommended.", + "units": "Units", "tell-failure-aws-lambda": "Tell Failure if AWS Lambda function execution raises exception", "tell-failure-aws-lambda-hint": "Rule node forces failure of message processing if AWS Lambda function execution raises exception.", + "basic-mode": "Basic", + "advanced-mode": "Advanced", + "save-time-series": { + "processing-settings": "Processing settings", + "processing-settings-hint": "Define how incoming messages are processed. In Basic mode, select a preconfigured processing strategy or enable only WebSocket updates. Advanced mode allows you to select individual processing strategies for each action.", + "advanced-settings-hint": "Be cautious when configuring processing strategies. Certain combinations can lead to unexpected behavior.", + "strategy": "Strategy", + "deduplication-interval": "Deduplication interval", + "deduplication-interval-required": "Deduplication interval is required", + "deduplication-interval-min-max-range": "Deduplication interval should be at least 1 second and at most 1 day", + "strategy-type": { + "every-message": "On every message", + "skip": "Skip", + "deduplicate": "Deduplicate", + "web-sockets-only": "WebSockets only" + }, + "time-series": "Time series", + "latest": "Latest values", + "web-sockets": "WebSockets", + "calculated-fields": "Calculated fields" + }, "key-val": { "key": "Key", "value": "Value", @@ -5353,6 +5481,7 @@ "entities": "Entities", "rule-engine": "Rule Engine", "time-to-live": "Time-to-live", + "calculated-fields": "Calculated fields", "alarms-and-notifications": "Alarms and notifications", "ota-files-in-bytes": "Files", "ws-title": "WS", @@ -5405,6 +5534,21 @@ "tenant-entity-import-rate-limit": "Entity version load", "tenant-notification-request-rate-limit": "Notification requests", "tenant-notification-requests-per-rule-rate-limit": "Notification requests per notification rule", + "max-calculated-fields": "Maximum number of calculated fields per entity", + "max-calculated-fields-range": "Maximum number of calculated fields per entity can't be negative", + "max-calculated-fields-required": "Maximum number of calculated fields per entity is required", + "max-data-points-per-rolling-arg": "Maximum number of data points in a time series rolling arguments", + "max-data-points-per-rolling-arg-range": "Maximum number of data points in a time series rolling arguments can't be negative", + "max-data-points-per-rolling-arg-required": "Maximum number of data points in a time series rolling arguments is required", + "max-arguments-per-cf": "Maximum number of arguments per calculated field", + "max-arguments-per-cf-range": "Maximum number of arguments per calculated field can't be negative", + "max-arguments-per-cf-required": "Maximum number of arguments per calculated field is required", + "max-state-size": "Maximum size of the state in KB", + "max-state-size-range": "Maximum size of the state in KB can't be negative", + "max-state-size-required": "Maximum size of the state in KB is required", + "max-value-argument-size": "Maximum size of the single value argument in KB", + "max-value-argument-size-range": "Maximum size of the single value argument in KB can't be negative", + "max-value-argument-size-required": "Maximum size of the single value argument in KB is required", "max-transport-messages": "Transport messages maximum number", "max-transport-messages-required": "Transport messages maximum number is required.", "max-transport-messages-range": "Transport messages maximum number can't be negative", @@ -5476,6 +5620,8 @@ "advanced-settings": "Advanced settings", "edit-limit": "Edit limit", "but-less-than": "but less than", + "calculated-field-debug-event-rate-limit": "Calculated field debug events", + "edit-calculated-field-debug-event-rate-limit": "Edit calculated field debug events rate limits", "edit-transport-tenant-msg-title": "Edit transport tenant messages rate limits", "edit-transport-tenant-telemetry-msg-title": "Edit transport tenant telemetry messages rate limits", "edit-transport-tenant-telemetry-data-points-title": "Edit transport tenant telemetry data points rate limits", @@ -6599,6 +6745,8 @@ "layout-outlined-icon": "Outlined.Icon", "main": "Main", "background": "Background", + "button-icon-on": "Button icon 'On'", + "button-icon-off": "Button icon 'Off'", "power-on-colors": "Power 'On' colors", "power-off-colors": "Power 'Off' colors", "disabled-colors": "Disabled colors", @@ -6651,6 +6799,41 @@ "preview": "Preview", "copy-style-from": "Copy style from" }, + "value-stepper": { + "behavior": "Behavior", + "simplified": "Simplified", + "filled": "Filled", + "outlined": "Outlined", + "volume": "Volume", + "initial-state": "Initial state", + "initial-state-hint": "Action to get the initial value.", + "disabled-state": "Disabled state", + "disabled-state-hint": "Configure condition under which the component is disabled.", + "right-button-click": "Right button click", + "right-button-click-hint": "Action while pressing on right button.", + "left-button-click": "Left button click", + "left-button-click-hint": "Action while pressing on left button.", + "auto-scale": "Auto scale", + "value-range": "Range", + "min-range": "Min", + "max-range": "Max", + "value-increment-decrement-step": "Value increment/decrement step", + "value": "Value", + "value-box-background": "Value box background", + "border": "Border", + "button-appearance": "Button appearance", + "left": "Left", + "right": "Right", + "left-button": "Left button", + "right-button": "Right button", + "icon": "Icon", + "color-palette": "Color palette", + "main": "Main", + "background": "Background", + "button-icon-on": "Button icon 'On'", + "button-on-colors": "Power 'On' colors", + "disabled-colors": "Disabled colors" + }, "button-state": { "activated-state": "Activated state", "activated-state-hint": "Configure condition under which the button is active.", @@ -8334,15 +8517,18 @@ "set-attribute": "Set attribute", "get-time-series": "Get time series", "get-alarm-status": "Get alarm status", - "get-dashboard-state": "Get dashboard state", + "get-dashboard-state": "Get dashboard state id", + "get-dashboard-state-object": "Get dashboard state object", "add-time-series": "Add time series", "execute-rpc-text": "Execute RPC method '{{methodName}}'", "get-time-series-text": "Use time series '{{key}}'", "get-attribute-text": "Use attribute '{{key}}'", "get-alarm-status-text": "Use alarm status", "get-dashboard-state-text": "Use dashboard state", - "when-dashboard-state-is-text": "When dashboard state is '{{state}}'", - "when-dashboard-state-function-is-text": "When f(dashboard state) is '{{state}}'", + "get-dashboard-state-object-text": "Use dashboard state object", + "when-dashboard-state-is-text": "When dashboard state id is '{{state}}'", + "when-dashboard-state-function-is-text": "When f(dashboard state id) is '{{state}}'", + "when-dashboard-state-object-function-is-text": "When f(dashboard state object) is '{{state}}'", "set-attribute-to-value-text": "Set '{{key}}' attribute to: {{value}}", "add-time-series-value-text": "Add '{{key}}' time series value: {{value}}", "set-attribute-text": "Set '{{key}}' attribute", diff --git a/ui-ngx/src/assets/widget/value-stepper/filled.svg b/ui-ngx/src/assets/widget/value-stepper/filled.svg new file mode 100644 index 0000000000..26c94b92cd --- /dev/null +++ b/ui-ngx/src/assets/widget/value-stepper/filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui-ngx/src/assets/widget/value-stepper/simplified.svg b/ui-ngx/src/assets/widget/value-stepper/simplified.svg new file mode 100644 index 0000000000..8a71ad47c8 --- /dev/null +++ b/ui-ngx/src/assets/widget/value-stepper/simplified.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui-ngx/src/assets/widget/value-stepper/volume.svg b/ui-ngx/src/assets/widget/value-stepper/volume.svg new file mode 100644 index 0000000000..96bda9fa7a --- /dev/null +++ b/ui-ngx/src/assets/widget/value-stepper/volume.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui-ngx/src/environments/environment.prod.ts b/ui-ngx/src/environments/environment.prod.ts index b76f84da34..a42c4a15d4 100644 --- a/ui-ngx/src/environments/environment.prod.ts +++ b/ui-ngx/src/environments/environment.prod.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2024 The Thingsboard Authors +/// Copyright © 2016-2025 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. diff --git a/ui-ngx/src/environments/environment.ts b/ui-ngx/src/environments/environment.ts index adee999496..f5b5a83bc8 100644 --- a/ui-ngx/src/environments/environment.ts +++ b/ui-ngx/src/environments/environment.ts @@ -1,5 +1,5 @@ /// -/// Copyright © 2016-2024 The Thingsboard Authors +/// Copyright © 2016-2025 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. diff --git a/ui-ngx/src/form.scss b/ui-ngx/src/form.scss index 0ae2b9584b..8f3c7f0cd0 100644 --- a/ui-ngx/src/form.scss +++ b/ui-ngx/src/form.scss @@ -1,5 +1,5 @@ /** - * Copyright © 2016-2024 The Thingsboard Authors + * Copyright © 2016-2025 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. @@ -163,6 +163,13 @@ .tb-form-panel-title { font-weight: 500; font-size: 16px; + + &.tb-required::after { + font-size: 13px; + color: rgba(0, 0, 0, .54); + vertical-align: top; + content: " *"; + } } .tb-form-panel-hint { font-size: 12px; @@ -398,6 +405,9 @@ border-color: rgba(0, 0, 0, 0.12); } } + .mdc-notched-outline__notch { + border-left: none; + } .mdc-floating-label { top: 20px; font-weight: 400; diff --git a/ui-ngx/src/index.html b/ui-ngx/src/index.html index 7b0a5987c3..f4eb743f62 100644 --- a/ui-ngx/src/index.html +++ b/ui-ngx/src/index.html @@ -1,6 +1,6 @@