Browse Source

Merge pull request #33 from ArtemDzhereleiko/AD/imp/cf/geofencing

Geofencing Calculated filelds
pull/13857/head
Shvaika Dmytro 9 months ago
committed by GitHub
parent
commit
7f5218b49e
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      ui-ngx/src/app/core/auth/auth.models.ts
  2. 2
      ui-ngx/src/app/core/auth/auth.reducer.ts
  3. 32
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts
  4. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html
  5. 172
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html
  6. 102
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts
  7. 146
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.html
  8. 76
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.scss
  9. 307
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.ts
  10. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.ts
  11. 224
      ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.html
  12. 51
      ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.scss
  13. 291
      ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.ts
  14. 10
      ui-ngx/src/app/modules/home/components/home-components.module.ts
  15. 28
      ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html
  16. 2
      ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts
  17. 3
      ui-ngx/src/app/modules/home/components/rule-node/common/common-rule-node-config.module.ts
  18. 4
      ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.html
  19. 17
      ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.ts
  20. 23
      ui-ngx/src/app/shared/components/time-unit-input.component.html
  21. 6
      ui-ngx/src/app/shared/components/time-unit-input.component.ts
  22. 61
      ui-ngx/src/app/shared/models/calculated-field.models.ts
  23. 3
      ui-ngx/src/app/shared/shared.module.ts
  24. 61
      ui-ngx/src/assets/locale/locale.constant-en_US.json

2
ui-ngx/src/app/core/auth/auth.models.ts

@ -31,6 +31,8 @@ export interface SysParamsState {
maxDebugModeDurationMinutes: number;
maxDataPointsPerRollingArg: number;
maxArgumentsPerCF: number;
minAllowedScheduledUpdateIntervalInSecForCF: number;
maxRelationLevelPerCfArgument: number;
ruleChainDebugPerTenantLimitsConfiguration?: string;
calculatedFieldDebugPerTenantLimitsConfiguration?: string;
trendzSettings: TrendzSettings;

2
ui-ngx/src/app/core/auth/auth.reducer.ts

@ -33,6 +33,8 @@ const emptyUserAuthState: AuthPayload = {
mobileQrEnabled: false,
maxResourceSize: 0,
maxArgumentsPerCF: 0,
minAllowedScheduledUpdateIntervalInSecForCF: 0,
maxRelationLevelPerCfArgument: 0,
maxDataPointsPerRollingArg: 0,
maxDebugModeDurationMinutes: 0,
userSettings: initialUserSettings,

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

@ -113,16 +113,16 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
expressionColumn.sortable = false;
expressionColumn.cellContentFunction = entity => {
const expressionLabel = this.getExpressionLabel(entity);
return expressionLabel.length < 45 ? expressionLabel : `<span style="display: inline-block; width: 45ch">${expressionLabel.substring(0, 44)}…</span>`;
return expressionLabel?.length < 45 ? expressionLabel : `<span style="display: inline-block; width: 45ch">${expressionLabel.substring(0, 44)}…</span>`;
}
expressionColumn.cellTooltipFunction = entity => {
const expressionLabel = this.getExpressionLabel(entity);
return expressionLabel.length < 45 ? null : expressionLabel
return expressionLabel?.length < 45 ? null : expressionLabel
};
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>('type', 'common.type', '50px', entity => this.translate.instant(CalculatedFieldTypeTranslations.get(entity.type))));
this.columns.push(new EntityTableColumn<CalculatedField>('type', 'common.type', '70px', entity => this.translate.instant(CalculatedFieldTypeTranslations.get(entity.type))));
this.columns.push(expressionColumn);
this.cellActionDescriptors.push(
@ -159,7 +159,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
if (entity.type === CalculatedFieldType.SCRIPT) {
return 'function calculate(ctx, ' + Object.keys(entity.configuration.arguments).join(', ') + ')';
} else {
return entity.configuration.expression;
return entity.configuration?.expression ?? '';
}
}
@ -257,13 +257,23 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
}
private updateImportedCalculatedField(calculatedField: CalculatedField): CalculatedField {
calculatedField.configuration.arguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => {
const arg = calculatedField.configuration.arguments[key];
acc[key] = arg.refEntityId?.entityType === ArgumentEntityType.Tenant
? { ...arg, refEntityId: { id: this.tenantId, entityType: ArgumentEntityType.Tenant } }
: arg;
return acc;
}, {});
if (calculatedField.type === CalculatedFieldType.GEOFENCING) {
calculatedField.configuration.zoneGroups = Object.keys(calculatedField.configuration.zoneGroups).reduce((acc, key) => {
const arg = calculatedField.configuration.zoneGroups[key];
acc[key] = arg.refEntityId?.entityType === ArgumentEntityType.Tenant
? { ...arg, refEntityId: { id: this.tenantId, entityType: ArgumentEntityType.Tenant } }
: arg;
return acc;
}, {});
} else {
calculatedField.configuration.arguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => {
const arg = calculatedField.configuration.arguments[key];
acc[key] = arg.refEntityId?.entityType === ArgumentEntityType.Tenant
? { ...arg, refEntityId: { id: this.tenantId, entityType: ArgumentEntityType.Tenant } }
: arg;
return acc;
}, {});
}
return calculatedField;
}

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

