diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.html
new file mode 100644
index 0000000000..5513770627
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.html
@@ -0,0 +1,192 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.scss
new file mode 100644
index 0000000000..7cfc6dfef2
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.scss
@@ -0,0 +1,18 @@
+/**
+ * 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 {
+
+}
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.ts
new file mode 100644
index 0000000000..c521c624d4
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.ts
@@ -0,0 +1,166 @@
+///
+/// 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, DestroyRef, Inject, Input } from '@angular/core';
+import { Store } from '@ngrx/store';
+import { AppState } from '@core/core.state';
+import { EntityComponent } from '../../components/entity/entity.component';
+import { FormBuilder, FormGroup, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
+import { AliasEntityType, EntityType } from '@shared/models/entity-type.models';
+import { NULL_UUID } from '@shared/models/id/has-uuid';
+import { ActionNotificationShow } from '@core/notification/notification.actions';
+import { TranslateService } from '@ngx-translate/core';
+import { AssetInfo } from '@app/shared/models/asset.models';
+import { EntityTableConfig } from '@home/models/entity/entities-table-config.models';
+import {
+ CalculatedFieldConfiguration,
+ CalculatedFieldInfo, calculatedFieldsEntityTypeList,
+ CalculatedFieldType, calculatedFieldTypes, CalculatedFieldTypeTranslations, OutputStrategyType
+} from '@shared/models/calculated-field.models';
+import { oneSpaceInsideRegex } from '@shared/models/regex.constants';
+import { isDefined } from '@core/utils';
+import { EntityId } from '@shared/models/id/entity-id';
+import { pairwise, switchMap } from 'rxjs/operators';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { BaseData } from '@shared/models/base-data';
+import { Observable, of } from 'rxjs';
+import { CalculatedFieldsService } from '@core/http/calculated-fields.service';
+import { getCurrentAuthUser } from '@core/auth/auth.selectors';
+import {
+ CalculatedFieldsTableConfig,
+ CalculatedFieldsTableEntity
+} from '@home/components/calculated-fields/calculated-fields-table-config';
+
+@Component({
+ selector: 'tb-calculated-field',
+ templateUrl: './calculated-field.component.html',
+ styleUrls: ['./calculated-field.component.scss']
+})
+export class CalculatedFieldComponent extends EntityComponent {
+
+ @Input()
+ standalone = false;
+
+ @Input()
+ entityName: string;
+
+ disabledConfiguration = false;
+
+ readonly tenantId = getCurrentAuthUser(this.store).tenantId;
+ readonly ownerId = getCurrentAuthUser(this.store).tenantId;
+ readonly EntityType = EntityType;
+ readonly calculatedFieldsEntityTypeList = calculatedFieldsEntityTypeList;
+ readonly CalculatedFieldType = CalculatedFieldType;
+ readonly fieldTypes = calculatedFieldTypes;
+ readonly CalculatedFieldTypeTranslations = CalculatedFieldTypeTranslations;
+
+ constructor(protected store: Store,
+ protected translate: TranslateService,
+ @Inject('entity') protected entityValue: CalculatedFieldInfo,
+ @Inject('entitiesTableConfig') protected entitiesTableConfigValue: CalculatedFieldsTableConfig,
+ protected fb: FormBuilder,
+ protected cd: ChangeDetectorRef,
+ private destroyRef: DestroyRef,
+ private calculatedFieldsService: CalculatedFieldsService) {
+ super(store, fb, entityValue, entitiesTableConfigValue, cd);
+ }
+
+ hideDelete() {
+ if (this.entitiesTableConfig) {
+ return !this.entitiesTableConfig.deleteEnabled(this.entity);
+ } else {
+ return false;
+ }
+ }
+
+ isAssignedToCustomer(entity: AssetInfo): boolean {
+ return entity && entity.customerId && entity.customerId.id !== NULL_UUID;
+ }
+
+ get entityId(): EntityId {
+ return this.entityForm.get('entityId').value;
+ }
+
+ get entitiesTableConfig(): CalculatedFieldsTableConfig {
+ return this.entitiesTableConfigValue;
+ }
+
+ changeEntity(entity: BaseData): void {
+ this.entityName = entity?.name;
+ }
+
+ buildForm(entity?: CalculatedFieldInfo): FormGroup {
+ const form = this.fb.group({
+ name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]],
+ entityId: [null],
+ type: [CalculatedFieldType.SIMPLE],
+ debugSettings: [],
+ configuration: this.fb.control({} as CalculatedFieldConfiguration),
+ });
+ form.get('type').valueChanges.pipe(
+ pairwise(),
+ takeUntilDestroyed(this.destroyRef)
+ ).subscribe(([prevType, nextType]) => {
+ if (![CalculatedFieldType.SIMPLE, CalculatedFieldType.SCRIPT].includes(prevType) ||
+ ![CalculatedFieldType.SIMPLE, CalculatedFieldType.SCRIPT].includes(nextType)) {
+ form.get('configuration').setValue(({} as CalculatedFieldConfiguration), {emitEvent: false});
+ }
+ });
+ return form;
+ }
+
+ updateForm(entity: CalculatedFieldInfo) {
+ const { configuration = {} as CalculatedFieldConfiguration, type = CalculatedFieldType.SIMPLE, debugSettings = { failuresEnabled: true, allEnabled: true }, entityId = this.entityId, ...value } = entity ?? {};
+ if (configuration.type !== CalculatedFieldType.ALARM) {
+ if (isDefined(configuration?.output) && !configuration?.output?.strategy) {
+ configuration.output.strategy = {type: OutputStrategyType.RULE_CHAIN};
+ }
+ }
+ this.entityForm.patchValue({ configuration, type, debugSettings, entityId, ...value }, {emitEvent: false});
+ setTimeout(() => this.entityForm.get('type').updateValueAndValidity({onlySelf: true}));
+ if (!entityId) {
+ this.entityForm.get('configuration').disable({emitEvent: false});
+ this.disabledConfiguration = true;
+ }
+ }
+
+ onTestScript(expression?: string): Observable {
+ const calculatedFieldId = this.entityId?.id;
+ if (calculatedFieldId) {
+ return this.calculatedFieldsService.getLatestCalculatedFieldDebugEvent(calculatedFieldId, {ignoreLoading: true})
+ .pipe(
+ switchMap(event => {
+ const args = event?.arguments ? JSON.parse(event.arguments) : null;
+ return this.entitiesTableConfig.getTestScriptDialog(this.entityFormValue(), args, false, expression);
+ }),
+ takeUntilDestroyed(this.destroyRef)
+ )
+ }
+
+ return this.entitiesTableConfig.getTestScriptDialog(this.entityFormValue(), null, false, expression);
+ }
+
+ updateFormState() {
+ if (this.entityForm) {
+ if (this.isEditValue) {
+ this.entityForm.enable({emitEvent: false});
+ this.entityForm.get('entityId').disable({emitEvent: false});
+ } else {
+ this.entityForm.disable({emitEvent: false});
+ }
+ }
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts
index e3f5ac9cc2..8a9241970d 100644
--- a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts
+++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts
@@ -50,6 +50,7 @@ import {
import {
CalculatedFieldsFilterConfigComponent
} from '@home/components/calculated-fields/table-header/calculated-fields-filter-config.component';
+import { CalculatedFieldComponent } from '@home/components/calculated-fields/calculated-field.component';
@NgModule({
declarations: [
@@ -57,7 +58,8 @@ import {
CalculatedFieldScriptTestDialogComponent,
CalculatedFieldTestArgumentsComponent,
CalculatedFieldsHeaderComponent,
- CalculatedFieldsFilterConfigComponent
+ CalculatedFieldsFilterConfigComponent,
+ CalculatedFieldComponent
],
imports: [
CommonModule,
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts
index 8385b5cb45..977e03e454 100644
--- a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts
+++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts
@@ -20,7 +20,7 @@ import {
EntityTableColumn,
EntityTableConfig
} from '@home/models/entity/entities-table-config.models';
-import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models';
+import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models';
import { TranslateService } from '@ngx-translate/core';
import { Direction } from '@shared/models/page/sort-order';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
@@ -67,8 +67,11 @@ import { EventsDialogComponent, EventsDialogData } from '@home/dialogs/events-di
import {
CalculatedFieldsHeaderComponent
} from '@home/components/calculated-fields/table-header/calculated-fields-header.component';
+import { EntityAction } from '@home/models/entity/entity-component.models';
+import { CalculatedFieldComponent } from '@home/components/calculated-fields/calculated-field.component';
+import { Router } from '@angular/router';
-type CalculatedFieldsTableEntity = CalculatedField | CalculatedFieldInfo;
+export type CalculatedFieldsTableEntity = CalculatedField | CalculatedFieldInfo;
export class CalculatedFieldsTableConfig extends EntityTableConfig {
@@ -93,30 +96,33 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig {
- this.editCalculatedField($event, entity);
- this.rowPointer = true;
- return true;
- };
+ this.entityComponent = CalculatedFieldComponent;
+ this.rowPointer = true;
}
this.tableTitle = this.pageMode ? '' : this.translate.instant('entity.type-calculated-fields');
- this.detailsPanelEnabled = false;
+ this.detailsPanelEnabled = this.pageMode;
this.entityType = EntityType.CALCULATED_FIELD;
this.entityTranslations = entityTypeTranslations.get(EntityType.CALCULATED_FIELD);
+ this.entityResources = entityTypeResources.get(EntityType.CALCULATED_FIELD);
this.entitiesFetchFunction = (pageLink: PageLink) => this.fetchCalculatedFields(pageLink);
this.addEntity = this.getCalculatedFieldDialog.bind(this);
+ this.loadEntity = id => this.calculatedFieldsService.getCalculatedFieldById(id.id);
this.deleteEntityTitle = (field) => this.translate.instant('calculated-fields.delete-title', {title: field.name});
this.deleteEntityContent = () => this.translate.instant('calculated-fields.delete-text');
this.deleteEntitiesTitle = count => this.translate.instant('calculated-fields.delete-multiple-title', {count});
this.deleteEntitiesContent = () => this.translate.instant('calculated-fields.delete-multiple-text');
this.deleteEntity = id => this.calculatedFieldsService.deleteCalculatedField(id.id);
+
+ this.onEntityAction = action => this.onCFAction(action);
+
this.addActionDescriptors = [
{
name: this.translate.instant('calculated-fields.create'),
@@ -172,14 +178,16 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig true,
iconFunction: ({ debugSettings }) => this.entityDebugSettingsService.isDebugActive(debugSettings?.allEnabledUntil) || debugSettings?.failuresEnabled ? 'mdi:bug' : 'mdi:bug-outline',
onAction: ($event, entity) => this.onOpenDebugConfig($event, entity),
- },
- {
+ }
+ );
+ if (!this.pageMode) {
+ this.cellActionDescriptors.push({
name: this.translate.instant('action.edit'),
icon: 'edit',
isEnabled: () => true,
onAction: ($event, entity) => this.editCalculatedField($event, entity),
- }
- );
+ })
+ }
}
fetchCalculatedFields(pageLink: PageLink): Observable> {
@@ -354,7 +362,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig this.updateData());
}
- private getTestScriptDialog(calculatedField: CalculatedFieldsTableEntity, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true, expression?: string): Observable {
+ getTestScriptDialog(calculatedField: CalculatedFieldsTableEntity, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true, expression?: string): Observable {
if (
calculatedField.type === CalculatedFieldType.SCRIPT ||
calculatedField.type === CalculatedFieldType.RELATED_ENTITIES_AGGREGATION ||
@@ -394,4 +402,22 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig): boolean {
+ switch (action.action) {
+ case 'open':
+ this.openCalculatedField(action.event, action.entity);
+ return true;
+ case 'export':
+ this.exportCalculatedField(action.event, action.entity);
+ return true;
+ }
+ return false;
+ }
}
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
index 2a0c29e31a..7687277113 100644
--- 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
@@ -36,7 +36,7 @@ import { ImportExportService } from '@shared/import-export/import-export.service
import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service';
import { DatePipe } from '@angular/common';
import { UtilsService } from "@core/services/utils.service";
-import { ActivatedRoute } from '@angular/router';
+import { ActivatedRoute, Router } from '@angular/router';
@Component({
selector: 'tb-calculated-fields-table',
@@ -70,6 +70,7 @@ export class CalculatedFieldsTableComponent {
private utilsService: UtilsService,
private destroyRef: DestroyRef,
private route: ActivatedRoute,
+ private router: Router
) {
this.pageMode = !!this.route.snapshot.data.isPage;
effect(() => {
@@ -88,6 +89,7 @@ export class CalculatedFieldsTableComponent {
this.importExportService,
this.entityDebugSettingsService,
this.utilsService,
+ this.router,
this.pageMode,
);
this.cd.markForCheck();
diff --git a/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings.service.ts b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings.service.ts
index cb982b9650..76def20a24 100644
--- a/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings.service.ts
+++ b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings.service.ts
@@ -22,7 +22,9 @@ import { TranslateService } from '@ngx-translate/core';
import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe';
import { EntityDebugSettingPanelConfig } from '@home/components/entity/debug/entity-debug-settings.model';
-@Injectable()
+@Injectable({
+ providedIn: 'root'
+})
export class EntityDebugSettingsService {
constructor(
diff --git a/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields-routing.module.ts b/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields-routing.module.ts
index 0ed435075c..03be3d86f6 100644
--- a/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields-routing.module.ts
+++ b/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields-routing.module.ts
@@ -14,24 +14,94 @@
/// limitations under the License.
///
-import { NgModule } from '@angular/core';
-import { RouterModule, Routes } from '@angular/router';
+import { DestroyRef, inject, NgModule, Renderer2 } from '@angular/core';
+import { ActivatedRouteSnapshot, ResolveFn, Router, RouterModule, RouterStateSnapshot, Routes } from '@angular/router';
import { Authority } from '@shared/models/authority.enum';
import { MenuId } from '@core/services/menu.models';
import { CalculatedFieldsTableComponent } from '@home/components/calculated-fields/calculated-fields-table.component';
+import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component';
+import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard';
+import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models';
+import { BreadCrumbConfig } from '@shared/components/breadcrumb';
+import { CalculatedFieldsTableConfig } from '@home/components/calculated-fields/calculated-fields-table-config';
+import { CalculatedFieldsService } from '@core/http/calculated-fields.service';
+import { TranslateService } from '@ngx-translate/core';
+import { MatDialog } from '@angular/material/dialog';
+import { Store } from '@ngrx/store';
+import { AppState } from '@core/core.state';
+import { DatePipe } from '@angular/common';
+import { ImportExportService } from '@shared/import-export/import-export.service';
+import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service';
+import { UtilsService } from '@core/services/utils.service';
+
+export const CalculatedFieldsTableConfigResolver: ResolveFn =
+ (_route: ActivatedRouteSnapshot,
+ _state: RouterStateSnapshot,
+ calculatedFieldsService = inject(CalculatedFieldsService),
+ translate = inject(TranslateService),
+ dialog = inject(MatDialog),
+ store = inject(Store),
+ datePipe = inject(DatePipe),
+ destroyRef = inject(DestroyRef),
+ importExportService = inject(ImportExportService),
+ entityDebugSettingsService = inject(EntityDebugSettingsService),
+ utilsService = inject(UtilsService),
+ router = inject(Router),
+ ) => {
+ return new CalculatedFieldsTableConfig(
+ calculatedFieldsService,
+ translate,
+ dialog,
+ datePipe,
+ null,
+ store,
+ destroyRef,
+ null,
+ null,
+ null,
+ importExportService,
+ entityDebugSettingsService,
+ utilsService,
+ router,
+ false,
+ );
+ };
const routes: Routes = [
{
path: 'calculatedFields',
- component: CalculatedFieldsTableComponent,
data: {
- auth: [Authority.TENANT_ADMIN],
- title: 'entity.type-calculated-fields',
breadcrumb: {
menuId: MenuId.calculated_fields
+ }
+ },
+ children: [
+ {
+ path: '',
+ component: CalculatedFieldsTableComponent,
+ data: {
+ auth: [Authority.TENANT_ADMIN],
+ title: 'entity.type-calculated-fields',
+ isPage: true,
+ }
},
- isPage: true,
- }
+ {
+ path: ':entityId',
+ component: EntityDetailsPageComponent,
+ canDeactivate: [ConfirmOnExitGuard],
+ data: {
+ breadcrumb: {
+ labelFunction: entityDetailsPageBreadcrumbLabelFunction,
+ icon: 'mdi:function-variant'
+ } as BreadCrumbConfig,
+ auth: [Authority.TENANT_ADMIN],
+ title: 'entity.type-calculated-fields',
+ },
+ resolve: {
+ entitiesTableConfig: CalculatedFieldsTableConfigResolver
+ }
+ }
+ ]
}
];
diff --git a/ui-ngx/src/app/shared/models/entity-type.models.ts b/ui-ngx/src/app/shared/models/entity-type.models.ts
index 48428932e4..bc0d3fd470 100644
--- a/ui-ngx/src/app/shared/models/entity-type.models.ts
+++ b/ui-ngx/src/app/shared/models/entity-type.models.ts
@@ -490,7 +490,7 @@ export const entityTypeTranslations = new Map