Merge pull request #13042 from maxunbearable/fix/5893-calculated-field-duplicate-argument

Fixed Calculated fields creation of arguments with same name
This commit is contained in:
Igor Kulikov 2025-03-27 18:26:28 +02:00 committed by GitHub
commit 8c9b48a9b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 84 additions and 51 deletions

View File

@ -160,7 +160,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
private getExpressionLabel(entity: CalculatedField): string { private getExpressionLabel(entity: CalculatedField): string {
if (entity.type === CalculatedFieldType.SCRIPT) { if (entity.type === CalculatedFieldType.SCRIPT) {
return 'function calculate(' + Object.keys(entity.configuration.arguments).join(', ') + ')'; return 'function calculate(ctx, ' + Object.keys(entity.configuration.arguments).join(', ') + ')';
} else { } else {
return entity.configuration.expression; return entity.configuration.expression;
} }

View File

@ -87,17 +87,17 @@
</ng-container> </ng-container>
<ng-container matColumnDef="actions" stickyEnd> <ng-container matColumnDef="actions" stickyEnd>
<mat-header-cell *matHeaderCellDef class="w-20 min-w-20"/> <mat-header-cell *matHeaderCellDef class="w-20 min-w-20"/>
<mat-cell *matCellDef="let argument; let $index = index"> <mat-cell *matCellDef="let argument;">
<div class="tb-form-table-row-cell-buttons flex w-20 min-w-20"> <div class="tb-form-table-row-cell-buttons flex w-20 min-w-20">
<button type="button" <button type="button"
mat-icon-button mat-icon-button
#button #button
(click)="manageArgument($event, button, argument, $index)" (click)="manageArgument($event, button, argument)"
[matTooltip]="'action.edit' | translate" [matTooltip]="'action.edit' | translate"
matTooltipPosition="above"> matTooltipPosition="above">
<mat-icon <mat-icon
[matBadgeHidden]="!(argument.refEntityKey.type === ArgumentType.Rolling [matBadgeHidden]="!(argument.refEntityKey.type === ArgumentType.Rolling
&& calculatedFieldType === CalculatedFieldType.SIMPLE) && !entityNameErrorSet.has(argument.refEntityId?.id)" && calculatedFieldType === CalculatedFieldType.SIMPLE) && argument.refEntityId?.id !== NULL_UUID"
matBadgeColor="warn" matBadgeColor="warn"
matBadgeSize="small" matBadgeSize="small"
matBadge="*" matBadge="*"

View File

@ -28,7 +28,6 @@ import {
ViewContainerRef, ViewContainerRef,
} from '@angular/core'; } from '@angular/core';
import { import {
AbstractControl,
ControlValueAccessor, ControlValueAccessor,
FormBuilder, FormBuilder,
NG_VALIDATORS, NG_VALIDATORS,
@ -50,7 +49,7 @@ import { TbPopoverService } from '@shared/components/popover.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EntityId } from '@shared/models/id/entity-id'; import { EntityId } from '@shared/models/id/entity-id';
import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models';
import { getEntityDetailsPageURL, isDefined, isDefinedAndNotNull, isEqual } from '@core/utils'; import { getEntityDetailsPageURL, isEqual } from '@core/utils';
import { TbPopoverComponent } from '@shared/components/popover.component'; import { TbPopoverComponent } from '@shared/components/popover.component';
import { TbTableDatasource } from '@shared/components/table/table-datasource.abstract'; import { TbTableDatasource } from '@shared/components/table/table-datasource.abstract';
import { EntityService } from '@core/http/entity.service'; import { EntityService } from '@core/http/entity.service';
@ -58,8 +57,9 @@ 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 { forkJoin, Observable } from 'rxjs';
import { NEVER } from 'rxjs'; import { NULL_UUID } from '@shared/models/id/has-uuid';
import { BaseData } from '@shared/models/base-data';
@Component({ @Component({
selector: 'tb-calculated-field-arguments-table', selector: 'tb-calculated-field-arguments-table',
@ -88,9 +88,8 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
@ViewChild(MatSort, { static: true }) sort: MatSort; @ViewChild(MatSort, { static: true }) sort: MatSort;
errorText = ''; errorText = '';
argumentsFormArray = this.fb.array<AbstractControl>([]); argumentsFormArray = this.fb.array<CalculatedFieldArgumentValue>([]);
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();
@ -100,6 +99,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
readonly ArgumentType = ArgumentType; readonly ArgumentType = ArgumentType;
readonly CalculatedFieldType = CalculatedFieldType; readonly CalculatedFieldType = CalculatedFieldType;
readonly maxArgumentsPerCF = getCurrentAuthState(this.store).maxArgumentsPerCF; readonly maxArgumentsPerCF = getCurrentAuthState(this.store).maxArgumentsPerCF;
readonly NULL_UUID = NULL_UUID;
private popoverComponent: TbPopoverComponent<CalculatedFieldArgumentPanelComponent>; private popoverComponent: TbPopoverComponent<CalculatedFieldArgumentPanelComponent>;
private propagateChange: (argumentsObj: Record<string, CalculatedFieldArgument>) => void = () => {}; private propagateChange: (argumentsObj: Record<string, CalculatedFieldArgument>) => void = () => {};
@ -115,7 +115,6 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
private store: Store<AppState> private store: Store<AppState>
) { ) {
this.argumentsFormArray.valueChanges.pipe(takeUntilDestroyed()).subscribe(value => { this.argumentsFormArray.valueChanges.pipe(takeUntilDestroyed()).subscribe(value => {
this.updateEntityNameMap(value);
this.updateDataSource(value); this.updateDataSource(value);
this.propagateChange(this.getArgumentsObject(value)); this.propagateChange(this.getArgumentsObject(value));
}); });
@ -154,7 +153,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
this.argumentsFormArray.markAsDirty(); this.argumentsFormArray.markAsDirty();
} }
manageArgument($event: Event, matButton: MatButton, argument = {} as CalculatedFieldArgumentValue, index?: number): void { manageArgument($event: Event, matButton: MatButton, argument = {} as CalculatedFieldArgumentValue): void {
$event?.stopPropagation(); $event?.stopPropagation();
if (this.popoverComponent && !this.popoverComponent.tbHidden) { if (this.popoverComponent && !this.popoverComponent.tbHidden) {
this.popoverComponent.hide(); this.popoverComponent.hide();
@ -163,15 +162,16 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
if (this.popoverService.hasPopover(trigger)) { if (this.popoverService.hasPopover(trigger)) {
this.popoverService.hidePopover(trigger); this.popoverService.hidePopover(trigger);
} else { } else {
const index = this.argumentsFormArray.controls.findIndex(control => isEqual(control.value, argument));
const isExists = index !== -1;
const ctx = { const ctx = {
index, index,
argument, argument,
entityId: this.entityId, entityId: this.entityId,
calculatedFieldType: this.calculatedFieldType, calculatedFieldType: this.calculatedFieldType,
buttonTitle: this.argumentsFormArray.at(index)?.value ? 'action.apply' : 'action.add', buttonTitle: isExists ? '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({ this.popoverComponent = this.popoverService.displayPopover({
@ -179,19 +179,20 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
renderer: this.renderer, renderer: this.renderer,
componentType: CalculatedFieldArgumentPanelComponent, componentType: CalculatedFieldArgumentPanelComponent,
hostView: this.viewContainerRef, hostView: this.viewContainerRef,
preferredPlacement: isDefined(index) ? 'left' : 'right', preferredPlacement: isExists ? 'left' : 'right',
context: ctx, context: ctx,
isModal: true isModal: true
}); });
this.popoverComponent.tbComponentRef.instance.argumentsDataApplied.subscribe(({ value, index }) => { this.popoverComponent.tbComponentRef.instance.argumentsDataApplied.subscribe(({ entityName, ...value }) => {
this.popoverComponent.hide(); this.popoverComponent.hide();
const formGroup = this.fb.group(value); if (entityName) {
if (isDefinedAndNotNull(index)) { this.entityNameMap.set(value.refEntityId.id, entityName);
this.argumentsFormArray.setControl(index, formGroup); }
} else { if (isExists) {
this.argumentsFormArray.push(formGroup); this.argumentsFormArray.at(index).setValue(value);
} else {
this.argumentsFormArray.push(this.fb.control(value));
} }
formGroup.markAsDirty();
this.cd.markForCheck(); this.cd.markForCheck();
}); });
} }
@ -206,7 +207,7 @@ 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) { } else if (this.argumentsFormArray.controls.some(control => control.value.refEntityId?.id === NULL_UUID)) {
this.errorText = 'calculated-fields.hint.arguments-entity-not-found'; 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';
@ -225,7 +226,8 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
writeValue(argumentsObj: Record<string, CalculatedFieldArgument>): void { writeValue(argumentsObj: Record<string, CalculatedFieldArgument>): void {
this.argumentsFormArray.clear(); this.argumentsFormArray.clear();
this.populateArgumentsFormArray(argumentsObj) this.populateArgumentsFormArray(argumentsObj);
this.updateEntityNameMap(this.argumentsFormArray.value);
} }
getEntityDetailsPageURL(id: string, type: EntityType): string { getEntityDetailsPageURL(id: string, type: EntityType): string {
@ -238,27 +240,48 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
...argumentsObj[key], ...argumentsObj[key],
argumentName: key argumentName: key
}; };
this.argumentsFormArray.push(this.fb.group(value), { emitEvent: false }); this.argumentsFormArray.push(this.fb.control(value), { emitEvent: false });
}); });
this.argumentsFormArray.updateValueAndValidity(); this.argumentsFormArray.updateValueAndValidity();
} }
private updateEntityNameMap(value: CalculatedFieldArgumentValue[]): void { private updateEntityNameMap(values: CalculatedFieldArgumentValue[]): void {
this.entityNameErrorSet.clear(); const entitiesByType = values.reduce((acc, { refEntityId = {}}) => {
value.forEach(({ refEntityId = {}}) => { if (refEntityId.id && refEntityId.entityType !== ArgumentEntityType.Tenant) {
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 }) acc[entityType] = acc[entityType] ?? [];
.pipe( acc[entityType].push(id);
catchError(() => {
this.entityNameErrorSet.add(id);
return NEVER;
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(entity => this.entityNameMap.set(id, entity.name));
} }
}); 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: CalculatedFieldArgumentValue[]): 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.argumentsFormArray.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.argumentsFormArray.updateValueAndValidity();
}
});
} }
private getSortValue(argument: CalculatedFieldArgumentValue, column: string): string { private getSortValue(argument: CalculatedFieldArgumentValue, column: string): string {

View File

@ -88,6 +88,7 @@
[placeholder]="'action.set' | translate" [placeholder]="'action.set' | translate"
[required]="true" [required]="true"
[entityType]="ArgumentEntityTypeParamsMap.get(entityType).entityType" [entityType]="ArgumentEntityTypeParamsMap.get(entityType).entityType"
(entityChanged)="entityNameSubject.next($event?.name)"
/> />
</div> </div>
} }