@ -59,7 +59,7 @@
<mat-cell *matCellDef="let argument" class="w-1/4 xs:hidden">
<div tbTruncateWithTooltip>
@if (argument.refEntityId?.id && argument.refEntityId?.entityType !== ArgumentEntityType.Tenant) {
<a aria-label="Open entity details page"
<a [attr.aria-label]="'calculated-fields.open-details-page' | translate"
[routerLink]="getEntityDetailsPageURL(argument.refEntityId.id, argument.refEntityId.entityType)">
{{ entityNameMap.get(argument.refEntityId.id) ?? '' }}
</a>

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

@ -63,76 +63,116 @@
</mat-form-field>
</div>
<ng-container [formGroup]="configFormGroup">
<div class="tb-form-panel">
<div class="tb-form-panel-title tb-required">{{ 'calculated-fields.arguments' | translate }}</div>
<tb-calculated-field-arguments-table
formControlName="arguments"
[entityId]="data.entityId"
[tenantId]="data.tenantId"
[entityName]="data.entityName"
[calculatedFieldType]="fieldFormGroup.get('type').value"
/>
</div>
<div class="tb-form-panel no-gap">
<div class="tb-form-panel-title tb-required">
{{ (fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE ? 'calculated-fields.expression' : 'calculated-fields.type.script' ) | translate }}
@if (fieldFormGroup.get('type').value !== CalculatedFieldType.GEOFENCING) {
<div class="tb-form-panel">
<div class="tb-form-panel-title tb-required">{{ 'calculated-fields.arguments' | translate }}</div>
<tb-calculated-field-arguments-table formControlName="arguments"
[entityId]="data.entityId"
[tenantId]="data.tenantId"
[entityName]="data.entityName"
[calculatedFieldType]="fieldFormGroup.get('type').value"/>
</div>
<mat-form-field class="mt-3" appearance="outline" subscriptSizing="dynamic" [class.hidden]="fieldFormGroup.get('type').value !== CalculatedFieldType.SIMPLE">
<input matInput formControlName="expressionSIMPLE" maxlength="255" [placeholder]="'(temperature - 32) / 1.8'" required>
<div matSuffix
class="pr-2"
[tb-help-popup]="'math/math-methods_fn'"
tb-help-popup-placement="left"
[tb-help-popup-style]="{maxWidth: '970px'}">
<div class="tb-form-panel no-gap">
<div class="tb-form-panel-title tb-required">
{{ (fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE ? 'calculated-fields.expression' : 'calculated-fields.type.script' ) | translate }}
</div>
@if (configFormGroup.get('expressionSIMPLE').errors && configFormGroup.get('expressionSIMPLE').touched) {
<mat-error>
@if (configFormGroup.get('expressionSIMPLE').hasError('required')) {
{{ 'calculated-fields.hint.expression-required' | translate }}
} @else if (configFormGroup.get('expressionSIMPLE').hasError('pattern')) {
{{ 'calculated-fields.hint.expression-invalid' | translate }}
} @else if (configFormGroup.get('expressionSIMPLE').hasError('maxLength')) {
{{ 'calculated-fields.hint.expression-max-length' | translate }}
}
</mat-error>
} @else {
<mat-hint>{{ 'calculated-fields.hint.expression' | translate }}</mat-hint>
}
</mat-form-field>
<div [class.hidden]="fieldFormGroup.get('type').value !== CalculatedFieldType.SCRIPT">
<tb-js-func
required
formControlName="expressionSCRIPT"
functionName="calculate"
[functionArgs]="functionArgs$ | async"
[disableUndefinedCheck]="true"
[scriptLanguage]="ScriptLanguage.TBEL"
[highlightRules]="argumentsHighlightRules$ | async"
[editorCompleter]="argumentsEditorCompleter$ | async"
[helpPopupStyle]="{ width: '1200px' }"
helpId="calculated-field/expression_fn"
>
<div toolbarPrefixButton class="tb-primary-background tbel-script-lang-chip">{{ 'api-usage.tbel' | translate }}</div>
<button toolbarSuffixButton
mat-icon-button
matTooltip="{{ 'calculated-fields.test-script-function' | translate }}"
matTooltipPosition="above"
class="tb-mat-32"
[disabled]="configFormGroup.get('arguments').invalid"
(click)="onTestScript()">
<mat-icon class="material-icons" color="primary">bug_report</mat-icon>
</button>
</tb-js-func>
<div>
<button mat-button mat-raised-button color="primary"
type="button"
(click)="onTestScript()"
[disabled]="configFormGroup.get('arguments').invalid">
{{ 'calculated-fields.test-script-function' | translate }}
</button>
<mat-form-field class="mt-3" appearance="outline" subscriptSizing="dynamic" [class.hidden]="fieldFormGroup.get('type').value !== CalculatedFieldType.SIMPLE">
<input matInput formControlName="expressionSIMPLE" maxlength="255" [placeholder]="'(temperature - 32) / 1.8'" required>
<div matSuffix
class="pr-2"
[tb-help-popup]="'math/math-methods_fn'"
tb-help-popup-placement="left"
[tb-help-popup-style]="{maxWidth: '970px'}">
</div>
@if (configFormGroup.get('expressionSIMPLE').errors && configFormGroup.get('expressionSIMPLE').touched) {
<mat-error>
@if (configFormGroup.get('expressionSIMPLE').hasError('required')) {
{{ 'calculated-fields.hint.expression-required' | translate }}
} @else if (configFormGroup.get('expressionSIMPLE').hasError('pattern')) {
{{ 'calculated-fields.hint.expression-invalid' | translate }}
} @else if (configFormGroup.get('expressionSIMPLE').hasError('maxLength')) {
{{ 'calculated-fields.hint.expression-max-length' | translate }}
}
</mat-error>
} @else {
<mat-hint>{{ 'calculated-fields.hint.expression' | translate }}</mat-hint>
}
</mat-form-field>
<div [class.hidden]="fieldFormGroup.get('type').value !== CalculatedFieldType.SCRIPT">
<tb-js-func required
formControlName="expressionSCRIPT"
functionName="calculate"
[functionArgs]="functionArgs$ | async"
[disableUndefinedCheck]="true"
[scriptLanguage]="ScriptLanguage.TBEL"
[highlightRules]="argumentsHighlightRules$ | async"
[editorCompleter]="argumentsEditorCompleter$ | async"
[helpPopupStyle]="{ width: '1200px' }"
helpId="calculated-field/expression_fn">
<div toolbarPrefixButton class="tb-primary-background tbel-script-lang-chip">{{ 'api-usage.tbel' | translate }}</div>
<button toolbarSuffixButton
mat-icon-button
matTooltip="{{ 'calculated-fields.test-script-function' | translate }}"
matTooltipPosition="above"
class="tb-mat-32"
[disabled]="configFormGroup.get('arguments').invalid"
(click)="onTestScript()">
<mat-icon class="material-icons" color="primary">bug_report</mat-icon>
</button>
</tb-js-func>
<div>
<button mat-button mat-raised-button color="primary"
type="button"
(click)="onTestScript()"
[disabled]="configFormGroup.get('arguments').invalid">
{{ 'calculated-fields.test-script-function' | translate }}
</button>
</div>
</div>
</div>
</div>
} @else {
<div class="tb-form-panel">
<div class="tb-form-panel-title tb-required" tb-hint-tooltip-icon="{{ 'calculated-fields.hint.entity-coordinates' | translate }}">
{{ 'calculated-fields.entity-coordinates' | translate }}
</div>
<div class="flex items-start gap-3" [formGroup]="coordinatesFormGroup">
<tb-entity-key-autocomplete class="flex-1"
placeholder="{{ 'calculated-fields.latitude-time-series-key' | translate }}"
requiredText="{{ 'calculated-fields.latitude-time-series-key-required' | translate }}"
formControlName="latitudeKeyName"
[dataKeyType]="DataKeyType.timeseries"
[entityFilter]="currentEntityFilter"/>
<tb-entity-key-autocomplete class="flex-1"
placeholder="{{ 'calculated-fields.longitude-time-series-key' | translate }}"
requiredText="{{ 'calculated-fields.longitude-time-series-key-required' | translate }}"
formControlName="longitudeKeyName"
[dataKeyType]="DataKeyType.timeseries"
[entityFilter]="currentEntityFilter"/>
</div>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title tb-required" tb-hint-tooltip-icon="{{ 'calculated-fields.hint.geofencing-zone-groups' | translate }}">
{{ 'calculated-fields.geofencing-zone-groups' | translate }}
</div>
<tb-calculated-field-geofencing-zone-groups-table formControlName="zoneGroups"
[entityId]="data.entityId"
[tenantId]="data.tenantId"
[entityName]="data.entityName"/>
<div class="tb-form-row space-between flex-1 columns-xs" [class.!hidden]="!isRelatedEntity">
<div tb-hint-tooltip-icon="{{'calculated-fields.hint.zone-group-refresh-interval' | translate}}">{{ 'calculated-fields.zone-group-refresh-interval' | translate }}</div>
<div class="flex flex-row items-center justify-start gap-2">
<tb-time-unit-input required
inlineField
requiredText="{{ 'calculated-fields.hint.zone-group-refresh-interval-required' | translate }}"
minErrorText="{{ 'calculated-fields.hint.zone-group-refresh-interval-min' | translate }}"
[minTime]="minAllowedScheduledUpdateIntervalInSecForCF"
formControlName="scheduledUpdateInterval">
</tb-time-unit-input>
</div>
</div>
</div>
}
<div class="tb-form-panel" [formGroup]="outputFormGroup">
<div class="tb-form-panel-title">{{ 'calculated-fields.output' | translate }}</div>
<div class="flex items-center gap-3">

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

@ -22,19 +22,22 @@ import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { DialogComponent } from '@shared/components/dialog.component';
import {
ArgumentEntityType,
CalculatedField,
CalculatedFieldConfiguration,
calculatedFieldDefaultScript,
CalculatedFieldGeofencing,
CalculatedFieldTestScriptFn,
CalculatedFieldType,
CalculatedFieldTypeTranslations,
getCalculatedFieldArgumentsEditorCompleter,
getCalculatedFieldArgumentsHighlights,
getCalculatedFieldCurrentEntityFilter,
OutputType,
OutputTypeTranslations
} from '@shared/models/calculated-field.models';
import { digitsRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants';
import { AttributeScope } from '@shared/models/telemetry/telemetry.models';
import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { EntityType } from '@shared/models/entity-type.models';
import { map, startWith, switchMap } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@ -43,6 +46,8 @@ import { CalculatedFieldsService } from '@core/http/calculated-fields.service';
import { Observable } from 'rxjs';
import { EntityId } from '@shared/models/id/entity-id';
import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model';
import { EntityFilter } from '@shared/models/query/query.models';
import { getCurrentAuthState } from '@core/auth/auth.selectors';
export interface CalculatedFieldDialogData {
value?: CalculatedField;
@ -63,12 +68,20 @@ export interface CalculatedFieldDialogData {
})
export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFieldDialogComponent, CalculatedField> {
readonly minAllowedScheduledUpdateIntervalInSecForCF = getCurrentAuthState(this.store).minAllowedScheduledUpdateIntervalInSecForCF;
fieldFormGroup = this.fb.group({
name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]],
type: [CalculatedFieldType.SIMPLE],
debugSettings: [],
configuration: this.fb.group({
entityCoordinates: this.fb.group({
latitudeKeyName: [null, [Validators.required]],
longitudeKeyName: [null, [Validators.required]],
}),
arguments: this.fb.control({}),
zoneGroups: this.fb.control({}),
scheduledUpdateInterval: [this.minAllowedScheduledUpdateIntervalInSecForCF],
expressionSIMPLE: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]],
expressionSCRIPT: [calculatedFieldDefaultScript],
output: this.fb.group({
@ -104,6 +117,10 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
action: () => this.data.additionalDebugActionConfig.action({ id: this.data.value.id, ...this.fromGroupValue }),
} : null;
currentEntityFilter: EntityFilter;
isRelatedEntity: boolean;
readonly OutputTypeTranslations = OutputTypeTranslations;
readonly OutputType = OutputType;
readonly AttributeScope = AttributeScope;
@ -113,6 +130,7 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
readonly fieldTypes = Object.values(CalculatedFieldType) as CalculatedFieldType[];
readonly outputTypes = Object.values(OutputType) as OutputType[];
readonly CalculatedFieldTypeTranslations = CalculatedFieldTypeTranslations;
readonly DataKeyType = DataKeyType;
constructor(protected store: Store<AppState>,
protected router: Router,
@ -125,6 +143,8 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
this.observeIsLoading();
this.applyDialogData();
this.observeTypeChanges();
this.observeZoneChanges();
this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.data.entityName, this.data.entityId);
}
get configFormGroup(): FormGroup {
@ -135,19 +155,34 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
return this.fieldFormGroup.get('configuration').get('output') as FormGroup;
}
get coordinatesFormGroup(): FormGroup {
return this.fieldFormGroup.get('configuration').get('entityCoordinates') as FormGroup;
}
get fromGroupValue(): CalculatedField {
const { configuration, type, name, ...rest } = this.fieldFormGroup.value;
const { expressionSIMPLE, expressionSCRIPT, output, ...restConfig } = configuration;
return {
configuration: {
...restConfig,
type, expression: configuration['expression'+type].trim(),
output: { ...output, name: output.name?.trim() ?? '' }
},
let cf: CalculatedField = {
name: name.trim(),
type,
...rest,
...rest
} as CalculatedField;
if (type !== CalculatedFieldType.GEOFENCING) {
cf.configuration = {
...restConfig,
type,
expression: configuration['expression'+type].trim(),
output: { ...output, name: output.name?.trim() ?? '' }
} as CalculatedFieldConfiguration;
} else {
cf.configuration = {
...restConfig,
type,
output: { ...output, name: output.name?.trim() ?? '' }
} as CalculatedFieldConfiguration;
delete cf.configuration.arguments;
}
return cf;
}
cancel(): void {
@ -204,6 +239,19 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
.subscribe(type => this.toggleKeyByCalculatedFieldType(type));
}
private observeZoneChanges(): void {
this.configFormGroup.get('zoneGroups').valueChanges
.pipe(takeUntilDestroyed())
.subscribe((zoneGroups: CalculatedFieldGeofencing) =>
this.checkRelatedEntity(zoneGroups)
);
this.checkRelatedEntity(this.configFormGroup.get('zoneGroups').value);
}
private checkRelatedEntity(zoneGroups: CalculatedFieldGeofencing) {
this.isRelatedEntity = Object.values(zoneGroups).some(zone => zone.refDynamicSourceConfiguration?.type === ArgumentEntityType.RelationQuery);
}
private toggleScopeByOutputType(type: OutputType): void {
if (type === OutputType.Attribute) {
this.outputFormGroup.get('scope').enable({emitEvent: false});
@ -222,20 +270,36 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
}
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});
if (this.outputFormGroup.get('type').value === OutputType.Attribute) {
this.configFormGroup.get('useLatestTs').disable({emitEvent: false});
} else {
this.configFormGroup.get('useLatestTs').enable({emitEvent: false});
}
} else {
if (type === CalculatedFieldType.GEOFENCING) {
this.configFormGroup.get('entityCoordinates').enable({emitEvent: false});
this.configFormGroup.get('zoneGroups').enable({emitEvent: false});
this.configFormGroup.get('scheduledUpdateInterval').enable({emitEvent: false});
this.outputFormGroup.get('name').disable({emitEvent: false});
this.configFormGroup.get('useLatestTs').disable({emitEvent: false});
this.configFormGroup.get('expressionSIMPLE').disable({emitEvent: false});
this.configFormGroup.get('expressionSCRIPT').enable({emitEvent: false});
this.configFormGroup.get('expressionSCRIPT').disable({emitEvent: false});
this.configFormGroup.get('arguments').disable({emitEvent: false});
} else {
this.configFormGroup.get('entityCoordinates').disable({emitEvent: false});
this.configFormGroup.get('zoneGroups').disable({emitEvent: false});
this.configFormGroup.get('scheduledUpdateInterval').disable({emitEvent: false});
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});
if (this.outputFormGroup.get('type').value === OutputType.Attribute) {
this.configFormGroup.get('useLatestTs').disable({emitEvent: false});
} else {
this.configFormGroup.get('useLatestTs').enable({emitEvent: false});
}
} else {
this.outputFormGroup.get('name').disable({emitEvent: false});
this.configFormGroup.get('useLatestTs').disable({emitEvent: false});
this.configFormGroup.get('expressionSIMPLE').disable({emitEvent: false});
this.configFormGroup.get('expressionSCRIPT').enable({emitEvent: false});
}
}
}

