Calculated adjustments

This commit is contained in:
mpetrov 2025-03-11 18:15:47 +02:00
parent 7d57cde6fa
commit 8c7c54b554
10 changed files with 58 additions and 45 deletions

View File

@ -258,8 +258,15 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
} }
private importCalculatedField(): void { private importCalculatedField(): void {
this.importExportService.importCalculatedField(this.entityId) this.importExportService.openCalculatedFieldImportDialog()
.pipe(filter(Boolean), takeUntilDestroyed(this.destroyRef)) .pipe(
filter(Boolean),
switchMap(calculatedField => this.getCalculatedFieldDialog(calculatedField, 'action.add')),
filter(Boolean),
switchMap(calculatedField => this.calculatedFieldsService.saveCalculatedField(calculatedField)),
filter(Boolean),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => this.updateData()); .subscribe(() => this.updateData());
} }

View File

@ -97,7 +97,7 @@
matTooltipPosition="above"> matTooltipPosition="above">
<mat-icon <mat-icon
[matBadgeHidden]="!(argument.refEntityKey.type === ArgumentType.Rolling [matBadgeHidden]="!(argument.refEntityKey.type === ArgumentType.Rolling
&& calculatedFieldType === CalculatedFieldType.SIMPLE)" && calculatedFieldType === CalculatedFieldType.SIMPLE) && !entityNameErrorSet.has(argument.refEntityId?.id)"
matBadgeColor="warn" matBadgeColor="warn"
matBadgeSize="small" matBadgeSize="small"
matBadge="*" matBadge="*"

View File