View File

@ -36,12 +36,13 @@ import { EntityId } from '@shared/models/id/entity-id';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EntityFilter } from '@shared/models/query/query.models'; import { EntityFilter } from '@shared/models/query/query.models';
import { AliasFilterType } from '@shared/models/alias.models'; import { AliasFilterType } from '@shared/models/alias.models';
import { merge } from 'rxjs'; import { BehaviorSubject, merge } from 'rxjs';
import { MINUTE } from '@shared/models/time/time.models'; 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'; import { EntityAutocompleteComponent } from '@shared/components/entity/entity-autocomplete.component';
import { NULL_UUID } from '@shared/models/id/has-uuid';
@Component({ @Component({
selector: 'tb-calculated-field-argument-panel', selector: 'tb-calculated-field-argument-panel',
@ -51,18 +52,16 @@ import { EntityAutocompleteComponent } from '@shared/components/entity/entity-au
export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewInit { export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewInit {
@Input() buttonTitle: string; @Input() buttonTitle: string;
@Input() index: number;
@Input() argument: CalculatedFieldArgumentValue; @Input() argument: CalculatedFieldArgumentValue;
@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; @ViewChild('entityAutocomplete') entityAutocomplete: EntityAutocompleteComponent;
argumentsDataApplied = output<{ value: CalculatedFieldArgumentValue, index: number }>(); argumentsDataApplied = output<CalculatedFieldArgumentValue>();
readonly maxDataPointsPerRollingArg = getCurrentAuthState(this.store).maxDataPointsPerRollingArg; readonly maxDataPointsPerRollingArg = getCurrentAuthState(this.store).maxDataPointsPerRollingArg;
readonly defaultLimit = Math.floor(this.maxDataPointsPerRollingArg / 10); readonly defaultLimit = Math.floor(this.maxDataPointsPerRollingArg / 10);
@ -85,6 +84,7 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
argumentTypes: ArgumentType[]; argumentTypes: ArgumentType[];
entityFilter: EntityFilter; entityFilter: EntityFilter;
entityNameSubject = new BehaviorSubject<string>(null);
readonly argumentEntityTypes = Object.values(ArgumentEntityType) as ArgumentEntityType[]; readonly argumentEntityTypes = Object.values(ArgumentEntityType) as ArgumentEntityType[];
readonly ArgumentEntityTypeTranslations = ArgumentEntityTypeTranslations; readonly ArgumentEntityTypeTranslations = ArgumentEntityTypeTranslations;
@ -106,7 +106,7 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
private store: Store<AppState> private store: Store<AppState>
) { ) {
this.observeEntityFilterChanges(); this.observeEntityFilterChanges();
this.observeEntityTypeChanges() this.observeEntityTypeChanges();
this.observeEntityKeyChanges(); this.observeEntityKeyChanges();
this.observeUpdatePosition(); this.observeUpdatePosition();
} }
@ -141,7 +141,7 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
} }
ngAfterViewInit(): void { ngAfterViewInit(): void {
if (this.entityHasError) { if (this.argument.refEntityId?.id === NULL_UUID) {
this.entityAutocomplete.selectEntityFormGroup.get('entity').markAsTouched(); this.entityAutocomplete.selectEntityFormGroup.get('entity').markAsTouched();
} }
} }
@ -152,11 +152,14 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
if (refEntityId.entityType === ArgumentEntityType.Tenant) { if (refEntityId.entityType === ArgumentEntityType.Tenant) {
refEntityId.id = this.tenantId; refEntityId.id = this.tenantId;
} }
if (refEntityId.entityType !== ArgumentEntityType.Current && refEntityId.entityType !== ArgumentEntityType.Tenant) {
value.entityName = this.entityNameSubject.value;
}
if (value.defaultValue) { if (value.defaultValue) {
value.defaultValue = value.defaultValue.trim(); value.defaultValue = value.defaultValue.trim();
} }
value.refEntityKey.key = value.refEntityKey.key.trim(); value.refEntityKey.key = value.refEntityKey.key.trim();
this.argumentsDataApplied.emit({ value, index: this.index }); this.argumentsDataApplied.emit(value);
} }
cancel(): void { cancel(): void {
@ -212,12 +215,16 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
} }
private observeEntityTypeChanges(): void { private observeEntityTypeChanges(): void {
this.argumentFormGroup.get('refEntityId').get('entityType').valueChanges this.refEntityIdFormGroup.get('entityType').valueChanges
.pipe(distinctUntilChanged(), takeUntilDestroyed()) .pipe(distinctUntilChanged(), takeUntilDestroyed())
.subscribe(type => { .subscribe(type => {
this.argumentFormGroup.get('refEntityId').get('id').setValue(''); this.argumentFormGroup.get('refEntityId').get('id').setValue('');
const isEntityWithId = type !== ArgumentEntityType.Tenant && type !== ArgumentEntityType.Current;
this.argumentFormGroup.get('refEntityId') this.argumentFormGroup.get('refEntityId')
.get('id')[type === ArgumentEntityType.Tenant || type === ArgumentEntityType.Current ? 'disable' : 'enable'](); .get('id')[isEntityWithId ? 'enable' : 'disable']();
if (!isEntityWithId) {
this.entityNameSubject.next(null);
}
if (!this.enableAttributeScopeSelection) { if (!this.enableAttributeScopeSelection) {
this.refEntityKeyFormGroup.get('scope').setValue(AttributeScope.SERVER_SCOPE); this.refEntityKeyFormGroup.get('scope').setValue(AttributeScope.SERVER_SCOPE);
} }

View File

@ -153,5 +153,6 @@ export class EntityDebugSettingsButtonComponent implements ControlValueAccessor
} else { } else {
this.debugSettingsFormGroup.enable({emitEvent: false}); this.debugSettingsFormGroup.enable({emitEvent: false});
} }
this.cd.markForCheck();
} }
} }