146
ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.html

@ -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.
-->
<div class="flex flex-col gap-3">
<div class="tb-form-panel stroked no-padding no-gap arguments-table flex flex-col" [class.arguments-table-with-error]="errorText">
<table mat-table [dataSource]="dataSource" class="overflow-hidden bg-transparent" matSort
[matSortActive]="sortOrder.property" [matSortDirection]="sortOrder.direction" matSortDisableClear>
<ng-container [matColumnDef]="'name'">
<mat-header-cell mat-sort-header *matHeaderCellDef class="!w-1/3 xs:!w-1/2">
<div tbTruncateWithTooltip>{{ 'common.name' | translate }}</div>
</mat-header-cell>
<mat-cell *matCellDef="let geofenceZone" class="argument-name-cell w-1/3 xs:w-1/2">
<div class="flex items-center">
<div tbTruncateWithTooltip class="flex-1">{{ geofenceZone.name }}</div>
<tb-copy-button class="copy-argument-name"
[copyText]="geofenceZone.name"
tooltipText="{{ 'calculated-fields.copy-zone-group-name' | translate }}"
tooltipPosition="above"
icon="content_copy"/>
</div>
</mat-cell>
</ng-container>
<ng-container [matColumnDef]="'entityType'">
<mat-header-cell mat-sort-header *matHeaderCellDef class="entity-type-header w-1/5 xs:hidden">
{{ 'entity.entity-type' | translate }}
</mat-header-cell>
<mat-cell *matCellDef="let geofenceZone" class="w-1/5 xs:hidden">
<div tbTruncateWithTooltip>
@if (geofenceZone.refEntityId?.entityType === ArgumentEntityType.Tenant) {
{{ 'calculated-fields.argument-current-tenant' | translate }}
} @else if (geofenceZone.refDynamicSourceConfiguration?.type === ArgumentEntityType.RelationQuery) {
{{ 'calculated-fields.argument-relation-query' | translate }}
} @else if (geofenceZone.refEntityId?.id) {
{{ entityTypeTranslations.get(geofenceZone.refEntityId.entityType).type | translate }}
} @else {
{{ 'calculated-fields.argument-current' | translate }}
}
</div>
</mat-cell>
</ng-container>
<ng-container [matColumnDef]="'target'">
<mat-header-cell *matHeaderCellDef class="w-1/4 xs:hidden">
{{ 'calculated-fields.target-zone' | translate }}
</mat-header-cell>
<mat-cell *matCellDef="let geofenceZone" class="w-1/4 xs:hidden">
<div tbTruncateWithTooltip>
@if (geofenceZone.refEntityId?.id && geofenceZone.refEntityId?.entityType !== ArgumentEntityType.Tenant) {
<a [attr.aria-label]="'calculated-fields.open-details-page' | translate"
[routerLink]="getEntityDetailsPageURL(geofenceZone.refEntityId.id, geofenceZone.refEntityId.entityType)">
{{ entityNameMap.get(geofenceZone.refEntityId.id) ?? '' }}
</a>
}
</div>
</mat-cell>
</ng-container>
<ng-container [matColumnDef]="'key'">
<mat-header-cell mat-sort-header *matHeaderCellDef class="w-1/4 xs:w-1/3">
{{ 'calculated-fields.perimeter-key' | translate }}
</mat-header-cell>
<mat-cell *matCellDef="let geofenceZone" class="w-1/4 xs:w-1/3">
<mat-chip class="tb-chip-row-ellipsis">
<div tbTruncateWithTooltip class="key-text">{{ geofenceZone.perimeterKeyName }}</div>
</mat-chip>
</mat-cell>
</ng-container>
<ng-container [matColumnDef]="'reportStrategy'">
<mat-header-cell mat-sort-header *matHeaderCellDef class="w-1/4 lt-md:hidden">
{{ 'calculated-fields.report-strategy' | translate }}
</mat-header-cell>
<mat-cell *matCellDef="let geofenceZone" class="w-1/4 lt-md:hidden">
<div tbTruncateWithTooltip>{{ GeofencingReportStrategyTranslations.get(geofenceZone.reportStrategy) | translate }}</div>
</mat-cell>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<mat-header-cell *matHeaderCellDef class="w-20 min-w-20"/>
<mat-cell *matCellDef="let geofenceZone;">
<div class="tb-form-table-row-cell-buttons flex w-20 min-w-20">
<button type="button"
mat-icon-button
#button
(click)="manageZone($event, button, geofenceZone)"
[matTooltip]="'action.edit' | translate"
matTooltipPosition="above">
<mat-icon [matBadgeHidden]="geofenceZone.refEntityId?.id !== NULL_UUID"
matBadgeColor="warn"
matBadgeSize="small"
matBadge="*">
edit
</mat-icon>
</button>
<button type="button"
mat-icon-button
(click)="onDelete($event, geofenceZone)"
[matTooltip]="'action.delete' | translate"
matTooltipPosition="above">
<mat-icon>delete</mat-icon>
</button>
</div>
</mat-cell>
</ng-container>
<mat-header-row class="mat-row-select"
*matHeaderRowDef="['name', 'entityType', 'target', 'key', 'reportStrategy', 'actions']"></mat-header-row>
<mat-row *matRowDef="let argument; columns: ['name', 'entityType', 'target', 'key', 'reportStrategy', 'actions']"></mat-row>
</table>
<div [class.!hidden]="(dataSource.isEmpty() | async) === false"
class="tb-prompt flex flex-1 items-end justify-center">
{{ 'calculated-fields.no-zone-configured' | translate }}
</div>
@if (errorText) {
<tb-error noMargin [error]="errorText | translate" class="flex h-9 items-center pl-3"/>
}
</div>
<div class="flex h-9 justify-between">
<button type="button"
mat-stroked-button
color="primary"
#button
(click)="manageZone($event, button)"
[disabled]="maxArgumentsPerCF > 0 && zoneGroupsFormArray.length >= maxArgumentsPerCF">
{{ 'calculated-fields.add-zone-group' | translate }}
</button>
@if (maxArgumentsPerCF && zoneGroupsFormArray.length >= maxArgumentsPerCF) {
<div class="tb-form-hint tb-primary-fill max-args-warning flex items-center gap-2">
<mat-icon>warning</mat-icon>
<span>{{ 'calculated-fields.hint.max-geofencing-zone' | translate }}</span>
</div>
}
</div>
</div>

76
ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.scss