@ -58,6 +58,8 @@ import { MatSort } from '@angular/material/sort';
import { getCurrentAuthState } from '@core/auth/auth.selectors'; import { getCurrentAuthState } from '@core/auth/auth.selectors';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
import { catchError } from 'rxjs/operators';
import { NEVER } from 'rxjs';
@Component({ @Component({
selector: 'tb-calculated-field-arguments-table', selector: 'tb-calculated-field-arguments-table',
@ -88,6 +90,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
errorText = ''; errorText = '';
argumentsFormArray = this.fb.array<AbstractControl>([]); argumentsFormArray = this.fb.array<AbstractControl>([]);
entityNameMap = new Map<string, string>(); entityNameMap = new Map<string, string>();
entityNameErrorSet = new Set<string>();
sortOrder = { direction: 'asc', property: '' }; sortOrder = { direction: 'asc', property: '' };
dataSource = new CalculatedFieldArgumentDatasource(); dataSource = new CalculatedFieldArgumentDatasource();
@ -168,6 +171,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
buttonTitle: this.argumentsFormArray.at(index)?.value ? 'action.apply' : 'action.add', buttonTitle: this.argumentsFormArray.at(index)?.value ? 'action.apply' : 'action.add',
tenantId: this.tenantId, tenantId: this.tenantId,
entityName: this.entityName, entityName: this.entityName,
entityHasError: this.entityNameErrorSet.has(argument.refEntityId?.id),
usedArgumentNames: this.argumentsFormArray.value.map(({ argumentName }) => argumentName).filter(name => name !== argument.argumentName), usedArgumentNames: this.argumentsFormArray.value.map(({ argumentName }) => argumentName).filter(name => name !== argument.argumentName),
}; };
this.popoverComponent = this.popoverService.displayPopover(trigger, this.renderer, this.popoverComponent = this.popoverService.displayPopover(trigger, this.renderer,
@ -198,6 +202,8 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
if (this.calculatedFieldType === CalculatedFieldType.SIMPLE if (this.calculatedFieldType === CalculatedFieldType.SIMPLE
&& this.argumentsFormArray.controls.some(control => control.value.refEntityKey.type === ArgumentType.Rolling)) { && this.argumentsFormArray.controls.some(control => control.value.refEntityKey.type === ArgumentType.Rolling)) {
this.errorText = 'calculated-fields.hint.arguments-simple-with-rolling'; this.errorText = 'calculated-fields.hint.arguments-simple-with-rolling';
} else if (this.entityNameErrorSet.size) {
this.errorText = 'calculated-fields.hint.arguments-entity-not-found';
} else if (!this.argumentsFormArray.controls.length) { } else if (!this.argumentsFormArray.controls.length) {
this.errorText = 'calculated-fields.hint.arguments-empty'; this.errorText = 'calculated-fields.hint.arguments-empty';
} else { } else {
@ -234,11 +240,18 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
} }
private updateEntityNameMap(value: CalculatedFieldArgumentValue[]): void { private updateEntityNameMap(value: CalculatedFieldArgumentValue[]): void {
this.entityNameErrorSet.clear();
value.forEach(({ refEntityId = {}}) => { value.forEach(({ refEntityId = {}}) => {
if (refEntityId.id && !this.entityNameMap.has(refEntityId.id)) { if (refEntityId.id && !this.entityNameMap.has(refEntityId.id)) {
const { id, entityType } = refEntityId as EntityId; const { id, entityType } = refEntityId as EntityId;
this.entityService.getEntity(entityType as EntityType, id, { ignoreLoading: true, ignoreErrors: true }) this.entityService.getEntity(entityType as EntityType, id, { ignoreLoading: true, ignoreErrors: true })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(
catchError(() => {
this.entityNameErrorSet.add(id);
return NEVER;
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(entity => this.entityNameMap.set(id, entity.name)); .subscribe(entity => this.entityNameMap.set(id, entity.name));
} }
}); });

View File

@ -20,7 +20,7 @@ import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { DialogComponent } from '@shared/components/dialog.component'; import { DialogComponent } from '@shared/components/dialog.component';
import { CalculatedFieldEventBody, DebugEventType, EventType } from '@shared/models/event.models'; import { CalculatedFieldEventBody, DebugEventType, Event, EventType } from '@shared/models/event.models';
import { EventTableComponent } from '@home/components/event/event-table.component'; import { EventTableComponent } from '@home/components/event/event-table.component';
import { CalculatedFieldDebugDialogData, CalculatedFieldType } from '@shared/models/calculated-field.models'; import { CalculatedFieldDebugDialogData, CalculatedFieldType } from '@shared/models/calculated-field.models';
@ -46,7 +46,7 @@ export class CalculatedFieldDebugDialogComponent extends DialogComponent<Calcula
ngAfterViewInit(): void { ngAfterViewInit(): void {
this.eventsTable.entitiesTable.updateData(); this.eventsTable.entitiesTable.updateData();
this.eventsTable.entitiesTable.cellActionDescriptors[0].isEnabled = () => this.data.value.type === CalculatedFieldType.SCRIPT; this.eventsTable.entitiesTable.cellActionDescriptors[0].isEnabled = (event => this.data.value.type === CalculatedFieldType.SCRIPT && !!(event as Event).body.arguments)
} }
cancel(): void { cancel(): void {

View File

@ -82,6 +82,7 @@
<div class="fixed-title-width tb-required">{{ ArgumentEntityTypeParamsMap.get(entityType).title | translate }}</div> <div class="fixed-title-width tb-required">{{ ArgumentEntityTypeParamsMap.get(entityType).title | translate }}</div>
<tb-entity-autocomplete <tb-entity-autocomplete
class="flex flex-1" class="flex flex-1"
#entityAutocomplete
formControlName="id" formControlName="id"
inlineField inlineField
[placeholder]="'action.set' | translate" [placeholder]="'action.set' | translate"
@ -165,7 +166,7 @@
</div> </div>
} @else { } @else {
<div class="tb-form-row"> <div class="tb-form-row">
<div class="fixed-title-width">{{ 'calculated-fields.time-window' | translate }}</div> <div class="fixed-title-width tb-required">{{ 'calculated-fields.time-window' | translate }}</div>
<tb-timeinterval <tb-timeinterval
subscriptSizing="dynamic" subscriptSizing="dynamic"
appearance="outline" appearance="outline"
@ -175,7 +176,7 @@
</div> </div>
@if (maxDataPointsPerRollingArg) { @if (maxDataPointsPerRollingArg) {
<div class="tb-form-row limit-field-row"> <div class="tb-form-row limit-field-row">
<div class="fixed-title-width">{{ 'calculated-fields.limit' | translate }}</div> <div class="fixed-title-width tb-required">{{ 'calculated-fields.limit' | translate }}</div>
<div class="limit-slider-container flex w-full flex-1 flex-row items-center justify-start"> <div class="limit-slider-container flex w-full flex-1 flex-row items-center justify-start">
<mat-slider class="flex-1" min="1" max="{{maxDataPointsPerRollingArg}}"> <mat-slider class="flex-1" min="1" max="{{maxDataPointsPerRollingArg}}">
<input matSliderThumb formControlName="limit" [value]="argumentFormGroup.get('limit').value"/> <input matSliderThumb formControlName="limit" [value]="argumentFormGroup.get('limit').value"/>

View File

@ -14,7 +14,7 @@
/// limitations under the License. /// limitations under the License.
/// ///
import { ChangeDetectorRef, Component, Input, OnInit, output } from '@angular/core'; import { AfterViewInit, ChangeDetectorRef, Component, Input, OnInit, output, ViewChild } from '@angular/core';
import { TbPopoverComponent } from '@shared/components/popover.component'; import { TbPopoverComponent } from '@shared/components/popover.component';
import { FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'; import { FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms';
import { charsWithNumRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants'; import { charsWithNumRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants';
@ -41,13 +41,14 @@ import { MINUTE } from '@shared/models/time/time.models';
import { getCurrentAuthState } from '@core/auth/auth.selectors'; import { getCurrentAuthState } from '@core/auth/auth.selectors';
import { AppState } from '@core/core.state'; import { AppState } from '@core/core.state';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { EntityAutocompleteComponent } from '@shared/components/entity/entity-autocomplete.component';
@Component({ @Component({
selector: 'tb-calculated-field-argument-panel', selector: 'tb-calculated-field-argument-panel',
templateUrl: './calculated-field-argument-panel.component.html', templateUrl: './calculated-field-argument-panel.component.html',
styleUrls: ['./calculated-field-argument-panel.component.scss'] styleUrls: ['./calculated-field-argument-panel.component.scss']
}) })
export class CalculatedFieldArgumentPanelComponent implements OnInit { export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewInit {
@Input() buttonTitle: string; @Input() buttonTitle: string;
@Input() index: number; @Input() index: number;
@ -55,9 +56,12 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit {
@Input() entityId: EntityId; @Input() entityId: EntityId;
@Input() tenantId: string; @Input() tenantId: string;
@Input() entityName: string; @Input() entityName: string;
@Input() entityHasError: boolean;
@Input() calculatedFieldType: CalculatedFieldType; @Input() calculatedFieldType: CalculatedFieldType;
@Input() usedArgumentNames: string[]; @Input() usedArgumentNames: string[];
@ViewChild('entityAutocomplete') entityAutocomplete: EntityAutocompleteComponent;
argumentsDataApplied = output<{ value: CalculatedFieldArgumentValue, index: number }>(); argumentsDataApplied = output<{ value: CalculatedFieldArgumentValue, index: number }>();
readonly maxDataPointsPerRollingArg = getCurrentAuthState(this.store).maxDataPointsPerRollingArg; readonly maxDataPointsPerRollingArg = getCurrentAuthState(this.store).maxDataPointsPerRollingArg;
@ -75,8 +79,8 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit {
scope: [{ value: AttributeScope.SERVER_SCOPE, disabled: true }, [Validators.required]], scope: [{ value: AttributeScope.SERVER_SCOPE, disabled: true }, [Validators.required]],
}), }),
defaultValue: ['', [Validators.pattern(oneSpaceInsideRegex)]], defaultValue: ['', [Validators.pattern(oneSpaceInsideRegex)]],
limit: [{ value: this.defaultLimit, disabled: !this.maxDataPointsPerRollingArg }], limit: [{ value: this.defaultLimit, disabled: !this.maxDataPointsPerRollingArg }, [Validators.required, Validators.min(1), Validators.max(this.maxDataPointsPerRollingArg)]],
timeWindow: [MINUTE * 15], timeWindow: [MINUTE * 15, [Validators.required]],
}); });
argumentTypes: ArgumentType[]; argumentTypes: ArgumentType[];
@ -136,6 +140,12 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit {
.filter(type => type !== ArgumentType.Rolling || this.calculatedFieldType === CalculatedFieldType.SCRIPT); .filter(type => type !== ArgumentType.Rolling || this.calculatedFieldType === CalculatedFieldType.SCRIPT);
} }
ngAfterViewInit(): void {
if (this.entityHasError) {
this.entityAutocomplete.selectEntityFormGroup.get('entity').markAsTouched();
}
}
saveArgument(): void { saveArgument(): void {
const { refEntityId, ...restConfig } = this.argumentFormGroup.value; const { refEntityId, ...restConfig } = this.argumentFormGroup.value;
const value = (refEntityId.entityType === ArgumentEntityType.Current ? restConfig : { refEntityId, ...restConfig }) as CalculatedFieldArgumentValue; const value = (refEntityId.entityType === ArgumentEntityType.Current ? restConfig : { refEntityId, ...restConfig }) as CalculatedFieldArgumentValue;

View File

@ -89,7 +89,8 @@ export class EntityVersionCreateComponent extends PageComponent implements OnIni
{entityName: this.entityName}), [Validators.required, Validators.pattern(/(?:.|\s)*\S(&:.|\s)*/)]], {entityName: this.entityName}), [Validators.required, Validators.pattern(/(?:.|\s)*\S(&:.|\s)*/)]],
saveRelations: [false, []], saveRelations: [false, []],
saveAttributes: [true, []], saveAttributes: [true, []],
saveCredentials: [true, []] saveCredentials: [true, []],
saveCalculatedFields: [true, []]
}); });
} }

View File

@ -187,18 +187,8 @@ export class ImportExportService {
}); });
} }
public importCalculatedField(entityId: EntityId): Observable<CalculatedField> { public openCalculatedFieldImportDialog(): Observable<CalculatedField> {
return this.openImportDialog('calculated-fields.import', 'calculated-fields.file').pipe( return this.openImportDialog('calculated-fields.import', 'calculated-fields.file').pipe(
mergeMap((calculatedField: CalculatedField) => {
if (!this.validateImportedCalculatedField({ entityId, ...calculatedField })) {
this.store.dispatch(new ActionNotificationShow(
{message: this.translate.instant('calculated-fields.invalid-file-error'),
type: 'error'}));
throw new Error('Invalid calculated field file');
} else {
return this.calculatedFieldsService.saveCalculatedField(this.prepareImport({ entityId, ...calculatedField }));
}
}),
catchError(() => of(null)), catchError(() => of(null)),
); );
} }
@ -989,16 +979,6 @@ export class ImportExportService {
} }
} }
private validateImportedCalculatedField(calculatedField: CalculatedField): boolean {
const { name, configuration, entityId } = calculatedField;
return isNotEmptyStr(name)
&& isDefined(configuration)
&& isDefined(entityId?.id)
&& !!Object.keys(configuration.arguments).length
&& isDefined(configuration.expression)
&& isDefined(configuration.output)
}
private validateImportedImage(image: ImageExportData): boolean { private validateImportedImage(image: ImageExportData): boolean {
return !(!isNotEmptyStr(image.data) return !(!isNotEmptyStr(image.data)
|| !isNotEmptyStr(image.title) || !isNotEmptyStr(image.title)

View File

@ -127,7 +127,7 @@ export const ArgumentTypeTranslations = new Map<ArgumentType, string>(
export interface CalculatedFieldArgument { export interface CalculatedFieldArgument {
refEntityKey: RefEntityKey; refEntityKey: RefEntityKey;
defaultValue?: string; defaultValue?: string;
refEntityId?: RefEntityKey; refEntityId?: RefEntityId;
limit?: number; limit?: number;
timeWindow?: number; timeWindow?: number;
} }
@ -138,7 +138,7 @@ export interface RefEntityKey {
scope?: AttributeScope; scope?: AttributeScope;
} }
export interface RefEntityKey { export interface RefEntityId {
entityType: ArgumentEntityType; entityType: ArgumentEntityType;
id: string; id: string;
} }
@ -563,12 +563,12 @@ export const getCalculatedFieldArgumentsHighlights = (
regex: `\\b${key}\\b`, regex: `\\b${key}\\b`,
next: argumentsObj[key].refEntityKey.type === ArgumentType.Rolling next: argumentsObj[key].refEntityKey.type === ArgumentType.Rolling
? 'calculatedFieldRollingArgumentValue' ? 'calculatedFieldRollingArgumentValue'
: 'start' : 'no_regex'
})); }));
const calculatedFieldCtxArgumentsHighlightRules = { const calculatedFieldCtxArgumentsHighlightRules = {
calculatedFieldCtxArgs: [ calculatedFieldCtxArgs: [
dotOperatorHighlightRule, dotOperatorHighlightRule,
...calculatedFieldArgumentsKeys.map(argumentRule => argumentRule.next === 'start' ? {...argumentRule, next: 'calculatedFieldSingleArgumentValue' } : argumentRule), ...calculatedFieldArgumentsKeys.map(argumentRule => argumentRule.next === 'no_regex' ? {...argumentRule, next: 'calculatedFieldSingleArgumentValue' } : argumentRule),
endGroupHighlightRule endGroupHighlightRule
] ]
}; };