View File

@ -53,7 +53,7 @@
color="primary" color="primary"
type="button" type="button"
(click)="onCancel(); additionalActionConfig.action()"> (click)="onCancel(); additionalActionConfig.action()">
{{ additionalActionConfig.title | translate }} {{ additionalActionConfig.title }}
</button> </button>
} }
</div> </div>

View File

@ -144,6 +144,7 @@ export interface RefEntityId {
export interface CalculatedFieldArgumentValue extends CalculatedFieldArgument { export interface CalculatedFieldArgumentValue extends CalculatedFieldArgument {
argumentName: string; argumentName: string;
entityName?: string;
} }
export type CalculatedFieldTestScriptFn = (calculatedField: CalculatedField, argumentsObj?: Record<string, unknown>, closeAllOnSave?: boolean) => Observable<string>; export type CalculatedFieldTestScriptFn = (calculatedField: CalculatedField, argumentsObj?: Record<string, unknown>, closeAllOnSave?: boolean) => Observable<string>;

View File

@ -1000,8 +1000,8 @@
"calculated-field": "calculated field", "calculated-field": "calculated field",
"hint": { "hint": {
"main-limited": "No more than {{msg}} {{entity}} debug messages per {{time}} will be recorded.", "main-limited": "No more than {{msg}} {{entity}} debug messages per {{time}} will be recorded.",
"on-failure": "Log all debug messages.", "on-failure": "Log error messages only.",
"all-messages": "Log error messages only. " "all-messages": "Log all debug messages."
} }
}, },
"calculated-fields": { "calculated-fields": {