@ -0,0 +1,76 @@
/**
* 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;
}
.copy-argument-name {
visibility: hidden;
transition: visibility 0.1s;
}
.argument-name-cell:hover {
.copy-argument-name {
visibility: visible;
}
}
}
.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 {
.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;
}
}
.copy-argument-name {
.mat-icon {
font-size: 16px;
padding: 4px;
}
}
}

307
ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.ts

@ -0,0 +1,307 @@
///
/// 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,
Renderer2,
ViewChild,
ViewContainerRef,
} from '@angular/core';
import {
ControlValueAccessor,
FormBuilder,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
} from '@angular/forms';
import {
ArgumentEntityType,
CalculatedFieldGeofencing,
CalculatedFieldGeofencingValue,
CalculatedFieldType,
GeofencingReportStrategyTranslations,
} from '@shared/models/calculated-field.models';
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, isEqual } 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';
import { forkJoin, Observable } from 'rxjs';
import { NULL_UUID } from '@shared/models/id/has-uuid';
import { BaseData } from '@shared/models/base-data';
import {
CalculatedFieldGeofencingZoneGroupsPanelComponent
} from '@home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component';
@Component({
selector: 'tb-calculated-field-geofencing-zone-groups-table',
templateUrl: './calculated-field-geofencing-zone-groups-table.component.html',
styleUrls: [`calculated-field-geofencing-zone-groups-table.component.scss`],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CalculatedFieldGeofencingZoneGroupsTableComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => CalculatedFieldGeofencingZoneGroupsTableComponent),
multi: true
}
],
})
export class CalculatedFieldGeofencingZoneGroupsTableComponent implements ControlValueAccessor, Validator, AfterViewInit {
@Input() entityId: EntityId;
@Input() tenantId: string;
@Input() entityName: string;
@ViewChild(MatSort, { static: true }) sort: MatSort;
errorText = '';
zoneGroupsFormArray = this.fb.array<CalculatedFieldGeofencingValue>([]);
entityNameMap = new Map<string, string>();
sortOrder = { direction: 'asc', property: '' };
dataSource = new CalculatedFieldZoneDatasource();
readonly GeofencingReportStrategyTranslations = GeofencingReportStrategyTranslations;
readonly entityTypeTranslations = entityTypeTranslations;
readonly ArgumentEntityType = ArgumentEntityType;
readonly maxArgumentsPerCF = getCurrentAuthState(this.store).maxArgumentsPerCF - 2;
readonly NULL_UUID = NULL_UUID;
private popoverComponent: TbPopoverComponent<CalculatedFieldGeofencingZoneGroupsPanelComponent>;
private propagateChange: (zonesObj: Record<string, CalculatedFieldGeofencing>) => 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<AppState>
) {
this.zoneGroupsFormArray.valueChanges.pipe(takeUntilDestroyed()).subscribe(value => {
this.updateDataSource(value);
this.propagateChange(this.getZonesObject(value));
});
}
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.zoneGroupsFormArray.value);
});
}
registerOnChange(fn: (zonesObj: Record<string, CalculatedFieldGeofencing>) => void): void {
this.propagateChange = fn;
}
registerOnTouched(_): void {}
validate(): ValidationErrors | null {
this.updateErrorText();
return this.errorText ? { zonesFormArray: false } : null;
}
onDelete($event: Event, zone: CalculatedFieldGeofencingValue): void {
$event.stopPropagation();
const index = this.zoneGroupsFormArray.controls.findIndex(control => isEqual(control.value, zone));
this.zoneGroupsFormArray.removeAt(index);
this.zoneGroupsFormArray.markAsDirty();
}
manageZone($event: Event, matButton: MatButton, zone = {} as CalculatedFieldGeofencingValue): 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 index = this.zoneGroupsFormArray.controls.findIndex(control => isEqual(control.value, zone));
const isExists = index !== -1;
const ctx = {
index,
zone,
entityId: this.entityId,
calculatedFieldType: CalculatedFieldType.GEOFENCING,
buttonTitle: isExists ? 'action.apply' : 'action.add',
tenantId: this.tenantId,
entityName: this.entityName,
usedNames: this.zoneGroupsFormArray.value.map(({ name }) => name).filter(name => name !== zone.name),
};
this.popoverComponent = this.popoverService.displayPopover({
trigger,
renderer: this.renderer,
componentType: CalculatedFieldGeofencingZoneGroupsPanelComponent,
hostView: this.viewContainerRef,
preferredPlacement: isExists ? ['left', 'leftTop', 'leftBottom'] : ['topRight', 'right', 'rightTop'],
context: ctx,
isModal: true
});
this.popoverComponent.tbComponentRef.instance.geofencingDataApplied.subscribe(({ entityName, ...value }) => {
this.popoverComponent.hide();
if (entityName) {
this.entityNameMap.set(value.refEntityId.id, entityName);
}
if (isExists) {
this.zoneGroupsFormArray.at(index).setValue(value);
} else {
this.zoneGroupsFormArray.push(this.fb.control(value));
}
this.cd.markForCheck();
});
}
}
private updateDataSource(value: CalculatedFieldGeofencingValue[]): void {
const sortedValue = this.sortData(value);
this.dataSource.loadData(sortedValue);
}
private updateErrorText(): void {
if (this.zoneGroupsFormArray.controls.some(control => control.value.refEntityId?.id === NULL_UUID)) {
this.errorText = 'calculated-fields.hint.geofencing-entity-not-found';
} else if (!this.zoneGroupsFormArray.controls.length) {
this.errorText = 'calculated-fields.hint.geofencing-empty';
} else {
this.errorText = '';
}
}
private getZonesObject(value: CalculatedFieldGeofencingValue[]): Record<string, CalculatedFieldGeofencing> {
return value.reduce((acc, zoneValue) => {
const { name, ...zone } = zoneValue as CalculatedFieldGeofencingValue;
acc[name] = zone;
return acc;
}, {} as Record<string, CalculatedFieldGeofencing>);
}
writeValue(zonesObj: Record<string, CalculatedFieldGeofencing>): void {
this.zoneGroupsFormArray.clear();
this.populateZonesFormArray(zonesObj);
this.updateEntityNameMap(this.zoneGroupsFormArray.value);
}
getEntityDetailsPageURL(id: string, type: EntityType): string {
return getEntityDetailsPageURL(id, type);
}
private populateZonesFormArray(zonesObj: Record<string, CalculatedFieldGeofencing>): void {
Object.keys(zonesObj).forEach(key => {
const value: CalculatedFieldGeofencingValue = {
...zonesObj[key],
name: key
};
this.zoneGroupsFormArray.push(this.fb.control(value), { emitEvent: false });
});
this.zoneGroupsFormArray.updateValueAndValidity();
}
private updateEntityNameMap(values: CalculatedFieldGeofencingValue[]): void {
const entitiesByType = values.reduce((acc, { refEntityId = {}}) => {
if (refEntityId.id && refEntityId.entityType !== ArgumentEntityType.Tenant) {
const { id, entityType } = refEntityId as EntityId;
acc[entityType] = acc[entityType] ?? [];
acc[entityType].push(id);
}
return acc;
}, {} as Record<EntityType, string[]>);
const tasks = Object.entries(entitiesByType).map(([entityType, ids]) =>
this.entityService.getEntities(entityType as EntityType, ids)
);
if (!tasks.length) {
return;
}
this.fetchEntityNames(tasks, values);
}
private fetchEntityNames(tasks: Observable<BaseData<EntityId>[]>[], values: CalculatedFieldGeofencingValue[]): void {
forkJoin(tasks as Observable<BaseData<EntityId>[]>[])
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((result: Array<BaseData<EntityId>>[]) => {
result.forEach((entities: BaseData<EntityId>[]) => entities.forEach((entity: BaseData<EntityId>) => this.entityNameMap.set(entity.id.id, entity.name)));
let updateTable = false;
values.forEach(({ refEntityId }) => {
if (refEntityId?.id && !this.entityNameMap.has(refEntityId.id) && refEntityId.entityType !== ArgumentEntityType.Tenant) {
updateTable = true;
const control = this.zoneGroupsFormArray.controls.find(control => control.value.refEntityId?.id === refEntityId.id);
const value = control.value;
value.refEntityId.id = NULL_UUID;
control.setValue(value, { emitEvent: false });
}
});
if (updateTable) {
this.zoneGroupsFormArray.updateValueAndValidity();
}
});
}
private getSortValue(zone: CalculatedFieldGeofencingValue, column: string): string {
switch (column) {
case 'entityType':
if (zone.refEntityId?.entityType === ArgumentEntityType.Tenant) {
return 'calculated-fields.argument-current-tenant';
} else if (zone.refDynamicSourceConfiguration.type === ArgumentEntityType.RelationQuery) {
return 'calculated-fields.argument-relation-query';
} else if (zone.refEntityId?.id) {
return entityTypeTranslations.get((zone.refEntityId)?.entityType as unknown as EntityType).type;
} else {
return 'calculated-fields.argument-current';
}
case 'key':
return zone.perimeterKeyName;
case 'reportStrategy':
return GeofencingReportStrategyTranslations.get(zone.reportStrategy);
default:
return zone.name;
}
}
private sortData(data: CalculatedFieldGeofencingValue[]): CalculatedFieldGeofencingValue[] {
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 CalculatedFieldZoneDatasource extends TbTableDatasource<CalculatedFieldGeofencingValue> {
constructor() {
super();
}
}

2
ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.ts

@ -86,7 +86,7 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
entityFilter: EntityFilter;
entityNameSubject = new BehaviorSubject<string>(null);
readonly argumentEntityTypes = Object.values(ArgumentEntityType) as ArgumentEntityType[];
readonly argumentEntityTypes = Object.values(ArgumentEntityType).filter(value => value !== ArgumentEntityType.RelationQuery) as ArgumentEntityType[];
readonly ArgumentEntityTypeTranslations = ArgumentEntityTypeTranslations;
readonly ArgumentType = ArgumentType;
readonly DataKeyType = DataKeyType;

224
ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.html

@ -0,0 +1,224 @@
<!--
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.
-->
<div class="w-full max-w-xl" [formGroup]="geofencingFormGroup">
<div class="tb-form-panel no-border no-padding mb-2">
<div class="tb-form-panel-title">{{ 'calculated-fields.geofencing-zone-groups-settings' | translate }}</div>
<div class="tb-form-panel no-border no-padding">
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.name' | translate }}</div>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<input matInput autocomplete="new-name" name="value" formControlName="name" maxlength="255" placeholder="{{ 'action.set' | translate }}"/>
@if (geofencingFormGroup.get('name').touched && geofencingFormGroup.get('name').hasError('required')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.name-required' | translate"
class="tb-error">
warning
</mat-icon>
} @else if (geofencingFormGroup.get('name').touched && geofencingFormGroup.get('name').hasError('duplicateName')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.name-duplicate' | translate"
class="tb-error">
warning
</mat-icon>
} @else if (geofencingFormGroup.get('name').touched && geofencingFormGroup.get('name').hasError('pattern')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.name-pattern' | translate"
class="tb-error">
warning
</mat-icon>
} @else if (geofencingFormGroup.get('name').touched && geofencingFormGroup.get('name').hasError('maxlength')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.name-max-length' | translate"
class="tb-error">
warning
</mat-icon>
} @else if (geofencingFormGroup.get('name').touched && geofencingFormGroup.get('name').hasError('forbiddenName')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.name-forbidden' | translate"
class="tb-error">
warning
</mat-icon>
}
</mat-form-field>
</div>
<ng-container [formGroup]="refEntityIdFormGroup">
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'entity.entity-type' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="entityType">
@for (type of argumentEntityTypes; track type) {
<mat-option [value]="type">{{ ArgumentEntityTypeTranslations.get(type) | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
@if (ArgumentEntityTypeParamsMap.has(entityType)) {
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ ArgumentEntityTypeParamsMap.get(entityType).title | translate }}</div>
<tb-entity-autocomplete class="flex flex-1"
#entityAutocomplete
formControlName="id"
inlineField
[placeholder]="'action.set' | translate"
[required]="true"
[entityType]="ArgumentEntityTypeParamsMap.get(entityType).entityType"
(entityChanged)="entityNameSubject.next($event?.name)"/>
</div>
}
</ng-container>
<ng-container [formGroup]="refDynamicSourceFormGroup">
<div class="tb-form-panel stroked" *ngIf="entityType === ArgumentEntityType.RelationQuery">
<mat-expansion-panel class="tb-settings" expanded>
<mat-expansion-panel-header>{{ 'calculated-fields.relation-query' | translate }}*</mat-expansion-panel-header>
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'calculated-fields.direction' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="direction">
@for (direction of GeofencingDirectionList; track direction) {
<mat-option [value]="direction">{{ GeofencingDirectionTranslations.get(direction) | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.relation-type' | translate }}</div>
<tb-string-autocomplete [fetchOptionsFn]="fetchOptions.bind(this)"
additionalClass="tb-suffix-show-on-hover"
class="flex-1"
appearance="outline"
panelWidth=""
required
[errorText]="'calculated-fields.hint.relation-type-required' | translate"
formControlName="relationType">
</tb-string-autocomplete>
</div>
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.relation-level' | translate }}</div>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<input matInput type="number" step="1" min="0" formControlName="maxLevel" placeholder="{{ 'action.set' | translate }}"/>
@if (refDynamicSourceFormGroup.get('maxLevel').touched && refDynamicSourceFormGroup.get('maxLevel').hasError('required')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.relation-level-required' | translate"
class="tb-error">
warning
</mat-icon>
} @else if (refDynamicSourceFormGroup.get('maxLevel').touched && refDynamicSourceFormGroup.get('maxLevel').hasError('min')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.relation-level-min' | translate"
class="tb-error">
warning
</mat-icon>
} @else if (refDynamicSourceFormGroup.get('maxLevel').touched && refDynamicSourceFormGroup.get('maxLevel').hasError('max')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.relation-level-max' | translate: {max: maxRelationLevelPerCfArgument}"
class="tb-error">
warning
</mat-icon>
}
</mat-form-field>
</div>
<div class="tb-form-row" [class.!hidden]="!(this.refDynamicSourceFormGroup.get('maxLevel').value > 1)">
<mat-slide-toggle class="mat-slide margin" formControlName="fetchLastLevelOnly">
{{ 'calculated-fields.fetch-last-available-level' | translate }}
</mat-slide-toggle>
</div>
</mat-expansion-panel>
</div>
</ng-container>
<ng-container>
<div class="tb-form-row">
<div class="fixed-title-width tb-required" tb-hint-tooltip-icon="{{'calculated-fields.hint.perimeter-attribute-key' | translate}}">
{{ 'calculated-fields.perimeter-attribute-key' | translate }}
</div>
<tb-entity-key-autocomplete class="flex-1" formControlName="perimeterKeyName" [dataKeyType]="DataKeyType.attribute" [entityFilter]="entityFilter"/>
</div>
<div class="tb-form-row">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'calculated-fields.hint.report-strategy' | translate}}">{{ 'calculated-fields.report-strategy' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="reportStrategy">
@for (strategy of GeofencingReportStrategyList; track strategy) {
<mat-option [value]="strategy">{{ GeofencingReportStrategyTranslations.get(strategy) | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</ng-container>
<div class="tb-form-panel stroked">
<mat-slide-toggle class="mat-slide" formControlName="createRelationsWithMatchedZones" (click)="$event.stopPropagation()">
<div tb-hint-tooltip-icon="{{ 'calculated-fields.hint.create-relation-with-matched-zones' | translate }}">
{{ 'calculated-fields.create-relation-with-matched-zones' | translate }}
</div>
</mat-slide-toggle>
<div class="tb-form-row" [class.!hidden]="!geofencingFormGroup.get('createRelationsWithMatchedZones').value">
<div class="fixed-title-width">{{ 'calculated-fields.direction' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="direction">
@for (direction of GeofencingDirectionList; track direction) {
<mat-option [value]="direction">{{ GeofencingDirectionTranslations.get(direction) | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="tb-form-row" [class.!hidden]="!geofencingFormGroup.get('createRelationsWithMatchedZones').value">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.relation-type' | translate }}</div>
<tb-string-autocomplete [fetchOptionsFn]="fetchOptions.bind(this)"
additionalClass="tb-suffix-show-on-hover"
class="flex-1"
appearance="outline"
panelWidth=""
required
[errorText]="'calculated-fields.hint.relation-type-required' | translate"
formControlName="relationType">
</tb-string-autocomplete>
</div>
</div>
</div>
</div>
<div class="flex justify-end gap-2">
<button mat-button
color="primary"
type="button"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button
color="primary"
type="button"
(click)="saveZone()"
[disabled]="geofencingFormGroup.invalid || !geofencingFormGroup.dirty">
{{ buttonTitle | translate }}
</button>
</div>
</div>

51
ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-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.
*/
@import '../../../../../../../scss/constants';
$panel-width: 520px;
:host {
display: flex;
width: $panel-width;
max-width: 100%;
max-height: 80vh;
.fixed-title-width {
@media #{$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;
}
}
}