View File

@ -1074,7 +1074,8 @@
"argument-type-required": "Argument type is required.", "argument-type-required": "Argument type is required.",
"max-args": "Maximum number of arguments reached.", "max-args": "Maximum number of arguments reached.",
"decimals-range": "Decimals by default should be a number between 0 and 15.", "decimals-range": "Decimals by default should be a number between 0 and 15.",
"expression": "Default expression demonstrates how to transform a temperature from Fahrenheit to Celsius." "expression": "Default expression demonstrates how to transform a temperature from Fahrenheit to Celsius.",
"arguments-entity-not-found": "Argument target entity not found."
} }
}, },
"confirm-on-exit": { "confirm-on-exit": {
@ -5570,12 +5571,12 @@
"max-calculated-fields": "Calculated fields per entity maximum number", "max-calculated-fields": "Calculated fields per entity maximum number",
"max-calculated-fields-range": "Calculated fields per entity maximum number can't be negative", "max-calculated-fields-range": "Calculated fields per entity maximum number can't be negative",
"max-calculated-fields-required": "Calculated fields per entity maximum number is required", "max-calculated-fields-required": "Calculated fields per entity maximum number is required",
"max-data-points-per-rolling-arg": "Maximum data points number in rolling arguments", "max-data-points-per-rolling-arg": "Max data points number in rolling arguments",
"max-data-points-per-rolling-arg-range": "Maximum data points number in rolling arguments can't be negative", "max-data-points-per-rolling-arg-range": "Max data points number in rolling arguments can't be negative",
"max-data-points-per-rolling-arg-required": "Maximum data points number in rolling arguments is required", "max-data-points-per-rolling-arg-required": "Max data points number in rolling arguments is required",
"max-arguments-per-cf": "Arguments per calculated field maximum number", "max-arguments-per-cf": "Arguments per calculated field max number",
"max-arguments-per-cf-range": "Arguments per calculated field maximum number can't be negative", "max-arguments-per-cf-range": "Arguments per calculated field max number can't be negative",
"max-arguments-per-cf-required": "Arguments per calculated field maximum number is required", "max-arguments-per-cf-required": "Arguments per calculated field max number is required",
"max-state-size": "State maximum size in KB", "max-state-size": "State maximum size in KB",
"max-state-size-range": "State maximum size in KB can't be negative", "max-state-size-range": "State maximum size in KB can't be negative",
"max-state-size-required": "State maximum size in KB is required", "max-state-size-required": "State maximum size in KB is required",