Merge pull request #12743 from maxunbearable/feature/calculated-fields-adjustments-24-02

Calculated fields adjustments
This commit is contained in:
Vladyslav Prykhodko 2025-02-26 15:41:51 +02:00 committed by GitHub
commit bd1e67a185
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 227 additions and 171 deletions

View File

@ -16,80 +16,78 @@
-->
<div class="flex flex-col gap-3">
<div class="tb-form-table">
<div class="tb-form-table-header">
<div class="argument-name-field tb-form-table-header-cell w-1/6 xs:w-1/3 sm:w-1/4" tbTruncateWithTooltip>{{ 'calculated-fields.argument-name' | translate }}</div>
<div class="tb-form-table-header-cell w-1/3 xs:hidden">{{ 'calculated-fields.datasource' | translate }}</div>
<div class="tb-form-table-header-cell w-1/6 lt-md:hidden">{{ 'common.type' | translate }}</div>
<div class="tb-form-table-header-cell w-1/6 xs:w-1/3">{{ 'entity.key' | translate }}</div>
<div class="tb-form-table-header-cell w-20 min-w-20"></div>
</div>
<div class="tb-form-table-body">
@for (group of argumentsFormArray.controls; track group) {
<div [formGroup]="group" class="tb-form-table-row">
<mat-form-field appearance="outline" class="argument-name-field tb-inline-field w-1/6 xs:w-1/3 sm:w-1/4" subscriptSizing="dynamic">
<input matInput formControlName="argumentName" placeholder="{{ 'action.set' | translate }}">
</mat-form-field>
<section class="datasource-field flex w-1/3 gap-2 xs:hidden">
@if (group.get('refEntityId')?.get('id')?.value) {
<ng-container [formGroup]="group.get('refEntityId')">
<mat-form-field appearance="outline" class="tb-inline-field w-1/2" subscriptSizing="dynamic">
<mat-select [value]="group.get('refEntityId').get('entityType').value" formControlName="entityType">
<mat-option [value]="group.get('refEntityId').get('entityType').value">
{{ entityTypeTranslations.get(group.get('refEntityId').get('entityType').value)?.type | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<tb-entity-autocomplete
class="entity-field w-1/2"
formControlName="id"
[inlineField]="true"
[hideLabel]="true"
[placeholder]="'action.set' | translate"
[entityType]="group.get('refEntityId').get('entityType').value"
/>
<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/4 xs:!w-1/2">
<div tbTruncateWithTooltip>{{ 'common.name' | translate }}</div>
</mat-header-cell>
<mat-cell *matCellDef="let argument" class="w-1/4 xs:w-1/2">
<div tbTruncateWithTooltip>{{ argument.argumentName }}</div>
</mat-cell>
</ng-container>
} @else {
<mat-form-field appearance="outline" class="tb-inline-field flex-1" subscriptSizing="dynamic">
<mat-select [value]="'current'" [disabled]="true">
<mat-option [value]="'current'">
{{
(group.get('refEntityId')?.get('entityType')?.value === ArgumentEntityType.Tenant
? 'calculated-fields.argument-current-tenant'
: 'calculated-fields.argument-current') | translate
}}
</mat-option>
</mat-select>
</mat-form-field>
}
</section>
<ng-container [formGroup]="group.get('refEntityKey')">
<mat-form-field appearance="outline" class="tb-inline-field w-1/6 lt-md:hidden" subscriptSizing="dynamic">
@if (group.get('refEntityKey').get('type').value; as type) {
<mat-select [value]="type" formControlName="type">
<mat-option [value]="type">
{{ ArgumentTypeTranslations.get(type) | translate }}
</mat-option>
</mat-select>
}
</mat-form-field>
<mat-chip-listbox formControlName="key" class="tb-inline-field entity-key-field w-1/6 xs:w-1/3">
<mat-chip>
<ng-container [matColumnDef]="'entityType'">
<mat-header-cell mat-sort-header *matHeaderCellDef class="entity-type-header w-1/4 xs:hidden">
{{ 'entity.entity-type' | translate }}
</mat-header-cell>
<mat-cell *matCellDef="let argument" class="w-1/4 xs:hidden">
<div tbTruncateWithTooltip>
{{ group.get('refEntityKey').get('key').value }}
@if (argument.refEntityId?.entityType === ArgumentEntityType.Tenant) {
{{ 'calculated-fields.argument-current-tenant' | translate }}
} @else if (argument.refEntityId?.id) {
{{ entityTypeTranslations.get(argument.refEntityId.entityType).type | translate }}
} @else {
{{ 'calculated-fields.argument-current' | translate }}
}
</div>
</mat-chip>
</mat-chip-listbox>
</mat-cell>
</ng-container>
<ng-container [matColumnDef]="'target'">
<mat-header-cell *matHeaderCellDef class="w-1/4 xs:hidden">
{{ 'entity-view.target-entity' | translate }}
</mat-header-cell>
<mat-cell *matCellDef="let argument" class="w-1/4 xs:hidden">
<div tbTruncateWithTooltip>
@if (argument.refEntityId?.id) {
<a aria-label="Open entity details page"
[routerLink]="getEntityDetailsPageURL(argument.refEntityId.id, argument.refEntityId.entityType)">
{{ entityNameMap.get(argument.refEntityId.id) ?? '' }}
</a>
}
</div>
</mat-cell>
</ng-container>
<ng-container [matColumnDef]="'type'">
<mat-header-cell mat-sort-header *matHeaderCellDef class="w-1/4 lt-md:hidden">
{{ 'common.type' | translate }}
</mat-header-cell>
<mat-cell *matCellDef="let argument" class="w-1/4 lt-md:hidden">
<div tbTruncateWithTooltip>{{ ArgumentTypeTranslations.get(argument.refEntityKey.type) | translate }}</div>
</mat-cell>
</ng-container>
<ng-container [matColumnDef]="'key'">
<mat-header-cell mat-sort-header *matHeaderCellDef class="w-1/4 xs:w-1/3">
{{ 'entity.key' | translate }}
</mat-header-cell>
<mat-cell *matCellDef="let argument" class="w-1/4 xs:w-1/3">
<mat-chip>
<div tbTruncateWithTooltip class="key-text">{{ argument.refEntityKey.key }}</div>
</mat-chip>
</mat-cell>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<mat-header-cell *matHeaderCellDef class="w-20 min-w-20"/>
<mat-cell *matCellDef="let argument; let $index = index">
<div class="tb-form-table-row-cell-buttons flex w-20 min-w-20">
<button type="button"
mat-icon-button
#button
(click)="manageArgument($event, button, $index)"
(click)="manageArgument($event, button, argument, $index)"
[matTooltip]="'action.edit' | translate"
matTooltipPosition="above">
<mat-icon
[matBadgeHidden]="!(group.get('refEntityKey').get('type').value === ArgumentType.Rolling
[matBadgeHidden]="!(argument.refEntityKey.type === ArgumentType.Rolling
&& calculatedFieldType === CalculatedFieldType.SIMPLE)"
matBadgeColor="warn"
matBadgeSize="small"
@ -100,19 +98,25 @@
</button>
<button type="button"
mat-icon-button
(click)="onDelete($index)"
(click)="onDelete($event, $index)"
[matTooltip]="'action.delete' | translate"
matTooltipPosition="above">
<mat-icon>delete</mat-icon>
</button>
</div>
</div>
} @empty {
<span class="tb-prompt flex items-center justify-center">{{ 'calculated-fields.no-arguments' | translate }}</span>
}
</mat-cell>
</ng-container>
<mat-header-row class="mat-row-select"
*matHeaderRowDef="['name', 'entityType', 'target', 'type', 'key', 'actions']; sticky: true"></mat-header-row>
<mat-row
*matRowDef="let argument; columns: ['name', 'entityType', 'target', 'type', 'key', 'actions']"></mat-row>
</table>
<div [class.!hidden]="(dataSource.isEmpty() | async) === false"
class="tb-prompt flex flex-1 items-end justify-center">
{{ 'calculated-fields.no-arguments' | translate }}
</div>
@if (errorText) {
<tb-error noMargin [error]="errorText | translate" class="pl-3"/>
<tb-error noMargin [error]="errorText | translate" class="flex h-9 items-center pl-3"/>
}
</div>
<div>

View File

@ -13,11 +13,21 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@use '../../../../../../../scss/constants' as constants;
:host {
.entity-key-field {
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
.arguments-table {
min-height: 108px;
&-with-error {
min-height: 150px;
}
.mat-mdc-table {
table-layout: fixed;
}
.key-text {
font-size: 13px;
}
}
.tb-form-table-row-cell-buttons {
@ -25,38 +35,24 @@
--mat-badge-small-size-container-overlap-offset: -5px;
--mat-badge-small-size-text-size: 0;
}
.argument-name-field {
@media #{constants.$mat-sm} {
min-width: 25%;
max-width: 25%;
}
@media #{constants.$mat-xs} {
min-width: 33%;
max-width: 33%;
}
}
.datasource-field {
min-width: 33%;
max-width: 33%;
}
}
:host ::ng-deep {
.entity-field {
a {
font-size: 14px;
white-space: nowrap;
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
}
.mat-mdc-standard-chip {
.mdc-evolution-chip__cell--primary, .mdc-evolution-chip__action--primary, .mdc-evolution-chip__text-label {
overflow: hidden;
}
}
.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;
}
}
}

