Browse Source

UI: Add calculated fields page

pull/14609/head
Vladyslav_Prykhodko 10 months ago
parent
commit
9f48f8cce2
  1. 12
      ui-ngx/src/app/core/services/menu.models.ts
  2. 4
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html
  3. 4
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts
  4. 2
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts
  5. 8
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts
  6. 31
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts
  7. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.html
  8. 6
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.scss
  9. 12
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts
  10. 49
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html
  11. 49
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts
  12. 107
      ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-filter-config.component.html
  13. 56
      ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-filter-config.component.scss
  14. 288
      ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-filter-config.component.ts
  15. 20
      ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-header.component.html
  16. 21
      ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-header.component.scss
  17. 43
      ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-header.component.ts
  18. 15
      ui-ngx/src/app/modules/home/pages/alarm/alarm-routing.module.ts
  19. 43
      ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields-routing.module.ts
  20. 34
      ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields.module.ts
  21. 2
      ui-ngx/src/app/modules/home/pages/home-pages.module.ts
  22. 8
      ui-ngx/src/app/shared/models/calculated-field.models.ts
  23. 5
      ui-ngx/src/assets/locale/locale.constant-en_US.json

12
ui-ngx/src/app/core/services/menu.models.ts

@ -98,6 +98,7 @@ export enum MenuId {
device_profiles = 'device_profiles',
asset_profiles = 'asset_profiles',
customers = 'customers',
calculated_fields = 'calculated_fields',
rule_chains = 'rule_chains',
edge_management = 'edge_management',
edges = 'edges',
@ -626,6 +627,16 @@ export const menuSectionMap = new Map<MenuId, MenuSection>([
icon: 'supervisor_account'
}
],
[
MenuId.calculated_fields,
{
id: MenuId.calculated_fields,
name: 'entity.type-calculated-fields',
type: 'link',
path: '/calculatedFields',
icon: 'calculate',
}
],
[
MenuId.rule_chains,
{
@ -839,6 +850,7 @@ const defaultUserMenuMap = new Map<Authority, MenuReference[]>([
]
},
{id: MenuId.customers},
{id: MenuId.calculated_fields},
{id: MenuId.rule_chains},
{
id: MenuId.edge_management,

4
ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html

@ -56,7 +56,7 @@
/>
</div>
@if (!data.entityId) {
<div class="flex flex-row xs:flex-col gap-4" formGroupName="entityId">
<div class="flex flex-row gap-4 xs:flex-col" formGroupName="entityId">
<tb-entity-type-select #entityTypeSelect
appearance="outline"
subscriptSizing="dynamic"
@ -118,7 +118,7 @@
</button>
</div>
<div *ngIf="!configFormGroup.get('clearRule').value">
<span translate class="tb-prompt text-base flex items-center justify-center">alarm-rule.no-clear-alarm-rule</span>
<span translate class="tb-prompt flex items-center justify-center text-base">alarm-rule.no-clear-alarm-rule</span>
</div>
<div [class.!hidden]="configFormGroup.get('clearRule').value">
<button mat-stroked-button color="primary"

4
ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts

@ -60,7 +60,7 @@ import { UtilsService } from "@core/services/utils.service";
import { deepClone, getEntityDetailsPageURL, isObject } from "@core/utils";
import { AlarmRuleTableHeaderComponent } from "@home/components/alarm-rules/alarm-rule-table-header.component";
import { EventsDialogComponent, EventsDialogData } from '@home/dialogs/events-dialog.component';
import { DebugEventType, Event as DebugEvent, EventType } from '@shared/models/event.models';
import { DebugEventType, EventType } from '@shared/models/event.models';
import { ActionNotificationShow } from "@core/notification/notification.actions";
import {
CalculatedFieldScriptTestDialogComponent,
@ -188,7 +188,7 @@ export class AlarmRulesTableConfig extends EntityTableConfig<any> {
fetchCalculatedFields(pageLink: PageLink): Observable<PageData<CalculatedField>> {
return this.pageMode ?
this.calculatedFieldsService.getCalculatedFieldsFilter(pageLink, {type: CalculatedFieldType.ALARM, ...this.alarmRuleFilterConfig}) :
this.calculatedFieldsService.getCalculatedFieldsFilter(pageLink, {types: [CalculatedFieldType.ALARM], ...this.alarmRuleFilterConfig}) :
this.calculatedFieldsService.getCalculatedFields(this.entityId, pageLink, CalculatedFieldType.ALARM);
}

2
ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts

@ -88,7 +88,7 @@ export class AlarmRulesTableComponent {
this.importExportService,
this.entityDebugSettingsService,
this.utilsService,
this.pageMode
this.pageMode,
);
this.cd.markForCheck();
}

8
ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts

@ -44,12 +44,20 @@ import {
import {
EntityAggregationComponentModule
} from '@home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.module';
import {
CalculatedFieldsHeaderComponent
} from '@home/components/calculated-fields/table-header/calculated-fields-header.component';
import {
CalculatedFieldsFilterConfigComponent
} from '@home/components/calculated-fields/table-header/calculated-fields-filter-config.component';
@NgModule({
declarations: [
CalculatedFieldDialogComponent,
CalculatedFieldScriptTestDialogComponent,
CalculatedFieldTestArgumentsComponent,
CalculatedFieldsHeaderComponent,
CalculatedFieldsFilterConfigComponent
],
imports: [
CommonModule,

31
ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts

@ -16,6 +16,7 @@
import {
DateEntityTableColumn,
EntityLinkTableColumn,
EntityTableColumn,
EntityTableConfig
} from '@home/models/entity/entities-table-config.models';
@ -39,8 +40,10 @@ import {
ArgumentEntityType,
ArgumentType,
CalculatedField,
CalculatedFieldAlarmRule,
CalculatedFieldEventArguments,
CalculatedFieldScriptConfiguration,
CalculatedFieldsQuery,
CalculatedFieldType,
CalculatedFieldTypeTranslations,
getCalculatedFieldArgumentsEditorCompleter,
@ -54,13 +57,16 @@ import {
CalculatedFieldTestScriptDialogData
} from './components/public-api';
import { ImportExportService } from '@shared/import-export/import-export.service';
import { isObject } from '@core/utils';
import { getEntityDetailsPageURL, isObject } from '@core/utils';
import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service';
import { DatePipe } from '@angular/common';
import { UtilsService } from "@core/services/utils.service";
import { ActionNotificationShow } from "@core/notification/notification.actions";
import { CalculatedFieldEventBody, DebugEventType, Event as DebugEvent, EventType } from '@shared/models/event.models';
import { EventsDialogComponent, EventsDialogData } from '@home/dialogs/events-dialog.component';
import {
CalculatedFieldsHeaderComponent
} from '@home/components/calculated-fields/table-header/calculated-fields-header.component';
export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedField> {
@ -70,6 +76,8 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
action: (calculatedField: CalculatedField) => this.openDebugEventsDialog.call(this, calculatedField),
};
calculatedFieldFilterConfig: CalculatedFieldsQuery;
constructor(private calculatedFieldsService: CalculatedFieldsService,
private translate: TranslateService,
private dialog: MatDialog,
@ -83,11 +91,14 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
private importExportService: ImportExportService,
private entityDebugSettingsService: EntityDebugSettingsService,
private utilsService: UtilsService,
public pageMode = false,
) {
super();
this.tableTitle = this.translate.instant('entity.type-calculated-fields');
if (this.pageMode) {
this.headerComponent = CalculatedFieldsHeaderComponent;
}
this.tableTitle = this.pageMode ? '' : this.translate.instant('entity.type-calculated-fields');
this.detailsPanelEnabled = false;
this.pageMode = false;
this.entityType = EntityType.CALCULATED_FIELD;
this.entityTranslations = entityTypeTranslations.get(EntityType.CALCULATED_FIELD);
@ -127,11 +138,19 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
};
this.columns.push(new DateEntityTableColumn<CalculatedField>('createdTime', 'common.created-time', this.datePipe, '150px'));
this.columns.push(new EntityTableColumn<CalculatedField>('name', 'common.name', '33%',
this.columns.push(new EntityTableColumn<CalculatedField>('name', 'common.name', this.pageMode ? '30%' :'33%',
entity => this.utilsService.customTranslation(entity.name, entity.name)));
this.columns.push(new EntityTableColumn<CalculatedField>('type', 'common.type', '170px', entity => this.translate.instant(CalculatedFieldTypeTranslations.get(entity.type).name), () => ({whiteSpace: 'nowrap' })));
this.columns.push(expressionColumn);
if (this.pageMode) {
this.columns.push(new EntityTableColumn<CalculatedFieldAlarmRule>('entityType', 'alarm-rule.target-entity-type', '15%',
entity => this.translate.instant(entityTypeTranslations.get(entity.entityId.entityType).type)));
this.columns.push(new EntityLinkTableColumn<CalculatedFieldAlarmRule>('entityName', 'alarm-rule.target-entity', '30%',
entity => this.utilsService.customTranslation(entity['entityName'], entity['entityName']),
entity => getEntityDetailsPageURL(entity.entityId?.id, entity.entityId?.entityType as EntityType), false));
}
this.cellActionDescriptors.push(
{
name: this.translate.instant('action.export'),
@ -173,7 +192,9 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
}
fetchCalculatedFields(pageLink: PageLink): Observable<PageData<CalculatedField>> {
return this.calculatedFieldsService.getCalculatedFields(this.entityId, pageLink);
return this.pageMode ?
this.calculatedFieldsService.getCalculatedFieldsFilter(pageLink, this.calculatedFieldFilterConfig):
this.calculatedFieldsService.getCalculatedFields(this.entityId, pageLink);
}
onOpenDebugConfig($event: Event, calculatedField: CalculatedField): void {

2
ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.html

@ -16,5 +16,5 @@
-->
@if (calculatedFieldsTableConfig) {
<tb-entities-table [entitiesTableConfig]="calculatedFieldsTableConfig"></tb-entities-table>
<tb-entities-table [class.white-background]="!pageMode" [entitiesTableConfig]="calculatedFieldsTableConfig"></tb-entities-table>
}

6
ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.scss

@ -14,9 +14,7 @@
* limitations under the License.
*/
:host ::ng-deep {
tb-entities-table {
.mat-drawer-container {
background-color: white;
}
.white-background {
--mat-sidenav-content-background-color: white;
}
}

12
ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table.component.ts

@ -36,6 +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';
@Component({
selector: 'tb-calculated-fields-table',
@ -55,6 +56,8 @@ export class CalculatedFieldsTableComponent {
calculatedFieldsTableConfig: CalculatedFieldsTableConfig;
pageMode: boolean = false;
constructor(private calculatedFieldsService: CalculatedFieldsService,
private translate: TranslateService,
private dialog: MatDialog,
@ -65,10 +68,12 @@ export class CalculatedFieldsTableComponent {
private importExportService: ImportExportService,
private entityDebugSettingsService: EntityDebugSettingsService,
private utilsService: UtilsService,
private destroyRef: DestroyRef) {
private destroyRef: DestroyRef,
private route: ActivatedRoute,
) {
this.pageMode = !!this.route.snapshot.data.isPage;
effect(() => {
if (this.active()) {
if (this.active() || this.pageMode) {
this.calculatedFieldsTableConfig = new CalculatedFieldsTableConfig(
this.calculatedFieldsService,
this.translate,
@ -83,6 +88,7 @@ export class CalculatedFieldsTableComponent {
this.importExportService,
this.entityDebugSettingsService,
this.utilsService,
this.pageMode,
);
this.cd.markForCheck();
}

49
ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html

@ -28,8 +28,8 @@
</mat-toolbar>
<div mat-dialog-content class="flex-1">
<div class="tb-form-panel no-border no-padding">
<div class="tb-form-panel">
<div class="tb-form-panel-title">{{ 'common.general' | translate }}</div>
<div class="tb-form-panel no-gap">
<div class="tb-form-panel-title mb-4">{{ 'common.general' | translate }}</div>
<div class="flex items-center gap-2">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'entity-field.title' | translate }}</mat-label>
@ -45,6 +45,7 @@
}
</mat-error>
}
<mat-hint></mat-hint>
</mat-form-field>
<tb-entity-debug-settings-button
formControlName="debugSettings"
@ -53,6 +54,30 @@
[additionalActionConfig]="additionalDebugActionConfig"
/>
</div>
@if (!data.entityId) {
<div class="flex flex-row gap-4 xs:flex-col" formGroupName="entityId">
<tb-entity-type-select #entityTypeSelect
appearance="outline"
class="flex-1"
showLabel
required
label="{{ 'alarm-rule.target-entity-type' | translate }}"
[filterAllowedEntityTypes]="false"
[allowedEntityTypes]="calculatedFieldsEntityTypeList"
formControlName="entityType">
</tb-entity-type-select>
@if (fieldFormGroup.get('entityId.entityType').value) {
<tb-entity-autocomplete #entityAutocompleteComponent
formControlName="id"
appearance="outline"
class="flex-1"
[placeholder]="'action.set' | translate"
[required]="true"
[entityType]="fieldFormGroup.get('entityId.entityType').value"
/>
}
</div>
}
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'common.type' | translate }}</mat-label>
<mat-select formControlName="type">
@ -68,16 +93,16 @@
@switch (fieldFormGroup.get('type').value) {
@case (CalculatedFieldType.GEOFENCING) {
<tb-geofencing-configuration formControlName="configuration"
[entityId]="data.entityId"
[entityName]="data.entityName"
[entityId]="entityId"
[entityName]="entityName"
[ownerId]="data.ownerId"
[tenantId]="data.tenantId"
></tb-geofencing-configuration>
}
@case (CalculatedFieldType.PROPAGATION) {
<tb-propagation-configuration formControlName="configuration"
[entityId]="data.entityId"
[entityName]="data.entityName"
[entityId]="entityId"
[entityName]="entityName"
[tenantId]="data.tenantId"
[ownerId]="data.ownerId"
[testScript]="onTestScript.bind(this)"
@ -85,23 +110,23 @@
}
@case (CalculatedFieldType.RELATED_ENTITIES_AGGREGATION) {
<tb-related-entities-aggregation-component formControlName="configuration"
[entityId]="data.entityId"
[entityName]="data.entityName"
[entityId]="entityId"
[entityName]="entityName"
[tenantId]="data.tenantId"
[testScript]="onTestScript.bind(this)"
></tb-related-entities-aggregation-component>
}
@case (CalculatedFieldType.ENTITY_AGGREGATION) {
<tb-entity-aggregation-component formControlName="configuration"
[entityId]="data.entityId"
[entityName]="data.entityName"
[entityId]="entityId"
[entityName]="entityName"
[tenantId]="data.tenantId"
></tb-entity-aggregation-component>
}
@default {
<tb-simple-configuration formControlName="configuration"
[entityId]="data.entityId"
[entityName]="data.entityName"
[entityId]="entityId"
[entityName]="entityName"
[ownerId]="data.ownerId"
[tenantId]="data.tenantId"
[isScript]="fieldFormGroup.get('type').value === CalculatedFieldType.SCRIPT"

49
ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts

@ -14,7 +14,7 @@
/// limitations under the License.
///
import { Component, DestroyRef, Inject, ViewEncapsulation } from '@angular/core';
import { Component, DestroyRef, Inject, ViewChild, ViewEncapsulation } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
@ -24,13 +24,15 @@ import { DialogComponent } from '@shared/components/dialog.component';
import {
CalculatedField,
CalculatedFieldConfiguration,
calculatedFieldsEntityTypeList,
CalculatedFieldTestScriptFn,
CalculatedFieldType,
calculatedFieldTypes,
CalculatedFieldTypeTranslations,
OutputStrategyType
} from '@shared/models/calculated-field.models';
import { oneSpaceInsideRegex } from '@shared/models/regex.constants';
import { EntityType } from '@shared/models/entity-type.models';
import { AliasEntityType, EntityType } from '@shared/models/entity-type.models';
import { pairwise, switchMap } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CalculatedFieldsService } from '@core/http/calculated-fields.service';
@ -38,6 +40,9 @@ import { Observable } from 'rxjs';
import { EntityId } from '@shared/models/id/entity-id';
import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model';
import { deepTrim, isDefined } from '@core/utils';
import { EntityTypeSelectComponent } from '@shared/components/entity/entity-type-select.component';
import { EntityAutocompleteComponent } from '@shared/components/entity/entity-autocomplete.component';
import { EntityService } from '@core/http/entity.service';
export interface CalculatedFieldDialogData {
value?: CalculatedField;
@ -61,6 +66,10 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
fieldFormGroup = this.fb.group({
name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]],
entityId: this.fb.group({
entityType: this.fb.control<EntityType | AliasEntityType | null>(EntityType.DEVICE_PROFILE, Validators.required),
id: [null as null | string, Validators.required],
}),
type: [CalculatedFieldType.SIMPLE],
debugSettings: [],
configuration: this.fb.control<CalculatedFieldConfiguration>({} as CalculatedFieldConfiguration),
@ -71,22 +80,44 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
action: () => this.data.additionalDebugActionConfig.action({ id: this.data.value.id, ...this.fromGroupValue }),
} : null;
entityName = this.data.entityName;
readonly EntityType = EntityType;
readonly calculatedFieldsEntityTypeList = calculatedFieldsEntityTypeList;
readonly CalculatedFieldType = CalculatedFieldType;
readonly fieldTypes = Object.values(CalculatedFieldType).filter(type => type !== CalculatedFieldType.ALARM) as CalculatedFieldType[];
readonly fieldTypes = calculatedFieldTypes;
readonly CalculatedFieldTypeTranslations = CalculatedFieldTypeTranslations;
@ViewChild('entityTypeSelect') entityTypeSelect: EntityTypeSelectComponent;
@ViewChild('entityAutocompleteComponent') entityAutocompleteComponent: EntityAutocompleteComponent;
constructor(protected store: Store<AppState>,
protected router: Router,
@Inject(MAT_DIALOG_DATA) public data: CalculatedFieldDialogData,
protected dialogRef: MatDialogRef<CalculatedFieldDialogComponent, CalculatedField>,
private calculatedFieldsService: CalculatedFieldsService,
private entityService: EntityService,
private destroyRef: DestroyRef,
private fb: FormBuilder) {
super(store, router, dialogRef);
this.observeIsLoading();
this.observeType();
this.applyDialogData();
if (!this.entityName) {
this.fieldFormGroup.get('entityId.id').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe((entityId) => {
if (entityId && (this.fieldFormGroup.get('entityId.entityType').value === EntityType.DEVICE_PROFILE ||
this.fieldFormGroup.get('entityId.entityType').value === EntityType.ASSET_PROFILE)) {
this.entityService.getEntity(this.fieldFormGroup.get('entityId.entityType').value as EntityType, entityId, {ignoreLoading: true, ignoreErrors: true}).subscribe(
value => {
this.entityName = value.name;
}
)
}
});
}
}
get fromGroupValue(): CalculatedField {
@ -102,6 +133,10 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
this.calculatedFieldsService.saveCalculatedField({ entityId: this.data.entityId, ...(this.data.value ?? {}), ...this.fromGroupValue})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(calculatedField => this.dialogRef.close(calculatedField));
} else {
this.fieldFormGroup.get('name').markAsTouched();
this.entityTypeSelect?.markAsTouched();
this.entityAutocompleteComponent?.markAsTouched();
}
}
@ -120,14 +155,18 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
return this.data.getTestScriptDialogFn(this.fromGroupValue, null, false, expression);
}
get entityId(): EntityId {
return this.data.entityId || this.fieldFormGroup.get('entityId').value;
}
private applyDialogData(): void {
const { configuration = {} as CalculatedFieldConfiguration, type = CalculatedFieldType.SIMPLE, debugSettings = { failuresEnabled: true, allEnabled: true }, ...value } = this.data.value ?? {};
const { configuration = {} as CalculatedFieldConfiguration, type = CalculatedFieldType.SIMPLE, debugSettings = { failuresEnabled: true, allEnabled: true }, entityId = this.data.entityId, ...value } = this.data.value ?? {};
if (configuration.type !== CalculatedFieldType.ALARM) {
if (isDefined(configuration?.output) && !configuration?.output?.strategy) {
configuration.output.strategy = {type: OutputStrategyType.RULE_CHAIN};
}
}
this.fieldFormGroup.patchValue({ configuration, type, debugSettings, ...value }, {emitEvent: false});
this.fieldFormGroup.patchValue({ configuration, type, debugSettings, entityId, ...value }, {emitEvent: false});
setTimeout(() => this.fieldFormGroup.get('type').updateValueAndValidity({onlySelf: true}));
}

107
ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-filter-config.component.html

@ -0,0 +1,107 @@
<!--
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.
-->
<ng-container *ngIf="panelMode; else componentMode">
<ng-container *ngTemplateOutlet="calculatedFieldsFilterPanel"></ng-container>
</ng-container>
<ng-template #componentMode>
<ng-container *ngIf="buttonMode; else calculatedFieldsFilter">
<button color="primary"
matTooltip="{{ 'calculated-fields.calculated-fields-filter' | translate }}"
matTooltipPosition="above"
mat-stroked-button
(click)="toggleCfFilterPanel($event)">
<mat-icon>filter_list</mat-icon>{{ 'calculated-fields.calculated-fields-filter' | translate }}
</button>
</ng-container>
</ng-template>
<ng-template #calculatedFieldsFilterPanel>
<form class="mat-content mat-padding flex flex-col" (ngSubmit)="update()">
<ng-container *ngTemplateOutlet="calculatedFieldsFilter"></ng-container>
<div class="tb-panel-actions flex flex-row items-center justify-end">
<button type="button"
mat-button
color="primary"
(click)="reset()">
{{ 'action.reset' | translate }}
</button>
<span class="flex-1"></span>
<button type="button"
mat-button
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button type="submit"
mat-raised-button
color="primary"
[disabled]="cfFilterForm.invalid || !cfFilterForm.dirty">
{{ 'action.update' | translate }}
</button>
</div>
</form>
</ng-template>
<ng-template #calculatedFieldsFilter>
<div class="tb-form-panel tb-calculated-fields-config-component no-padding no-border" [formGroup]="cfFilterForm">
<div class="tb-form-row column-xs">
<div class="fixed-title-width" translate>calculated-fields.calculated-field-names</div>
<tb-string-items-list subscriptSizing="dynamic"
formControlName="name"
appearance="outline"
fieldClass="flex"
class="flex-1"
placeholder="{{ !cfFilterForm.get('name').value?.length ? ('calculated-fields.all-names' | translate) : '' }}"
allowUserValue>
</tb-string-items-list>
</div>
<div class="tb-form-row column-xs">
<div class="fixed-title-width" translate>calculated-fields.calculated-field-types</div>
<tb-string-items-list subscriptSizing="dynamic"
formControlName="types"
appearance="outline"
fieldClass="flex"
class="flex-1"
placeholder="{{ !cfFilterForm.get('types').value?.length ? ('calculated-fields.any-type' | translate) : '' }}"
[predefinedValues]="types">
</tb-string-items-list>
</div>
<div class="tb-form-row column-xs">
<div class="fixed-title-width" translate>alarm-rule.target-entity-type</div>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="entityType" placeholder="{{ 'alarm-rule.any-type' | translate }}">
<mat-option>{{ 'alarm-rule.any-type' | translate }}</mat-option>
<mat-option *ngFor="let type of listEntityTypes" [value]="type">
{{ entityTypeTranslations.get(type)?.type | translate }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
@if (cfFilterForm.get('entityType').value) {
<div class="tb-form-row column-xs">
<div class="fixed-title-width" translate>alarm-rule.target-entities</div>
<tb-entity-list appearance="outline"
subscriptSizing="dynamic"
class="flex flex-1"
inlineField
syncIdsWithDB
labelText="{{'entity.entity-list' | translate}}"
[entityType]="cfFilterForm.get('entityType').value"
formControlName="entities">
</tb-entity-list>
</div>
}
</div>
</ng-template>

56
ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-filter-config.component.scss

@ -0,0 +1,56 @@
/**
* 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 '../scss/constants';
:host {
display: flex;
max-width: 100%;
.mdc-button {
max-width: 100%;
}
}
:host ::ng-deep {
.mdc-button {
.mat-icon {
min-width: 24px;
}
.mdc-button__label {
overflow: hidden;
text-overflow: ellipsis;
}
}
}
::ng-deep {
.tb-calculated-fields-config-component {
max-width: 100%;
width: 600px;
min-width: 100%;
flex: 1;
tb-entity-subtype-list {
flex: 1;
@media #{$mat-gt-xs} {
width: 180px;
}
.mdc-evolution-chip-set__chips {
width: 100%;
}
}
}
}

288
ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-filter-config.component.ts

@ -0,0 +1,288 @@
///
/// 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,
ElementRef,
forwardRef,
Inject,
InjectionToken,
Input,
OnInit,
Optional,
TemplateRef,
ViewChild,
ViewContainerRef
} from '@angular/core';
import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from '@angular/forms';
import { coerceBoolean } from '@shared/decorators/coercion';
import { Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay';
import { TemplatePortal } from '@angular/cdk/portal';
import { deepClone, isArraysEqualIgnoreUndefined, isDefinedAndNotNull, isEmpty, isUndefinedOrNull } from '@core/utils';
import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models';
import { fromEvent, Subscription } from 'rxjs';
import { POSITION_MAP } from '@shared/models/overlay.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
calculatedFieldsEntityTypeList,
CalculatedFieldsQuery,
calculatedFieldTypes,
CalculatedFieldTypeTranslations
} from '@shared/models/calculated-field.models';
import { StringItemsOption } from '@shared/components/string-items-list.component';
import { TranslateService } from '@ngx-translate/core';
export const CALCULATED_FIELDS_CONFIG_DATA = new InjectionToken<any>('CalculatedFieldsFilterConfigData');
export interface CalculatedFieldsFilterConfigData {
panelMode: boolean;
userMode: boolean;
filterConfig: CalculatedFieldsQuery;
initialFilterConfig?: CalculatedFieldsQuery;
}
@Component({
selector: 'tb-calculated-fields-filter-config',
templateUrl: './calculated-fields-filter-config.component.html',
styleUrls: ['./calculated-fields-filter-config.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CalculatedFieldsFilterConfigComponent),
multi: true
}
]
})
export class CalculatedFieldsFilterConfigComponent implements OnInit, ControlValueAccessor {
@ViewChild('calculatedFieldsFilterPanel')
calculatedFieldsFilterPanel: TemplateRef<any>;
@Input()
disabled: boolean;
@coerceBoolean()
@Input()
buttonMode = true;
@coerceBoolean()
@Input()
userMode = false;
@coerceBoolean()
@Input()
propagatedFilter = true;
@Input()
initialCfFilterConfig: CalculatedFieldsQuery = {
name: [],
types: [],
entityType: null,
entities: []
};
panelMode = false;
cfFilterForm: FormGroup;
panelResult: CalculatedFieldsQuery = null;
entityType = EntityType;
listEntityTypes = calculatedFieldsEntityTypeList;
entityTypeTranslations = entityTypeTranslations;
readonly types: StringItemsOption[] = calculatedFieldTypes.map(item => ({
name: this.translate.instant(CalculatedFieldTypeTranslations.get(item).name),
value: item
}));
private cfFilterOverlayRef: OverlayRef;
private cfFilterConfig: CalculatedFieldsQuery;
private resizeWindows: Subscription;
private propagateChange = (_: any) => {};
constructor(@Optional() @Inject(CALCULATED_FIELDS_CONFIG_DATA)
private data: CalculatedFieldsFilterConfigData | undefined,
@Optional() private overlayRef: OverlayRef,
private fb: FormBuilder,
private overlay: Overlay,
private nativeElement: ElementRef,
private viewContainerRef: ViewContainerRef,
private destroyRef: DestroyRef,
private translate: TranslateService) {
}
ngOnInit(): void {
if (this.data) {
this.panelMode = this.data.panelMode;
this.userMode = this.data.userMode;
this.cfFilterConfig = this.data.filterConfig;
this.initialCfFilterConfig = this.data.initialFilterConfig;
if (this.panelMode && !this.initialCfFilterConfig) {
this.initialCfFilterConfig = deepClone(this.cfFilterConfig);
}
}
this.cfFilterForm = this.fb.group({
name: [null, []],
types: [null, []],
entityType: [null, []],
entities: [null, []]
});
this.cfFilterForm.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(
() => {
if (!this.buttonMode) {
this.cfConfigUpdated(this.cfFilterForm.value);
}
}
);
if (this.panelMode) {
this.updateCfConfigForm(this.cfFilterConfig);
}
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(_fn: any): void {
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
if (this.disabled) {
this.cfFilterForm.disable({emitEvent: false});
} else {
this.cfFilterForm.enable({emitEvent: false});
}
}
writeValue(cfFilterConfig?: CalculatedFieldsQuery): void {
this.cfFilterConfig = cfFilterConfig;
if (!this.initialCfFilterConfig && cfFilterConfig) {
this.initialCfFilterConfig = deepClone(cfFilterConfig);
}
this.updateCfConfigForm(cfFilterConfig);
}
toggleCfFilterPanel($event: Event) {
if ($event) {
$event.stopPropagation();
}
const config = new OverlayConfig({
panelClass: 'tb-filter-panel',
backdropClass: 'cdk-overlay-transparent-backdrop',
hasBackdrop: true,
maxHeight: '80vh',
height: 'min-content',
minWidth: ''
});
config.hasBackdrop = true;
config.positionStrategy = this.overlay.position()
.flexibleConnectedTo(this.nativeElement)
.withPositions([POSITION_MAP.bottomLeft]);
this.cfFilterOverlayRef = this.overlay.create(config);
this.cfFilterOverlayRef.backdropClick().subscribe(() => {
this.cfFilterOverlayRef.dispose();
});
this.cfFilterOverlayRef.attach(new TemplatePortal(this.calculatedFieldsFilterPanel,
this.viewContainerRef));
this.resizeWindows = fromEvent(window, 'resize').subscribe(() => {
this.cfFilterOverlayRef.updatePosition();
});
}
cancel() {
this.updateCfConfigForm(this.cfFilterConfig);
if (this.overlayRef) {
this.overlayRef.dispose();
} else {
this.resizeWindows.unsubscribe();
this.cfFilterOverlayRef.dispose();
}
}
update() {
this.cfConfigUpdated(this.cfFilterForm.value);
this.cfFilterForm.markAsPristine();
if (this.panelMode) {
this.panelResult = this.cfFilterConfig;
}
if (this.overlayRef) {
this.overlayRef.dispose();
} else {
this.resizeWindows.unsubscribe();
this.cfFilterOverlayRef.dispose();
}
}
reset() {
const cfFilterConfig = this.cfFilterFromFormValue(this.cfFilterForm.value);
if (!this.cfFilterConfigEquals(cfFilterConfig, this.initialCfFilterConfig)) {
this.updateCfConfigForm(this.initialCfFilterConfig);
this.cfFilterForm.markAsDirty();
}
}
private cfFilterConfigEquals = (filter1?: CalculatedFieldsQuery, filter2?: CalculatedFieldsQuery): boolean => {
if (filter1 === filter2) {
return true;
}
if ((isUndefinedOrNull(filter1) || isEmpty(filter1)) && (isUndefinedOrNull(filter2) || isEmpty(filter2))) {
return true;
} else if (isDefinedAndNotNull(filter1) && isDefinedAndNotNull(filter2)) {
if (!isArraysEqualIgnoreUndefined(filter1.name, filter2.name)) {
return false;
}
if (!isArraysEqualIgnoreUndefined(filter1.types, filter2.types)) {
return false;
}
if (!isArraysEqualIgnoreUndefined(filter1.entities, filter2.entities)) {
return false;
}
return filter1.entityType !== filter2.entityType;
}
return false;
};
private updateCfConfigForm(cfFilterConfig?: CalculatedFieldsQuery) {
this.cfFilterForm.patchValue({
name: cfFilterConfig?.name ?? [],
types: cfFilterConfig?.types ?? [],
entityType: cfFilterConfig?.entityType ?? null,
entities: cfFilterConfig?.entities ?? [],
}, {emitEvent: false});
}
private cfConfigUpdated(formValue: any) {
this.cfFilterConfig = this.cfFilterFromFormValue(formValue);
this.propagateChange(this.cfFilterConfig);
}
private cfFilterFromFormValue(formValue: any): CalculatedFieldsQuery {
return {
name: formValue?.name ?? [],
types: formValue?.types ?? [],
entityType: formValue?.entityType ?? null,
entities: formValue?.entities ?? [],
};
}
}

20
ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-header.component.html

@ -0,0 +1,20 @@
<!--
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-calculated-fields-filter-config [ngModel]="calculatedFieldsTableConfig.calculatedFieldFilterConfig"
(ngModelChange)="calculatedFieldsFilterChanged($event)">
</tb-calculated-fields-filter-config>

21
ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-header.component.scss

@ -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.
*/
:host {
padding-right: 8px;
overflow: hidden;
max-width: 100%;
}

43
ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-header.component.ts

@ -0,0 +1,43 @@
///
/// 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 { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { EntityTableHeaderComponent } from '@home/components/entity/entity-table-header.component';
import { CalculatedField, CalculatedFieldsQuery } from "@shared/models/calculated-field.models";
import { CalculatedFieldsTableConfig } from '@home/components/calculated-fields/calculated-fields-table-config';
@Component({
selector: 'tb-calculated-fields-table-header',
templateUrl: './calculated-fields-header.component.html',
styleUrls: ['./calculated-fields-header.component.scss']
})
export class CalculatedFieldsHeaderComponent extends EntityTableHeaderComponent<CalculatedField> {
get calculatedFieldsTableConfig(): CalculatedFieldsTableConfig {
return this.entitiesTableConfig as CalculatedFieldsTableConfig;
}
constructor(protected store: Store<AppState>) {
super(store);
}
calculatedFieldsFilterChanged(calculatedFieldFilterConfig: CalculatedFieldsQuery) {
this.calculatedFieldsTableConfig.calculatedFieldFilterConfig = calculatedFieldFilterConfig;
this.calculatedFieldsTableConfig.getTable().resetSortAndFilter(true, true);
}
}

15
ui-ngx/src/app/modules/home/pages/alarm/alarm-routing.module.ts

@ -14,28 +14,15 @@
/// limitations under the License.
///
import { Injectable, NgModule } from '@angular/core';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { Authority } from '@shared/models/authority.enum';
import { Observable } from 'rxjs';
import { OAuth2Service } from '@core/http/oauth2.service';
import { AlarmTableComponent } from '@home/components/alarm/alarm-table.component';
import { AlarmsMode } from '@shared/models/alarm.models';
import { MenuId } from '@core/services/menu.models';
import { RouterTabsComponent } from "@home/components/router-tabs.component";
import { AlarmRulesTableComponent } from "@home/components/alarm-rules/alarm-rules-table.component";
@Injectable()
export class OAuth2LoginProcessingUrlResolver {
constructor(private oauth2Service: OAuth2Service) {
}
resolve(): Observable<string> {
return this.oauth2Service.getLoginProcessingUrl();
}
}
const routes: Routes = [
{
path: 'alarms',

43
ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields-routing.module.ts

@ -0,0 +1,43 @@
///
/// 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 { NgModule } from '@angular/core';
import { RouterModule, 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';
const routes: Routes = [
{
path: 'calculatedFields',
component: CalculatedFieldsTableComponent,
data: {
auth: [Authority.TENANT_ADMIN],
title: 'entity.type-calculated-fields',
breadcrumb: {
menuId: MenuId.calculated_fields
},
isPage: true,
}
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
providers: []
})
export class CalculatedFieldsRoutingModule { }

34
ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields.module.ts

@ -0,0 +1,34 @@
///
/// 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 { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SharedModule } from '@shared/shared.module';
import { HomeDialogsModule } from '../../dialogs/home-dialogs.module';
import { HomeComponentsModule } from '@modules/home/components/home-components.module';
import { CalculatedFieldsRoutingModule } from '@home/pages/calculated-fields/calculated-fields-routing.module';
@NgModule({
declarations: [],
imports: [
CommonModule,
SharedModule,
HomeComponentsModule,
HomeDialogsModule,
CalculatedFieldsRoutingModule
]
})
export class CalculatedFieldsModule { }

2
ui-ngx/src/app/modules/home/pages/home-pages.module.ts

@ -27,6 +27,7 @@ import { UserModule } from '@modules/home/pages/user/user.module';
import { DeviceModule } from '@modules/home/pages/device/device.module';
import { AssetModule } from '@modules/home/pages/asset/asset.module';
import { EntityViewModule } from '@modules/home/pages/entity-view/entity-view.module';
import { CalculatedFieldsModule } from '@home/pages/calculated-fields/calculated-fields.module';
import { RuleChainModule } from '@modules/home/pages/rulechain/rulechain.module';
import { WidgetLibraryModule } from '@modules/home/pages/widget/widget-library.module';
import { DashboardModule } from '@modules/home/pages/dashboard/dashboard.module';
@ -69,6 +70,7 @@ import { AiModelModule } from '@home/pages/ai-model/ai-model.module';
EdgeModule,
EntityViewModule,
CustomerModule,
CalculatedFieldsModule,
RuleChainModule,
WidgetLibraryModule,
DashboardModule,

8
ui-ngx/src/app/shared/models/calculated-field.models.ts

@ -119,6 +119,8 @@ export const CalculatedFieldTypeTranslations = new Map<CalculatedFieldType, Calc
]
)
export const calculatedFieldTypes = Object.values(CalculatedFieldType).filter(type => type !== CalculatedFieldType.ALARM)
export type CalculatedFieldConfiguration =
| CalculatedFieldSimpleConfiguration
| CalculatedFieldScriptConfiguration
@ -567,6 +569,8 @@ export type CalculatedFieldArgumentEventValue<ValueType = unknown> = CalculatedF
export type CalculatedFieldEventArguments<ValueType = unknown> = Record<string, CalculatedFieldArgumentEventValue<ValueType>>;
export const calculatedFieldsEntityTypeList = [EntityType.DEVICE, EntityType.ASSET, EntityType.DEVICE_PROFILE, EntityType.ASSET_PROFILE];
export const defaultCalculatedFieldOutput: CalculatedFieldOutputTimeSeries = {
type: OutputType.Timeseries,
strategy: {
@ -1062,8 +1066,8 @@ export function uniqueNameValidator(existingNames: string[]): ValidatorFn {
}
export interface CalculatedFieldsQuery {
type: CalculatedFieldType;
types: Array<CalculatedFieldType>;
entityType?: EntityType;
entities?: Array<string>;
name?: string;
name?: Array<string>;
}

5
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -1220,6 +1220,11 @@
"deduplication-interval": "Deduplication interval",
"deduplication-interval-min": "Deduplication interval should be at least {{ sec }} seconds.",
"deduplication-interval-required": "Deduplication interval is required.",
"calculated-fields-filter": "Calculated fields filter",
"calculated-field-names": "Calculated field names",
"calculated-field-types": "Calculated field types",
"any-type": "Any type",
"all-names": "All",
"metrics": {
"metrics": "Metrics",
"metrics-empty": "At least one metric must be configured.",

Loading…
Cancel
Save