291
ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.ts

@ -0,0 +1,291 @@
///
/// 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, Input, OnInit, output, ViewChild } from '@angular/core';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms';
import { charsWithNumRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants';
import {
ArgumentEntityType,
ArgumentEntityTypeParamsMap,
ArgumentEntityTypeTranslations,
CalculatedFieldGeofencing,
CalculatedFieldGeofencingValue,
CalculatedFieldType,
GeofencingDirectionTranslations,
GeofencingReportStrategy,
GeofencingReportStrategyTranslations,
getCalculatedFieldCurrentEntityFilter
} from '@shared/models/calculated-field.models';
import { debounceTime, delay, distinctUntilChanged, filter, map } from 'rxjs/operators';
import { EntityType } from '@shared/models/entity-type.models';
import { DataKeyType } from '@shared/models/telemetry/telemetry.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 { BehaviorSubject, merge, Observable, of } from 'rxjs';
import { getCurrentAuthState } from '@core/auth/auth.selectors';
import { AppState } from '@core/core.state';
import { Store } from '@ngrx/store';
import { EntityAutocompleteComponent } from '@shared/components/entity/entity-autocomplete.component';
import { NULL_UUID } from '@shared/models/id/has-uuid';
import { EntitySearchDirection } from '@shared/models/relation.models';
@Component({
selector: 'tb-calculated-field-geofencing-zone-groups-panel',
templateUrl: './calculated-field-geofencing-zone-groups-panel.component.html',
styleUrls: ['./calculated-field-geofencing-zone-groups-panel.component.scss']
})
export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit, AfterViewInit {
@Input() buttonTitle: string;
@Input() zone: CalculatedFieldGeofencing;
@Input() entityId: EntityId;
@Input() tenantId: string;
@Input() entityName: string;
@Input() calculatedFieldType: CalculatedFieldType;
@Input() usedNames: string[];
@ViewChild('entityAutocomplete') entityAutocomplete: EntityAutocompleteComponent;
geofencingDataApplied = output<CalculatedFieldGeofencingValue>();
readonly maxRelationLevelPerCfArgument = getCurrentAuthState(this.store).maxRelationLevelPerCfArgument;
geofencingFormGroup = this.fb.group({
name: ['', [Validators.required, this.uniqNameRequired(), this.forbiddenNameValidator(), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]],
refEntityId: this.fb.group({
entityType: [ArgumentEntityType.Current],
id: ['']
}),
refDynamicSourceConfiguration: this.fb.group({
direction: [EntitySearchDirection.TO],
relationType: ['', [Validators.required]],
maxLevel: [1, [Validators.required, Validators.min(1), Validators.max(this.maxRelationLevelPerCfArgument)]],
fetchLastLevelOnly: [false],
}),
perimeterKeyName: ['', [Validators.pattern(oneSpaceInsideRegex)]],
reportStrategy: [GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS],
createRelationsWithMatchedZones: [false],
direction: [EntitySearchDirection.TO],
relationType: ['', [Validators.required]]
});
entityFilter: EntityFilter;
entityNameSubject = new BehaviorSubject<string>(null);
readonly ArgumentEntityType = ArgumentEntityType;
readonly argumentEntityTypes = Object.values(ArgumentEntityType) as ArgumentEntityType[];
readonly ArgumentEntityTypeTranslations = ArgumentEntityTypeTranslations;
readonly DataKeyType = DataKeyType;
readonly ArgumentEntityTypeParamsMap = ArgumentEntityTypeParamsMap;
readonly GeofencingReportStrategyList = Object.values(GeofencingReportStrategy) as Array<GeofencingReportStrategy>;
readonly GeofencingReportStrategyTranslations = GeofencingReportStrategyTranslations;
readonly GeofencingDirectionList = Object.values(EntitySearchDirection) as Array<EntitySearchDirection>;
readonly GeofencingDirectionTranslations = GeofencingDirectionTranslations;
private currentEntityFilter: EntityFilter;
constructor(
private fb: FormBuilder,
private cd: ChangeDetectorRef,
private popover: TbPopoverComponent<CalculatedFieldGeofencingZoneGroupsPanelComponent>,
private store: Store<AppState>
) {
this.observeMaxLevelChanges();
this.observeEntityFilterChanges();
this.observeEntityTypeChanges();
this.observeUpdatePosition();
this.observeCreateRelationZonesChanges();
}
get entityType(): ArgumentEntityType {
return this.geofencingFormGroup.get('refEntityId').get('entityType').value;
}
get refEntityIdFormGroup(): FormGroup {
return this.geofencingFormGroup.get('refEntityId') as FormGroup;
}
get refDynamicSourceFormGroup(): FormGroup {
return this.geofencingFormGroup.get('refDynamicSourceConfiguration') as FormGroup;
}
ngOnInit(): void {
this.geofencingFormGroup.patchValue(this.zone, {emitEvent: false});
if (this.zone.refDynamicSourceConfiguration?.type) {
this.refEntityIdFormGroup.get('entityType').setValue(this.zone.refDynamicSourceConfiguration.type, {emitEvent: false});
}
this.validateFetchLastLevelOnly(this.zone?.refDynamicSourceConfiguration?.maxLevel);
this.validateDirectionAndRelationType(this.zone?.createRelationsWithMatchedZones);
this.validateRefDynamicSourceConfiguration(this.zone?.refEntityId?.entityType || this.zone?.refDynamicSourceConfiguration?.type);
this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.entityName, this.entityId);
this.updateEntityFilter(this.zone.refEntityId?.entityType);
}
fetchOptions(searchText: string): Observable<Array<string>> {
const search = searchText ? searchText?.toLowerCase() : '';
return of(['Contains', 'Manages']).pipe(map(name => name?.filter(option => option.toLowerCase().includes(search))));
}
private observeMaxLevelChanges(): void {
this.refDynamicSourceFormGroup.get('maxLevel').valueChanges
.pipe(takeUntilDestroyed())
.subscribe(value => this.validateFetchLastLevelOnly(value));
}
private observeCreateRelationZonesChanges(): void {
this.geofencingFormGroup.get('createRelationsWithMatchedZones').valueChanges
.pipe(takeUntilDestroyed())
.subscribe(value => this.validateDirectionAndRelationType(value));
}
private validateFetchLastLevelOnly(maxLevel = 1): void {
if (maxLevel > 1) {
this.refDynamicSourceFormGroup.get('fetchLastLevelOnly').enable({emitEvent: false});
} else {
this.refDynamicSourceFormGroup.get('fetchLastLevelOnly').disable({emitEvent: false});
}
}
private validateDirectionAndRelationType(createRelation = false): void {
if (createRelation) {
this.geofencingFormGroup.get('direction').enable({emitEvent: false});
this.geofencingFormGroup.get('relationType').enable({emitEvent: false});
} else {
this.geofencingFormGroup.get('direction').disable({emitEvent: false});
this.geofencingFormGroup.get('relationType').disable({emitEvent: false});
}
}
private validateRefDynamicSourceConfiguration(type: ArgumentEntityType = ArgumentEntityType.Current): void {
if (type === ArgumentEntityType.RelationQuery) {
this.refDynamicSourceFormGroup.enable({emitEvent: false});
} else {
this.refDynamicSourceFormGroup.disable({emitEvent: false});
}
}
ngAfterViewInit(): void {
if (this.zone.refEntityId?.id === NULL_UUID) {
this.entityAutocomplete.selectEntityFormGroup.get('entity').markAsTouched();
}
}
saveZone(): void {
const value = this.geofencingFormGroup.value as CalculatedFieldGeofencingValue;
const argumentType = value.refEntityId.entityType;
switch (argumentType) {
case ArgumentEntityType.Current:
delete value.refEntityId;
break;
case ArgumentEntityType.RelationQuery:
delete value.refEntityId;
value.refDynamicSourceConfiguration.type = ArgumentEntityType.RelationQuery;
break;
case ArgumentEntityType.Tenant:
value.refEntityId.id = this.tenantId;
break
default:
value.entityName = this.entityNameSubject.value;
}
this.geofencingDataApplied.emit(value);
}
cancel(): void {
this.popover.hide();
}
private updateEntityFilter(entityType: ArgumentEntityType = ArgumentEntityType.Current): void {
let entityFilter: EntityFilter;
switch (entityType) {
case ArgumentEntityType.Current:
case ArgumentEntityType.RelationQuery:
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.geofencingFormGroup.get('refEntityId').value as unknown as EntityId,
};
}
this.entityFilter = entityFilter;
this.cd.markForCheck();
}
private observeEntityFilterChanges(): void {
merge(
this.refEntityIdFormGroup.get('entityType').valueChanges,
this.refEntityIdFormGroup.get('id').valueChanges.pipe(filter(Boolean)),
)
.pipe(debounceTime(50), takeUntilDestroyed())
.subscribe(() => this.updateEntityFilter(this.entityType));
}
private observeEntityTypeChanges(): void {
this.refEntityIdFormGroup.get('entityType').valueChanges
.pipe(distinctUntilChanged(), takeUntilDestroyed())
.subscribe(type => {
this.geofencingFormGroup.get('refEntityId').get('id').setValue('');
const isEntityWithId = type !== ArgumentEntityType.Tenant && type !== ArgumentEntityType.Current && type !== ArgumentEntityType.RelationQuery;
this.geofencingFormGroup.get('refEntityId')
.get('id')[isEntityWithId ? 'enable' : 'disable']();
if (!isEntityWithId) {
this.entityNameSubject.next(null);
}
this.validateRefDynamicSourceConfiguration(type);
});
}
private uniqNameRequired(): ValidatorFn {
return (control: FormControl) => {
const newName = control.value.trim().toLowerCase();
const isDuplicate = this.usedNames?.some(name => name.toLowerCase() === newName);
return isDuplicate ? { duplicateName: true } : null;
};
}
private forbiddenNameValidator(): ValidatorFn {
return (control: FormControl) => {
const trimmedValue = control.value.trim().toLowerCase();
const forbiddenNames = ['ctx', 'e', 'pi'];
return forbiddenNames.includes(trimmedValue) ? { forbiddenName: true } : null;
};
}
private observeUpdatePosition(): void {
merge(
this.refEntityIdFormGroup.get('entityType').valueChanges,
this.geofencingFormGroup.get('createRelationsWithMatchedZones').valueChanges
)
.pipe(delay(50), takeUntilDestroyed())
.subscribe(() => this.popover.updatePosition());
}
}

