committed by
GitHub
24 changed files with 1500 additions and 127 deletions
@ -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> |
|||
@ -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; |
|||
} |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -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> |
|||
@ -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; |
|||
} |
|||
} |
|||
} |
|||
@ -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()); |
|||
} |
|||
|
|||
} |
|||
Loading…
Reference in new issue