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