10
ui-ngx/src/app/modules/home/components/home-components.module.ts

@ -205,6 +205,12 @@ import {
} from '@home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component';
import { CheckConnectivityDialogComponent } from '@home/components/ai-model/check-connectivity-dialog.component';
import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialog.component';
import {
CalculatedFieldGeofencingZoneGroupsTableComponent
} from '@home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component';
import {
CalculatedFieldGeofencingZoneGroupsPanelComponent
} from '@home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component';
@NgModule({
declarations:
@ -356,6 +362,8 @@ import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialo
CalculatedFieldDebugDialogComponent,
CalculatedFieldScriptTestDialogComponent,
CalculatedFieldTestArgumentsComponent,
CalculatedFieldGeofencingZoneGroupsTableComponent,
CalculatedFieldGeofencingZoneGroupsPanelComponent,
CheckConnectivityDialogComponent,
AIModelDialogComponent,
],
@ -503,6 +511,8 @@ import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialo
CalculatedFieldDebugDialogComponent,
CalculatedFieldScriptTestDialogComponent,
CalculatedFieldTestArgumentsComponent,
CalculatedFieldGeofencingZoneGroupsTableComponent,
CalculatedFieldGeofencingZoneGroupsPanelComponent,
CheckConnectivityDialogComponent,
AIModelDialogComponent,
],