View File

@ -15,20 +15,22 @@
///
import {
AfterViewInit,
ChangeDetectorRef,
Component,
DestroyRef,
forwardRef,
Input,
OnChanges,
Renderer2,
SimpleChanges,
ViewChild,
ViewContainerRef,
} from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
FormBuilder,
FormGroup,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
@ -48,8 +50,11 @@ 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 { isDefined, isDefinedAndNotNull } from '@core/utils';
import { getEntityDetailsPageURL, isDefined, isDefinedAndNotNull } 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';
@Component({
selector: 'tb-calculated-field-arguments-table',
@ -68,19 +73,23 @@ import { TbPopoverComponent } from '@shared/components/popover.component';
}
],
})
export class CalculatedFieldArgumentsTableComponent implements ControlValueAccessor, Validator, OnChanges {
export class CalculatedFieldArgumentsTableComponent implements ControlValueAccessor, Validator, OnChanges, AfterViewInit {
@Input() entityId: EntityId;
@Input() tenantId: string;
@Input() entityName: string;
@Input() calculatedFieldType: CalculatedFieldType;
@ViewChild(MatSort, { static: true }) sort: MatSort;
errorText = '';
argumentsFormArray = this.fb.array<AbstractControl>([]);
entityNameMap = new Map<string, string>();
sortOrder = { direction: 'asc', property: '' };
dataSource = new CalculatedFieldArgumentDatasource();
readonly entityTypeTranslations = entityTypeTranslations;
readonly ArgumentTypeTranslations = ArgumentTypeTranslations;
readonly EntityType = EntityType;
readonly ArgumentEntityType = ArgumentEntityType;
readonly ArgumentType = ArgumentType;
readonly CalculatedFieldType = CalculatedFieldType;
@ -93,10 +102,14 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
private popoverService: TbPopoverService,
private viewContainerRef: ViewContainerRef,
private cd: ChangeDetectorRef,
private renderer: Renderer2
private renderer: Renderer2,
private entityService: EntityService,
private destroyRef: DestroyRef,
) {
this.argumentsFormArray.valueChanges.pipe(takeUntilDestroyed()).subscribe(() => {
this.propagateChange(this.getArgumentsObject());
this.argumentsFormArray.valueChanges.pipe(takeUntilDestroyed()).subscribe(value => {
this.updateEntityNameMap(value);
this.updateDataSource(value);
this.propagateChange(this.getArgumentsObject(value));
});
}
@ -107,6 +120,14 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
}
}
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.argumentsFormArray.value);
});
}
registerOnChange(fn: (argumentsObj: Record<string, CalculatedFieldArgument>) => void): void {
this.propagateChange = fn;
}
@ -118,12 +139,13 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
return this.errorText ? { argumentsFormArray: false } : null;
}
onDelete(index: number): void {
onDelete($event: Event, index: number): void {
$event.stopPropagation();
this.argumentsFormArray.removeAt(index);
this.argumentsFormArray.markAsDirty();
}
manageArgument($event: Event, matButton: MatButton, index?: number): void {
manageArgument($event: Event, matButton: MatButton, argument = {} as CalculatedFieldArgumentValue, index?: number): void {
$event?.stopPropagation();
if (this.popoverComponent && !this.popoverComponent.tbHidden) {
this.popoverComponent.hide();
@ -132,16 +154,15 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
if (this.popoverService.hasPopover(trigger)) {
this.popoverService.hidePopover(trigger);
} else {
const argumentObj = this.argumentsFormArray.at(index)?.getRawValue() ?? {};
const ctx = {
index,
argument: argumentObj,
argument,
entityId: this.entityId,
calculatedFieldType: this.calculatedFieldType,
buttonTitle: this.argumentsFormArray.at(index)?.value ? 'action.apply' : 'action.add',
tenantId: this.tenantId,
entityName: this.entityName,
usedArgumentNames: this.argumentsFormArray.getRawValue().map(({ argumentName }) => argumentName).filter(name => name !== argumentObj.argumentName),
usedArgumentNames: this.argumentsFormArray.value.map(({ argumentName }) => argumentName).filter(name => name !== argument.argumentName),
};
this.popoverComponent = this.popoverService.displayPopover(trigger, this.renderer,
this.viewContainerRef, CalculatedFieldArgumentPanelComponent, isDefined(index) ? 'left' : 'right', false, null,
@ -150,7 +171,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
{}, {}, true);
this.popoverComponent.tbComponentRef.instance.argumentsDataApplied.subscribe(({ value, index }) => {
this.popoverComponent.hide();
const formGroup = this.getArgumentFormGroup(value);
const formGroup = this.fb.group(value);
if (isDefinedAndNotNull(index)) {
this.argumentsFormArray.setControl(index, formGroup);
} else {
@ -162,9 +183,14 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
}
}
private updateDataSource(value: CalculatedFieldArgumentValue[]): void {
const sortedValue = this.sortData(value);
this.dataSource.loadData(sortedValue);
}
private updateErrorText(): void {
if (this.calculatedFieldType === CalculatedFieldType.SIMPLE
&& this.argumentsFormArray.controls.some(control => control.get('refEntityKey').get('type').value === ArgumentType.Rolling)) {
&& this.argumentsFormArray.controls.some(control => control.value.refEntityKey.type === ArgumentType.Rolling)) {
this.errorText = 'calculated-fields.hint.arguments-simple-with-rolling';
} else if (!this.argumentsFormArray.controls.length) {
this.errorText = 'calculated-fields.hint.arguments-empty';
@ -173,9 +199,9 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
}
}
private getArgumentsObject(): Record<string, CalculatedFieldArgument> {
return this.argumentsFormArray.getRawValue().reduce((acc, rawValue) => {
const { argumentName, ...argument } = rawValue as CalculatedFieldArgumentValue;
private getArgumentsObject(value: CalculatedFieldArgumentValue[]): Record<string, CalculatedFieldArgument> {
return value.reduce((acc, argumentValue) => {
const { argumentName, ...argument } = argumentValue as CalculatedFieldArgumentValue;
acc[argumentName] = argument;
return acc;
}, {} as Record<string, CalculatedFieldArgument>);
@ -186,31 +212,62 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces
this.populateArgumentsFormArray(argumentsObj)
}
getEntityDetailsPageURL(id: string, type: EntityType): string {
return getEntityDetailsPageURL(id, type);
}
private populateArgumentsFormArray(argumentsObj: Record<string, CalculatedFieldArgument>): void {
Object.keys(argumentsObj).forEach(key => {
const value: CalculatedFieldArgumentValue = {
...argumentsObj[key],
argumentName: key
};
this.argumentsFormArray.push(this.getArgumentFormGroup(value), {emitEvent: false});
this.argumentsFormArray.push(this.fb.group(value), { emitEvent: false });
});
this.argumentsFormArray.updateValueAndValidity();
}
private updateEntityNameMap(value: CalculatedFieldArgumentValue[]): void {
value.forEach(({ refEntityId = {}}) => {
if (refEntityId.id && !this.entityNameMap.has(refEntityId.id)) {
const { id, entityType } = refEntityId as EntityId;
this.entityService.getEntity(entityType as EntityType, id, { ignoreLoading: true, ignoreErrors: true })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(entity => this.entityNameMap.set(id, entity.name));
}
});
}
private getArgumentFormGroup(value: CalculatedFieldArgumentValue): FormGroup {
return this.fb.group({
...value,
argumentName: [{ value: value.argumentName, disabled: true }],
...(value.refEntityId ? {
refEntityId: this.fb.group({
entityType: [{ value: value.refEntityId.entityType, disabled: true }],
id: [{ value: value.refEntityId.id , disabled: true }],
}),
} : {}),
refEntityKey: this.fb.group({
...value.refEntityKey,
type: [{ value: value.refEntityKey.type, disabled: true }],
key: [{ value: value.refEntityKey.key, disabled: true }],
}),
})
private getSortValue(argument: CalculatedFieldArgumentValue, column: string): string {
switch (column) {
case 'entityType':
if (argument.refEntityId?.entityType === ArgumentEntityType.Tenant) {
return 'calculated-fields.argument-current-tenant';
} else if (argument.refEntityId?.id) {
return entityTypeTranslations.get((argument.refEntityId)?.entityType as unknown as EntityType).type;
} else {
return 'calculated-fields.argument-current';
}
case 'type':
return ArgumentTypeTranslations.get(argument.refEntityKey.type);
case 'key':
return argument.refEntityKey.key;
default:
return argument.argumentName;
}
}
private sortData(data: CalculatedFieldArgumentValue[]): CalculatedFieldArgumentValue[] {
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 CalculatedFieldArgumentDatasource extends TbTableDatasource<CalculatedFieldArgumentValue> {
constructor() {
super();
}
}

View File

@ -143,10 +143,10 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
}
private applyDialogData(): void {
const { configuration = {}, type = CalculatedFieldType.SIMPLE, ...value } = this.data.value ?? {};
const { configuration = {}, type = CalculatedFieldType.SIMPLE, debugSettings = { failuresEnabled: true, allEnabled: true }, ...value } = this.data.value ?? {};
const { expression, ...restConfig } = configuration as CalculatedFieldConfiguration;
const updatedConfig = { ...restConfig , ['expression'+type]: expression };
this.fieldFormGroup.patchValue({ configuration: updatedConfig, type, ...value }, {emitEvent: false});
this.fieldFormGroup.patchValue({ configuration: updatedConfig, type, debugSettings, ...value }, {emitEvent: false});
}
private observeTypeChanges(): void {

View File

@ -16,7 +16,7 @@
import {
AdditionalDebugActionConfig,
EntityDebugSettings,
HasEntityDebugSettings,
HasTenantId,
HasVersion
} from '@shared/models/entity.models';
@ -34,8 +34,7 @@ import {
endGroupHighlightRule
} from '@shared/models/ace/ace.models';
export interface CalculatedField extends Omit<BaseData<CalculatedFieldId>, 'label'>, HasVersion, HasTenantId, ExportableEntity<CalculatedFieldId> {
debugSettings?: EntityDebugSettings;
export interface CalculatedField extends Omit<BaseData<CalculatedFieldId>, 'label'>, HasVersion, HasEntityDebugSettings, HasTenantId, ExportableEntity<CalculatedFieldId> {
configuration: CalculatedFieldConfiguration;
type: CalculatedFieldType;
entityId: EntityId;