28
ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html

@ -275,6 +275,34 @@
</mat-error>
<mat-hint></mat-hint>
</mat-form-field>
<mat-form-field class="mat-block flex-1" appearance="fill" subscriptSizing="dynamic">
<mat-label translate>tenant-profile.max-related-level-per-argument</mat-label>
<input matInput required min="1" step="1"
formControlName="maxRelationLevelPerCfArgument"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxRelationLevelPerCfArgument').hasError('required')">
{{ 'tenant-profile.max-related-level-per-argument-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('maxRelationLevelPerCfArgument').hasError('min')">
{{ 'tenant-profile.max-related-level-per-argument-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
</mat-form-field>
</div>
<div class="flex flex-1 flex-row xs:flex-col gt-xs:gap-4">
<mat-form-field class="mat-block flex-1" appearance="fill" subscriptSizing="dynamic">
<mat-label translate>tenant-profile.min-allowed-scheduled-update-interval</mat-label>
<input matInput required min="0" step="1"
formControlName="minAllowedScheduledUpdateIntervalInSecForCF"
type="number">
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('minAllowedScheduledUpdateIntervalInSecForCF').hasError('required')">
{{ 'tenant-profile.min-allowed-scheduled-update-interval-required' | translate}}
</mat-error>
<mat-error *ngIf="defaultTenantProfileConfigurationFormGroup.get('minAllowedScheduledUpdateIntervalInSecForCF').hasError('min')">
{{ 'tenant-profile.min-allowed-scheduled-update-interval-range' | translate}}
</mat-error>
<mat-hint></mat-hint>
</mat-form-field>
<div class="flex-1"></div>
</div>
<mat-expansion-panel class="configuration-panel">

2
ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts

@ -124,6 +124,8 @@ export class DefaultTenantProfileConfigurationComponent implements ControlValueA
edgeUplinkMessagesRateLimitsPerEdge: [null, []],
maxCalculatedFieldsPerEntity: [null, [Validators.required, Validators.min(0)]],
maxArgumentsPerCF: [null, [Validators.required, Validators.min(0)]],
maxRelationLevelPerCfArgument: [null, [Validators.required, Validators.min(1)]],
minAllowedScheduledUpdateIntervalInSecForCF: [null, [Validators.required, Validators.min(0)]],
maxDataPointsPerRollingArg: [null, [Validators.required, Validators.min(0)]],
maxStateSizeInKBytes: [null, [Validators.required, Validators.min(0)]],
calculatedFieldDebugEventsRateLimit: [null, []],

3
ui-ngx/src/app/modules/home/components/rule-node/common/common-rule-node-config.module.ts

@ -33,7 +33,6 @@ import { RelationsQueryConfigOldComponent } from './relations-query-config-old.c
import { SelectAttributesComponent } from './select-attributes.component';
import { AlarmStatusSelectComponent } from './alarm-status-select.component';
import { ExampleHintComponent } from './example-hint.component';
import { TimeUnitInputComponent } from './time-unit-input.component';
@NgModule({
declarations: [
@ -52,7 +51,6 @@ import { TimeUnitInputComponent } from './time-unit-input.component';
SelectAttributesComponent,
AlarmStatusSelectComponent,
ExampleHintComponent,
TimeUnitInputComponent
],
imports: [
CommonModule,
@ -75,7 +73,6 @@ import { TimeUnitInputComponent } from './time-unit-input.component';
SelectAttributesComponent,
AlarmStatusSelectComponent,
ExampleHintComponent,
TimeUnitInputComponent
]
})

4
ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.html

@ -16,7 +16,7 @@
-->
<mat-form-field class="tb-flex no-gap !w-full" appearance="outline" subscriptSizing="dynamic">
<input matInput type="text" placeholder="{{ 'action.set' | translate }}"
<input matInput type="text" placeholder="{{ placeholder }}"
#keyInput
[formControl]="keyControl"
required
@ -32,7 +32,7 @@
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'common.hint.key-required' | translate"
[matTooltip]="requiredText"
class="tb-error">
warning
</mat-icon>

17
ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.ts

@ -14,7 +14,17 @@
/// limitations under the License.
///
import { Component, effect, ElementRef, forwardRef, input, OnChanges, SimpleChanges, ViewChild, } from '@angular/core';
import {
Component,
effect,
ElementRef,
forwardRef,
Input,
input,
OnChanges,
SimpleChanges,
ViewChild,
} from '@angular/core';
import {
ControlValueAccessor,
FormBuilder,
@ -32,6 +42,7 @@ import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.
import { EntitiesKeysByQuery } from '@shared/models/entity.models';
import { EntityFilter } from '@shared/models/query/query.models';
import { isEqual } from '@core/utils';
import { TranslateService } from '@ngx-translate/core';
@Component({
selector: 'tb-entity-key-autocomplete',
@ -53,6 +64,9 @@ export class EntityKeyAutocompleteComponent implements ControlValueAccessor, Val
@ViewChild('keyInput', {static: true}) keyInput: ElementRef;
@Input() placeholder = this.translate.instant('action.set');
@Input() requiredText = this.translate.instant('common.hint.key-required');
entityFilter = input.required<EntityFilter>();
dataKeyType = input.required<DataKeyType>();
keyScopeType = input<AttributeScope>();
@ -96,6 +110,7 @@ export class EntityKeyAutocompleteComponent implements ControlValueAccessor, Val
constructor(
private fb: FormBuilder,
private entityService: EntityService,
private translate: TranslateService,
) {
this.keyControl.valueChanges
.pipe(takeUntilDestroyed())

23
ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.html → ui-ngx/src/app/shared/components/time-unit-input.component.html

@ -28,19 +28,18 @@
</div>
@if (inlineField) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="hasError"
*ngIf="hasError"
class="tb-error">
warning
</mat-icon>
} @else {
<mat-hint *ngIf="subscriptSizing === 'fixed'"></mat-hint>
<mat-error *ngIf="hasError">
{{ hasError }}
</mat-error>
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="hasError"
*ngIf="hasError"
class="tb-error">
warning
</mat-icon>
}
<mat-hint *ngIf="(subscriptSizing === 'fixed') && !inlineField"></mat-hint>
<mat-error *ngIf="hasError && !inlineField">
{{ hasError }}
</mat-error>
</mat-form-field>
<mat-form-field [class.h-fit.max-w-33%.flex-full]="!inlineField"
[appearance]="inlineField ? 'outline' : appearance"

6
ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts → ui-ngx/src/app/shared/components/time-unit-input.component.ts

@ -25,7 +25,7 @@ import {
Validator,
Validators
} from '@angular/forms';
import { TimeUnit, timeUnitTranslations } from '../rule-node-config.models';
import { TimeUnit, timeUnitTranslations } from '@home/components/rule-node/rule-node-config.models';
import { isDefinedAndNotNull, isNumeric } from '@core/utils';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { coerceBoolean, coerceNumber } from '@shared/decorators/coercion';
@ -133,7 +133,9 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator,
);
}
if (isDefinedAndNotNull(this.minTime)) {
validators.push(Validators.min(this.minTime));
validators.push((control: AbstractControl) =>
Validators.min(Math.ceil(this.minTime / this.timeIntervalsInSec.get(this.timeInputForm.get('timeUnit').value)))(control)
);
}
timeControl.setValidators(validators);

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

@ -14,11 +14,7 @@
/// limitations under the License.
///
import {
HasEntityDebugSettings,
HasTenantId,
HasVersion
} from '@shared/models/entity.models';
import { HasEntityDebugSettings, HasTenantId, HasVersion } from '@shared/models/entity.models';
import { BaseData, ExportableEntity } from '@shared/models/base-data';
import { CalculatedFieldId } from '@shared/models/id/calculated-field-id';
import { EntityId } from '@shared/models/id/entity-id';
@ -33,6 +29,7 @@ import {
dotOperatorHighlightRule,
endGroupHighlightRule
} from '@shared/models/ace/ace.models';
import { EntitySearchDirection } from '@shared/models/relation.models';
export interface CalculatedField extends Omit<BaseData<CalculatedFieldId>, 'label'>, HasVersion, HasEntityDebugSettings, HasTenantId, ExportableEntity<CalculatedFieldId> {
configuration: CalculatedFieldConfiguration;
@ -43,19 +40,23 @@ export interface CalculatedField extends Omit<BaseData<CalculatedFieldId>, 'labe
export enum CalculatedFieldType {
SIMPLE = 'SIMPLE',
SCRIPT = 'SCRIPT',
GEOFENCING = 'GEOFENCING'
}
export const CalculatedFieldTypeTranslations = new Map<CalculatedFieldType, string>(
[
[CalculatedFieldType.SIMPLE, 'calculated-fields.type.simple'],
[CalculatedFieldType.SCRIPT, 'calculated-fields.type.script'],
[CalculatedFieldType.GEOFENCING, 'calculated-fields.type.geofencing'],
]
)
export interface CalculatedFieldConfiguration {
type: CalculatedFieldType;
expression: string;
arguments: Record<string, CalculatedFieldArgument>;
expression?: string;
arguments?: Record<string, CalculatedFieldArgument>;
zoneGroups?: Record<string, CalculatedFieldGeofencing>;
scheduledUpdateInterval?: number;
output: CalculatedFieldOutput;
}
@ -72,6 +73,7 @@ export enum ArgumentEntityType {
Asset = 'ASSET',
Customer = 'CUSTOMER',
Tenant = 'TENANT',
RelationQuery = 'RELATION_QUERY',
}
export const ArgumentEntityTypeTranslations = new Map<ArgumentEntityType, string>(
@ -81,6 +83,28 @@ export const ArgumentEntityTypeTranslations = new Map<ArgumentEntityType, string
[ArgumentEntityType.Asset, 'calculated-fields.argument-asset'],
[ArgumentEntityType.Customer, 'calculated-fields.argument-customer'],
[ArgumentEntityType.Tenant, 'calculated-fields.argument-tenant'],
[ArgumentEntityType.RelationQuery, 'calculated-fields.argument-relation-query'],
]
)
export enum GeofencingReportStrategy {
REPORT_TRANSITION_EVENTS_ONLY = 'REPORT_TRANSITION_EVENTS_ONLY',
REPORT_PRESENCE_STATUS_ONLY = 'REPORT_PRESENCE_STATUS_ONLY',
REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS = 'REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS'
}
export const GeofencingReportStrategyTranslations = new Map<GeofencingReportStrategy, string>(
[
[GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, 'calculated-fields.report-transition-event-and-presence'],
[GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_ONLY, 'calculated-fields.report-transition-event-only'],
[GeofencingReportStrategy.REPORT_PRESENCE_STATUS_ONLY, 'calculated-fields.report-presence-status-only']
]
)
export const GeofencingDirectionTranslations = new Map<EntitySearchDirection, string>(
[
[EntitySearchDirection.FROM, 'calculated-fields.direction-from'],
[EntitySearchDirection.TO, 'calculated-fields.direction-to'],
]
)
@ -131,6 +155,29 @@ export interface CalculatedFieldArgument {
timeWindow?: number;
}
export interface CalculatedFieldGeofencing {
perimeterKeyName: string;
reportStrategy: GeofencingReportStrategy;
refEntityId?: RefEntityId;
refDynamicSourceConfiguration: RefDynamicSourceConfiguration;
createRelationsWithMatchedZones: boolean;
relationType: string;
direction: EntitySearchDirection;
}
export interface RefDynamicSourceConfiguration {
type?: ArgumentEntityType.RelationQuery;
direction: EntitySearchDirection;
relationType: string;
maxLevel: number;
fetchLastLevelOnly?: boolean;
}
export interface CalculatedFieldGeofencingValue extends CalculatedFieldGeofencing {
name: string;
entityName?: string;
}
export interface RefEntityKey {
key: string;
type: ArgumentType;

3
ui-ngx/src/app/shared/shared.module.ts

@ -228,6 +228,7 @@ import { JsFuncModuleRowComponent } from '@shared/components/js-func-module-row.
import { EntityKeyAutocompleteComponent } from '@shared/components/entity/entity-key-autocomplete.component';
import { DurationLeftPipe } from '@shared/pipe/duration-left.pipe';
import { MqttVersionSelectComponent } from '@shared/components/mqtt-version-select.component';
import { TimeUnitInputComponent } from '@shared/components/time-unit-input.component';
export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) {
return markedOptionsService;
@ -443,6 +444,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
ScadaSymbolInputComponent,
EntityKeyAutocompleteComponent,
MqttVersionSelectComponent,
TimeUnitInputComponent,
],
imports: [
CommonModule,
@ -707,6 +709,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
ScadaSymbolInputComponent,
EntityKeyAutocompleteComponent,
MqttVersionSelectComponent,
TimeUnitInputComponent,
]
})
export class SharedModule { }

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

@ -1021,12 +1021,14 @@
"selected-fields": "{ count, plural, =1 {1 calculated field} other {# calculated fields} } selected",
"type": {
"simple": "Simple",
"script": "Script"
"script": "Script",
"geofencing" : "Geofencing"
},
"arguments": "Arguments",
"decimals-by-default": "Decimals by default",
"debugging": "Calculated field debugging",
"argument-name": "Argument name",
"name": "Name",
"datasource": "Datasource",
"add-argument": "Add argument",
"test-script-function": "Test script function",
@ -1038,6 +1040,7 @@
"argument-asset": "Asset",
"argument-customer": "Customer",
"argument-tenant": "Current tenant",
"argument-relation-query": "Related entities",
"argument-type": "Argument type",
"see-debug-events": "See debug events",
"attribute": "Attribute",
@ -1071,6 +1074,34 @@
"delete-multiple-text": "Be careful, after the confirmation all selected calculated fields will be removed and all related data will become unrecoverable.",
"test-with-this-message": "Test with this message",
"use-latest-timestamp": "Use latest timestamp",
"entity-coordinates": "Entity coordinates",
"latitude-time-series-key": "Latitude time series key",
"latitude-time-series-key-required": "Latitude time series key is required.",
"longitude-time-series-key": "Longitude time series key",
"longitude-time-series-key-required": "Longitude time series key is required.",
"geofencing-zone-groups": "Geofencing zone groups",
"geofencing-zone-groups-settings": "Geofencing zone group settings",
"target-zone": "Target zone",
"perimeter-key": "Perimeter key",
"report-strategy": "Report strategy",
"no-zone-configured": "No zone group configured",
"no-zone-configured-required": "At least one zone group must be configured.",
"add-zone-group": "Add zone group",
"report-transition-event-only": "Transition events only",
"report-presence-status-only": "Presence status only",
"report-transition-event-and-presence": "Presence status and transition events",
"perimeter-attribute-key": "Perimeter attribute key",
"relation-query": "Relations query",
"direction": "Direction",
"direction-from": "From source entity",
"direction-to": "To source entity",
"relation-type": "Relation type",
"create-relation-with-matched-zones": "Create relations with matched zones",
"relation-level": "Relation level",
"fetch-last-available-level": "Fetch last available level only",
"zone-group-refresh-interval": "Zone groups refresh interval",
"copy-zone-group-name": "Copy zone group name",
"open-details-page": "Open entity details page",
"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.",
@ -1082,12 +1113,32 @@
"argument-name-duplicate": "Argument with such name already exists.",
"argument-name-max-length": "Argument name should be less than 256 characters.",
"argument-name-forbidden": "Argument name is reserved and cannot be used.",
"name-required": "Mame is required.",
"name-pattern": "Name is invalid.",
"name-duplicate": "Name with such name already exists.",
"name-max-length": "Name should be less than 256 characters.",
"name-forbidden": "Name is reserved and cannot be used.",
"argument-type-required": "Argument type is required.",
"max-args": "Maximum number of arguments reached.",
"decimals-range": "Decimals by default should be a number between 0 and 15.",
"expression": "Default expression demonstrates how to transform a temperature from Fahrenheit to Celsius.",
"arguments-entity-not-found": "Argument target entity not found.",
"use-latest-timestamp": "If enabled, the calculated value will be persisted using the most recent timestamp from the arguments telemetry, instead of the server time."
"use-latest-timestamp": "If enabled, the calculated value will be persisted using the most recent timestamp from the arguments telemetry, instead of the server time.",
"entity-coordinates": "Specify the time series keys that provide entity GPS coordinates (latitude and longitude).",
"geofencing-zone-groups": "Define one or more geofencing zones groups to check (e.g. 'allowedZones', 'restrictedZones'). Each group must have a unique name, which is used as a prefix for calculated field output telemetry keys.",
"perimeter-attribute-key": "Set the attribute key that contains the geofencing zone perimeter definition. The perimeter is always taken from server-side attributes of the zone entity.",
"report-strategy": "Presence status reports whether the entity is currently INSIDE or OUTSIDE the zone group.Transition events report when the entity ENTERED or LEFT the zone group.",
"create-relation-with-matched-zones": "Automatically create and maintain relations between the entity and the zones it is currently inside. Relations are removed when the entity leaves a zone and created when it enters a new one.",
"relation-type-required": "Relation type is required.",
"relation-level-required": "Relation level is required.",
"relation-level-min": "Minimum relation level value is 1.",
"relation-level-max": "Maximum relation level value is {{max}}.",
"geofencing-empty": "At least one zone group must be configured.",
"geofencing-entity-not-found": "Geofencing target entity not found.",
"max-geofencing-zone": "Maximum number of geofencing zones reached.",
"zone-group-refresh-interval": "Defines how often zone groups configured via related entities are refreshed. Set to 0 to disable scheduled refresh.",
"zone-group-refresh-interval-required": "Zone groups refresh interval is required.",
"zone-group-refresh-interval-min": "Zone group refresh interval is below the minimum allowed system interval."
}
},
"ai-models": {
@ -5728,6 +5779,12 @@
"max-arguments-per-cf": "Arguments per calculated field max number",
"max-arguments-per-cf-range": "Arguments per calculated field max number can't be negative",
"max-arguments-per-cf-required": "Arguments per calculated field max number is required",
"max-related-level-per-argument": "Maximum relation level per 'Related entities' argument",
"max-related-level-per-argument-range": "Relation level per 'Related entities' argument max number can't be less than '1'",
"max-related-level-per-argument-required": "Relation level per 'Related entities' argument max number is required",
"min-allowed-scheduled-update-interval": "Min allowed update interval for 'Related entities' arguments (seconds)",
"min-allowed-scheduled-update-interval-range": "Min allowed update interval min number can't be negative",
"min-allowed-scheduled-update-interval-required": "Min allowed update interval min number is required",
"max-state-size": "State maximum size in KB",
"max-state-size-range": "State maximum size in KB can't be negative",
"max-state-size-required": "State maximum size in KB is required",

Loading…
Cancel
Save