feat: Actions V2 improvements in console (#9759)

# Which Problems Are Solved
This PR allows one to edit the order of Actions V2 Targets in an
Execution. Editing of Targets was also added back again.

# How the Problems Are Solved
One of the changes is the addition of the CorrectlyTypedExecution which
restricts the Grpc types a bit more to make working with them easier.
Some fields may be optional in the Grpc Protobuf but in reality are
always set.
Typings were generally improved to make them more accurate and safer to
work with.

# Additional Changes
Removal of the Actions V2 Feature flag as it will be enabled by default
anyways.

# Additional Context
This pr used some advanced Angular Signals logic which is very
interesting for future PR's.
- Part of the tasks from #7248

---------

Co-authored-by: Max Peintner <peintnerm@gmail.com>
(cherry picked from commit 56e0df67d5)
This commit is contained in:
Ramon
2025-04-23 11:21:14 +02:00
committed by Livio Spring
parent 7fceb5eaf8
commit 3348acdbab
37 changed files with 557 additions and 375 deletions

View File

@@ -27,7 +27,6 @@ import { LoginV2FeatureToggleComponent } from '../feature-toggle/login-v2-featur
// to add a new feature, add the key here and in the FEATURE_KEYS array
const FEATURE_KEYS = [
'actions',
'consoleUseV2UserApi',
'debugOidcParentError',
'disableUserTokenEvent',

View File

@@ -1,14 +1,14 @@
<cnsl-refresh-table (refreshed)="refresh.emit()" [loading]="(dataSource$ | async) === null">
<cnsl-refresh-table (refreshed)="refresh.emit()" [loading]="loading()">
<div actions>
<ng-content></ng-content>
</div>
<div class="table-wrapper">
<table *ngIf="dataSource$ | async as dataSource" mat-table class="table" aria-label="Elements" [dataSource]="dataSource">
<table mat-table class="table" aria-label="Elements" [dataSource]="dataSource">
<ng-container matColumnDef="condition">
<th mat-header-cell *matHeaderCellDef>{{ 'ACTIONSTWO.EXECUTION.TABLE.CONDITION' | translate }}</th>
<td mat-cell *cnslCellDef="let row; dataSource: dataSource">
<span *ngIf="row.condition?.conditionType?.value">
{{ row?.condition | condition }}
<span>
{{ row.execution.condition | condition }}
</span>
</td>
</ng-container>
@@ -16,7 +16,7 @@
<ng-container matColumnDef="type">
<th mat-header-cell *matHeaderCellDef>{{ 'ACTIONSTWO.EXECUTION.TABLE.TYPE' | translate }}</th>
<td mat-cell *cnslCellDef="let row; dataSource: dataSource">
{{ 'ACTIONSTWO.EXECUTION.TYPES.' + row?.condition?.conditionType?.case | translate }}
{{ 'ACTIONSTWO.EXECUTION.TYPES.' + row.execution.condition.conditionType.case | translate }}
</td>
</ng-container>
@@ -24,16 +24,9 @@
<th mat-header-cell *matHeaderCellDef>{{ 'ACTIONSTWO.EXECUTION.TABLE.TARGET' | translate }}</th>
<td mat-cell *cnslCellDef="let row; dataSource: dataSource">
<div class="target-key">
<cnsl-project-role-chip *ngFor="let target of filteredTargetTypes(row.targets) | async" [roleName]="target.name"
<cnsl-project-role-chip *ngFor="let target of row.mappedTargets; trackBy: trackTarget" [roleName]="target.name"
>{{ target.name }}
</cnsl-project-role-chip>
<cnsl-project-role-chip
*ngFor="let condition of filteredIncludeConditions(row.targets)"
[roleName]="condition | condition"
>
<mat-icon class="icon">refresh</mat-icon>
{{ condition | condition }}
</cnsl-project-role-chip>
</div>
</td>
</ng-container>
@@ -43,7 +36,7 @@
{{ 'ACTIONSTWO.EXECUTION.TABLE.CREATIONDATE' | translate }}
</th>
<td mat-cell *cnslCellDef="let row; dataSource: dataSource">
<span class="no-break">{{ row.creationDate | timestampToDate | localizedDate: 'regular' }}</span>
<span class="no-break">{{ row.execution.creationDate | timestampToDate | localizedDate: 'regular' }}</span>
</td>
</ng-container>
@@ -55,7 +48,7 @@
actions
matTooltip="{{ 'ACTIONS.REMOVE' | translate }}"
color="warn"
(click)="$event.stopPropagation(); delete.emit(row)"
(click)="$event.stopPropagation(); delete.emit(row.execution)"
mat-icon-button
>
<i class="las la-trash"></i>
@@ -69,6 +62,7 @@
class="highlight pointer"
mat-row
*matRowDef="let row; columns: ['condition', 'type', 'target', 'creationDate', 'actions']"
(click)="selected.emit(row.execution)"
></tr>
</table>
</div>

View File

@@ -1,9 +1,10 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { Observable, ReplaySubject } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { ChangeDetectionStrategy, Component, computed, effect, EventEmitter, Input, Output } from '@angular/core';
import { combineLatestWith, Observable, ReplaySubject } from 'rxjs';
import { filter, map, startWith } from 'rxjs/operators';
import { MatTableDataSource } from '@angular/material/table';
import { Condition, Execution, ExecutionTargetType } from '@zitadel/proto/zitadel/action/v2beta/execution_pb';
import { Target } from '@zitadel/proto/zitadel/action/v2beta/target_pb';
import { toSignal } from '@angular/core/rxjs-interop';
import { CorrectlyTypedExecution } from '../../actions-two-add-action/actions-two-add-action-dialog.component';
@Component({
selector: 'cnsl-actions-two-actions-table',
@@ -16,10 +17,13 @@ export class ActionsTwoActionsTableComponent {
public readonly refresh = new EventEmitter<void>();
@Output()
public readonly delete = new EventEmitter<Execution>();
public readonly selected = new EventEmitter<CorrectlyTypedExecution>();
@Output()
public readonly delete = new EventEmitter<CorrectlyTypedExecution>();
@Input({ required: true })
public set executions(executions: Execution[] | null) {
public set executions(executions: CorrectlyTypedExecution[] | null) {
this.executions$.next(executions);
}
@@ -28,33 +32,76 @@ export class ActionsTwoActionsTableComponent {
this.targets$.next(targets);
}
@Output()
public readonly selected = new EventEmitter<Execution>();
private readonly executions$ = new ReplaySubject<CorrectlyTypedExecution[] | null>(1);
private readonly executions$ = new ReplaySubject<Execution[] | null>(1);
private readonly targets$ = new ReplaySubject<Target[] | null>(1);
protected readonly dataSource$ = this.executions$.pipe(
filter(Boolean),
map((keys) => new MatTableDataSource(keys)),
protected readonly dataSource = this.getDataSource();
protected readonly loading = this.getLoading();
private getDataSource() {
const executions$: Observable<CorrectlyTypedExecution[]> = this.executions$.pipe(filter(Boolean), startWith([]));
const executionsSignal = toSignal(executions$, { requireSync: true });
const targetsMapSignal = this.getTargetsMap();
const dataSignal = computed(() => {
const executions = executionsSignal();
const targetsMap = targetsMapSignal();
if (targetsMap.size === 0) {
return [];
}
return executions.map((execution) => {
const mappedTargets = execution.targets.map((target) => {
const targetType = targetsMap.get(target.type.value);
if (!targetType) {
throw new Error(`Target with id ${target.type.value} not found`);
}
return targetType;
});
return { execution, mappedTargets };
});
});
const dataSource = new MatTableDataSource(dataSignal());
effect(() => {
const data = dataSignal();
if (dataSource.data !== data) {
dataSource.data = data;
}
});
return dataSource;
}
private getTargetsMap() {
const targets$ = this.targets$.pipe(filter(Boolean), startWith([] as Target[]));
const targetsSignal = toSignal(targets$, { requireSync: true });
return computed(() => {
const map = new Map<string, Target>();
for (const target of targetsSignal()) {
map.set(target.id, target);
}
return map;
});
}
private getLoading() {
const loading$ = this.executions$.pipe(
combineLatestWith(this.targets$),
map(([executions, targets]) => executions === null || targets === null),
startWith(true),
);
protected filteredTargetTypes(targets: ExecutionTargetType[]): Observable<Target[]> {
const targetIds = targets
.map((t) => t.type)
.filter((t): t is Extract<ExecutionTargetType['type'], { case: 'target' }> => t.case === 'target')
.map((t) => t.value);
return this.targets$.pipe(
filter(Boolean),
map((alltargets) => alltargets!.filter((target) => targetIds.includes(target.id))),
);
return toSignal(loading$, { requireSync: true });
}
protected filteredIncludeConditions(targets: ExecutionTargetType[]): Condition[] {
return targets
.map((t) => t.type)
.filter((t): t is Extract<ExecutionTargetType['type'], { case: 'include' }> => t.case === 'include')
.map(({ value }) => value);
protected trackTarget(_: number, target: Target) {
return target.id;
}
}

View File

@@ -2,7 +2,7 @@
<p class="cnsl-secondary-text">{{ 'ACTIONSTWO.EXECUTION.DESCRIPTION' | translate }}</p>
<cnsl-actions-two-actions-table
(refresh)="refresh.next(true)"
(refresh)="refresh$.next(true)"
(delete)="deleteExecution($event)"
(selected)="openDialog($event)"
[executions]="executions$ | async"

View File

@@ -1,19 +1,20 @@
import { ChangeDetectionStrategy, Component, DestroyRef, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, Component, DestroyRef } from '@angular/core';
import { ActionService } from 'src/app/services/action.service';
import { NewFeatureService } from 'src/app/services/new-feature.service';
import { defer, firstValueFrom, Observable, of, shareReplay, Subject, TimeoutError } from 'rxjs';
import { catchError, filter, map, startWith, switchMap, tap, timeout } from 'rxjs/operators';
import { lastValueFrom, Observable, of, Subject } from 'rxjs';
import { catchError, map, startWith, switchMap } from 'rxjs/operators';
import { ToastService } from 'src/app/services/toast.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { ORGANIZATIONS } from '../../settings-list/settings';
import { ActionTwoAddActionDialogComponent } from '../actions-two-add-action/actions-two-add-action-dialog.component';
import {
ActionTwoAddActionDialogComponent,
ActionTwoAddActionDialogData,
ActionTwoAddActionDialogResult,
CorrectlyTypedExecution,
correctlyTypeExecution,
} from '../actions-two-add-action/actions-two-add-action-dialog.component';
import { MatDialog } from '@angular/material/dialog';
import { MessageInitShape } from '@bufbuild/protobuf';
import { Execution, ExecutionSchema } from '@zitadel/proto/zitadel/action/v2beta/execution_pb';
import { SetExecutionRequestSchema } from '@zitadel/proto/zitadel/action/v2beta/action_service_pb';
import { Target } from '@zitadel/proto/zitadel/action/v2beta/target_pb';
import { Value } from 'google-protobuf/google/protobuf/struct_pb';
@Component({
selector: 'cnsl-actions-two-actions',
@@ -21,123 +22,92 @@ import { Value } from 'google-protobuf/google/protobuf/struct_pb';
styleUrls: ['./actions-two-actions.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ActionsTwoActionsComponent implements OnInit {
protected readonly refresh = new Subject<true>();
private readonly actionsEnabled$: Observable<boolean>;
protected readonly executions$: Observable<Execution[]>;
export class ActionsTwoActionsComponent {
protected readonly refresh$ = new Subject<true>();
protected readonly executions$: Observable<CorrectlyTypedExecution[]>;
protected readonly targets$: Observable<Target[]>;
constructor(
private readonly actionService: ActionService,
private readonly featureService: NewFeatureService,
private readonly toast: ToastService,
private readonly destroyRef: DestroyRef,
private readonly router: Router,
private readonly route: ActivatedRoute,
private readonly dialog: MatDialog,
) {
this.actionsEnabled$ = this.getActionsEnabled$().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.executions$ = this.getExecutions$(this.actionsEnabled$);
this.targets$ = this.getTargets$(this.actionsEnabled$);
this.executions$ = this.getExecutions$();
this.targets$ = this.getTargets$();
}
ngOnInit(): void {
// this also preloads
this.actionsEnabled$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(async (enabled) => {
if (enabled) {
return;
}
await this.router.navigate([], {
relativeTo: this.route,
queryParams: {
id: ORGANIZATIONS.id,
},
queryParamsHandling: 'merge',
});
});
}
private getExecutions$(actionsEnabled$: Observable<boolean>) {
return this.refresh.pipe(
private getExecutions$() {
return this.refresh$.pipe(
startWith(true),
switchMap(() => {
return this.actionService.listExecutions({});
}),
map(({ result }) => result),
catchError(async (err) => {
const actionsEnabled = await firstValueFrom(actionsEnabled$);
if (actionsEnabled) {
map(({ result }) => result.map(correctlyTypeExecution)),
catchError((err) => {
this.toast.showError(err);
}
return [];
return of([]);
}),
);
}
private getTargets$(actionsEnabled$: Observable<boolean>) {
return this.refresh.pipe(
private getTargets$() {
return this.refresh$.pipe(
startWith(true),
switchMap(() => {
return this.actionService.listTargets({});
}),
map(({ result }) => result),
catchError(async (err) => {
const actionsEnabled = await firstValueFrom(actionsEnabled$);
if (actionsEnabled) {
this.toast.showError(err);
}
return [];
}),
);
}
private getActionsEnabled$() {
return defer(() => this.featureService.getInstanceFeatures()).pipe(
map(({ actions }) => actions?.enabled ?? false),
timeout(1000),
catchError((err) => {
if (!(err instanceof TimeoutError)) {
this.toast.showError(err);
}
return of(false);
return of([]);
}),
);
}
public openDialog(execution?: Execution): void {
const ref = this.dialog.open<ActionTwoAddActionDialogComponent>(ActionTwoAddActionDialogComponent, {
public async openDialog(execution?: CorrectlyTypedExecution): Promise<void> {
const request$ = this.dialog
.open<ActionTwoAddActionDialogComponent, ActionTwoAddActionDialogData, ActionTwoAddActionDialogResult>(
ActionTwoAddActionDialogComponent,
{
width: '400px',
data: execution
? {
execution: execution,
execution,
}
: {},
});
},
)
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef));
ref.afterClosed().subscribe((request?: MessageInitShape<typeof SetExecutionRequestSchema>) => {
if (request) {
this.actionService
.setExecution(request)
.then(() => {
setTimeout(() => {
this.refresh.next(true);
}, 1000);
})
.catch((error) => {
const request = await lastValueFrom(request$);
if (!request) {
return;
}
try {
await this.actionService.setExecution(request);
await new Promise((res) => setTimeout(res, 1000));
this.refresh$.next(true);
} catch (error) {
console.error(error);
this.toast.showError(error);
});
}
});
}
public async deleteExecution(execution: Execution) {
public async deleteExecution(execution: CorrectlyTypedExecution) {
const deleteReq: MessageInitShape<typeof SetExecutionRequestSchema> = {
condition: execution.condition,
targets: [],
};
try {
await this.actionService.setExecution(deleteReq);
await new Promise((res) => setTimeout(res, 1000));
this.refresh.next(true);
this.refresh$.next(true);
} catch (error) {
console.error(error);
this.toast.showError(error);
}
}
}

View File

@@ -14,15 +14,15 @@
*ngSwitchCase="Page.Condition"
[conditionType]="typeSignal()"
(back)="back()"
(continue)="conditionSignal.set($event); continue()"
(continue)="conditionSignal.set({ conditionType: { case: typeSignal(), value: $event } }); continue()"
></cnsl-actions-two-add-action-condition>
<cnsl-actions-two-add-action-target
*ngSwitchCase="Page.Target"
(back)="back()"
[hideBackButton]="!!data.execution"
(continue)="targetSignal.set($event); continue()"
[selectedCondition]="data.execution?.condition"
(continue)="targetsSignal.set($event); continue()"
[preselectedTargetIds]="preselectedTargetIds"
></cnsl-actions-two-add-action-target>
</div>
</mat-dialog-content>

View File

@@ -7,6 +7,7 @@
.actions {
display: flex;
justify-content: space-between;
margin-top: 1rem;
}
.hide {

View File

@@ -7,11 +7,15 @@ import { MessageInitShape } from '@bufbuild/protobuf';
import {
ActionsTwoAddActionConditionComponent,
ConditionType,
ConditionTypeValue,
} from './actions-two-add-action-condition/actions-two-add-action-condition.component';
import { ActionsTwoAddActionTargetComponent } from './actions-two-add-action-target/actions-two-add-action-target.component';
import { CommonModule } from '@angular/common';
import { Execution, ExecutionTargetTypeSchema } from '@zitadel/proto/zitadel/action/v2beta/execution_pb';
import {
Condition,
Execution,
ExecutionTargetType,
ExecutionTargetTypeSchema,
} from '@zitadel/proto/zitadel/action/v2beta/execution_pb';
import { Subject } from 'rxjs';
import { SetExecutionRequestSchema } from '@zitadel/proto/zitadel/action/v2beta/action_service_pb';
@@ -21,6 +25,41 @@ enum Page {
Target,
}
export type CorrectlyTypedCondition = Condition & { conditionType: Extract<Condition['conditionType'], { case: string }> };
type CorrectlyTypedTargets = { type: Extract<ExecutionTargetType['type'], { case: 'target' }> };
export type CorrectlyTypedExecution = Omit<Execution, 'targets' | 'condition'> & {
condition: CorrectlyTypedCondition;
targets: CorrectlyTypedTargets[];
};
export const correctlyTypeExecution = (execution: Execution): CorrectlyTypedExecution => {
if (!execution.condition?.conditionType?.case) {
throw new Error('Condition is required');
}
const conditionType = execution.condition.conditionType;
const condition = {
...execution.condition,
conditionType,
};
return {
...execution,
condition,
targets: execution.targets
.map(({ type }) => ({ type }))
.filter((target): target is CorrectlyTypedTargets => target.type.case === 'target'),
};
};
export type ActionTwoAddActionDialogData = {
execution?: CorrectlyTypedExecution;
};
export type ActionTwoAddActionDialogResult = MessageInitShape<typeof SetExecutionRequestSchema>;
@Component({
selector: 'cnsl-actions-two-add-action-dialog',
templateUrl: './actions-two-add-action-dialog.component.html',
@@ -37,45 +76,45 @@ enum Page {
],
})
export class ActionTwoAddActionDialogComponent {
public Page = Page;
public page = signal<Page | undefined>(Page.Type);
protected readonly Page = Page;
protected readonly page = signal<Page>(Page.Type);
public typeSignal = signal<ConditionType>('request');
public conditionSignal = signal<ConditionTypeValue<ConditionType> | undefined>(undefined); // TODO: fix this type
public targetSignal = signal<Array<MessageInitShape<typeof ExecutionTargetTypeSchema>> | undefined>(undefined);
protected readonly typeSignal = signal<ConditionType>('request');
protected readonly conditionSignal = signal<MessageInitShape<typeof SetExecutionRequestSchema>['condition']>(undefined);
protected readonly targetsSignal = signal<MessageInitShape<typeof ExecutionTargetTypeSchema>[]>([]);
public continueSubject = new Subject<void>();
protected readonly continueSubject = new Subject<void>();
public request = computed<MessageInitShape<typeof SetExecutionRequestSchema>>(() => {
protected readonly request = computed<MessageInitShape<typeof SetExecutionRequestSchema>>(() => {
return {
condition: {
conditionType: {
case: this.typeSignal(),
value: this.conditionSignal() as any, // TODO: fix this type
},
},
targets: this.targetSignal(),
condition: this.conditionSignal(),
targets: this.targetsSignal(),
};
});
protected readonly preselectedTargetIds: string[] = [];
constructor(
public dialogRef: MatDialogRef<ActionTwoAddActionDialogComponent, MessageInitShape<typeof SetExecutionRequestSchema>>,
@Inject(MAT_DIALOG_DATA) protected readonly data: { execution?: Execution },
protected readonly dialogRef: MatDialogRef<ActionTwoAddActionDialogComponent, ActionTwoAddActionDialogResult>,
@Inject(MAT_DIALOG_DATA) protected readonly data: ActionTwoAddActionDialogData,
) {
if (data?.execution) {
this.typeSignal.set(data.execution.condition?.conditionType.case ?? 'request');
this.conditionSignal.set((data.execution.condition?.conditionType as any)?.value ?? undefined);
this.targetSignal.set(data.execution.targets ?? []);
this.page.set(Page.Target); // Set the initial page based on the provided execution data
}
effect(() => {
const currentPage = this.page();
if (currentPage === Page.Target) {
this.continueSubject.next(); // Trigger the Subject to request condition form when the page changes to "Target"
}
});
if (!data?.execution) {
return;
}
this.targetsSignal.set(data.execution.targets);
this.typeSignal.set(data.execution.condition.conditionType.case);
this.conditionSignal.set(data.execution.condition);
this.preselectedTargetIds = data.execution.targets.map((target) => target.type.value);
this.page.set(Page.Target); // Set the initial page based on the provided execution data
}
public continue() {

View File

@@ -1,37 +1,71 @@
<form *ngIf="targetForm" class="form-grid" [formGroup]="targetForm" (ngSubmit)="submit()">
<form class="form-grid" [formGroup]="form()" (ngSubmit)="submit()">
<p class="target-description">{{ 'ACTIONSTWO.EXECUTION.DIALOG.TARGET.DESCRIPTION' | translate }}</p>
<cnsl-form-field class="full-width">
<cnsl-label>{{ 'ACTIONSTWO.EXECUTION.DIALOG.TARGET.TARGET.DESCRIPTION' | translate }}</cnsl-label>
<mat-select formControlName="target">
<mat-option *ngIf="(executionTargets$ | async) === null" class="is-loading">
<input
cnslInput
#trigger="matAutocompleteTrigger"
#input
type="text"
[formControl]="form().controls.autocomplete"
[matAutocomplete]="autoservice"
(keydown.enter)="handleEnter($event); input.blur(); trigger.closePanel()"
/>
<mat-autocomplete #autoservice="matAutocomplete">
<mat-option *ngIf="targets().state === 'loading'" class="is-loading">
<mat-spinner diameter="30"></mat-spinner>
</mat-option>
<mat-option *ngFor="let target of executionTargets$ | async" [value]="target">
<mat-option
*ngFor="let target of selectableTargets(); trackBy: trackTarget"
#option
(click)="addTarget(target); option.deselect()"
[value]="target.name"
>
{{ target.name }}
</mat-option>
</mat-select>
</mat-autocomplete>
</cnsl-form-field>
<!-- <cnsl-form-field class="full-width">-->
<!-- <cnsl-label>{{ 'ACTIONSTWO.EXECUTION.DIALOG.TARGET.CONDITIONS.DESCRIPTION' | translate }}</cnsl-label>-->
<!-- <mat-select [multiple]="true" formControlName="executionConditions">-->
<!-- <mat-option *ngIf="(executionConditions$ | async) === null" class="is-loading">-->
<!-- <mat-spinner diameter="30"></mat-spinner>-->
<!-- </mat-option>-->
<!-- <mat-option *ngFor="let condition of executionConditions$ | async" [value]="condition">-->
<!-- <span>{{ condition | condition }}</span>-->
<!-- </mat-option>-->
<!-- </mat-select>-->
<!-- </cnsl-form-field>-->
<table mat-table cdkDropList (cdkDropListDropped)="drop($event)" [dataSource]="dataSource" [trackBy]="trackTarget">
<ng-container matColumnDef="order">
<th mat-header-cell *matHeaderCellDef>Reorder</th>
<td mat-cell *cnslCellDef="let row; let i = index; dataSource: dataSource">
<i class="las la-bars"></i>
</td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *cnslCellDef="let row; dataSource: dataSource">
<cnsl-project-role-chip [roleName]="row.name">{{ row.name }}</cnsl-project-role-chip>
</td>
</ng-container>
<ng-container matColumnDef="deleteAction" stickyEnd>
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *cnslCellDef="let i = index; dataSource: dataSource">
<cnsl-table-actions>
<button
actions
matTooltip="{{ 'ACTIONS.REMOVE' | translate }}"
color="warn"
(click)="removeTarget(i)"
mat-icon-button
>
<i class="las la-trash"></i>
</button>
</cnsl-table-actions>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="['order', 'name', 'deleteAction']"></tr>
<tr class="drag-row" cdkDrag mat-row *matRowDef="let row; columns: ['order', 'name', 'deleteAction']"></tr>
</table>
<div class="actions">
<button *ngIf="!hideBackButton" mat-stroked-button (click)="back.emit()">
{{ 'ACTIONS.BACK' | translate }}
</button>
<span class="fill-space"></span>
<button color="primary" [disabled]="targetForm.invalid" mat-raised-button type="submit">
<button color="primary" [disabled]="form().invalid" mat-raised-button type="submit">
{{ 'ACTIONS.CONTINUE' | translate }}
</button>
</div>

View File

@@ -10,3 +10,27 @@
font: 1;
}
}
.cdk-drag-preview {
box-sizing: border-box;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: space-between;
}
.cdk-drag-placeholder {
opacity: 0;
}
.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.cdk-drop-list-dragging .mat-row:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.drag-row {
backdrop-filter: blur(10px);
}

View File

@@ -1,10 +1,20 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
EventEmitter,
Input,
Output,
signal,
Signal,
} from '@angular/core';
import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { MatButtonModule } from '@angular/material/button';
import { FormBuilder, FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { Observable, catchError, defer, map, of, shareReplay, ReplaySubject, combineLatestWith } from 'rxjs';
import { ReplaySubject, switchMap } from 'rxjs';
import { MatRadioModule } from '@angular/material/radio';
import { ActionService } from 'src/app/services/action.service';
import { ToastService } from 'src/app/services/toast.service';
@@ -13,15 +23,18 @@ import { InputModule } from 'src/app/modules/input/input.module';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MessageInitShape } from '@bufbuild/protobuf';
import { Target } from '@zitadel/proto/zitadel/action/v2beta/target_pb';
import { SetExecutionRequestSchema } from '@zitadel/proto/zitadel/action/v2beta/action_service_pb';
import { Condition, ExecutionTargetTypeSchema } from '@zitadel/proto/zitadel/action/v2beta/execution_pb';
import { ExecutionTargetTypeSchema } from '@zitadel/proto/zitadel/action/v2beta/execution_pb';
import { MatSelectModule } from '@angular/material/select';
import { atLeastOneFieldValidator } from 'src/app/modules/form-field/validators/validators';
import { ActionConditionPipeModule } from 'src/app/pipes/action-condition-pipe/action-condition-pipe.module';
export type TargetInit = NonNullable<
NonNullable<MessageInitShape<typeof SetExecutionRequestSchema>['targets']>
>[number]['type'];
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { startWith } from 'rxjs/operators';
import { TypeSafeCellDefModule } from 'src/app/directives/type-safe-cell-def/type-safe-cell-def.module';
import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { minArrayLengthValidator } from '../../../form-field/validators/validators';
import { ProjectRoleChipModule } from '../../../project-role-chip/project-role-chip.module';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TableActionsModule } from '../../../table-actions/table-actions.module';
@Component({
standalone: true,
@@ -42,114 +55,172 @@ export type TargetInit = NonNullable<
MatButtonModule,
MatProgressSpinnerModule,
MatSelectModule,
MatTableModule,
TypeSafeCellDefModule,
CdkDrag,
CdkDropList,
ProjectRoleChipModule,
MatTooltipModule,
TableActionsModule,
],
})
export class ActionsTwoAddActionTargetComponent {
protected readonly targetForm = this.buildActionTargetForm();
@Input() public hideBackButton = false;
@Input()
public set preselectedTargetIds(preselectedTargetIds: string[]) {
this.preselectedTargetIds$.next(preselectedTargetIds);
}
@Output() public readonly back = new EventEmitter<void>();
@Output() public readonly continue = new EventEmitter<MessageInitShape<typeof ExecutionTargetTypeSchema>[]>();
@Input() public hideBackButton = false;
@Input() set selectedCondition(selectedCondition: Condition | undefined) {
this.selectedCondition$.next(selectedCondition);
}
private readonly selectedCondition$ = new ReplaySubject<Condition | undefined>(1);
private readonly preselectedTargetIds$ = new ReplaySubject<string[]>(1);
protected readonly executionTargets$: Observable<Target[]>;
protected readonly executionConditions$: Observable<Condition[]>;
protected readonly form: ReturnType<typeof this.buildForm>;
protected readonly targets: ReturnType<typeof this.listTargets>;
private readonly selectedTargetIds: Signal<string[]>;
protected readonly selectableTargets: Signal<Target[]>;
protected readonly dataSource: MatTableDataSource<Target>;
constructor(
private readonly fb: FormBuilder,
private readonly actionService: ActionService,
private readonly toast: ToastService,
) {
this.executionTargets$ = this.listExecutionTargets().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.executionConditions$ = this.listExecutionConditions().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.form = this.buildForm();
this.targets = this.listTargets();
this.selectedTargetIds = this.getSelectedTargetIds(this.form);
this.selectableTargets = this.getSelectableTargets(this.targets, this.selectedTargetIds);
this.dataSource = this.getDataSource(this.targets, this.selectedTargetIds);
}
private buildActionTargetForm() {
return this.fb.group(
{
target: new FormControl<Target | null>(null, { validators: [] }),
executionConditions: new FormControl<Condition[]>([], { validators: [] }),
},
{
validators: atLeastOneFieldValidator(['target', 'executionConditions']),
},
);
private buildForm() {
const preselectedTargetIds = toSignal(this.preselectedTargetIds$, { initialValue: [] as string[] });
return computed(() => {
return this.fb.group({
autocomplete: new FormControl('', { nonNullable: true }),
selectedTargetIds: new FormControl(preselectedTargetIds(), {
nonNullable: true,
validators: [minArrayLengthValidator(1)],
}),
});
});
}
private listExecutionTargets() {
return defer(() => this.actionService.listTargets({})).pipe(
map(({ result }) => result.filter(this.targetHasDetailsAndConfig)),
catchError((error) => {
private listTargets() {
const targetsSignal = signal({ state: 'loading' as 'loading' | 'loaded', targets: new Map<string, Target>() });
this.actionService
.listTargets({})
.then(({ result }) => {
const targets = result.reduce((acc, target) => {
acc.set(target.id, target);
return acc;
}, new Map<string, Target>());
targetsSignal.set({ state: 'loaded', targets });
})
.catch((error) => {
this.toast.showError(error);
return of([]);
});
return computed(targetsSignal);
}
private getSelectedTargetIds(form: typeof this.form) {
const selectedTargetIds$ = toObservable(form).pipe(
startWith(form()),
switchMap((form) => {
const { selectedTargetIds } = form.controls;
return selectedTargetIds.valueChanges.pipe(startWith(selectedTargetIds.value));
}),
);
return toSignal(selectedTargetIds$, { requireSync: true });
}
private listExecutionConditions(): Observable<Condition[]> {
const selectedConditionJson$ = this.selectedCondition$.pipe(map((c) => JSON.stringify(c)));
return defer(() => this.actionService.listExecutions({})).pipe(
combineLatestWith(selectedConditionJson$),
map(([executions, selectedConditionJson]) =>
executions.result.map((e) => e?.condition).filter(this.conditionIsDefinedAndNotCurrentOne(selectedConditionJson)),
),
catchError((error) => {
this.toast.showError(error);
return of([]);
}),
);
private getSelectableTargets(targets: typeof this.targets, selectedTargetIds: Signal<string[]>) {
return computed(() => {
const targetsCopy = new Map(targets().targets);
for (const selectedTargetId of selectedTargetIds()) {
targetsCopy.delete(selectedTargetId);
}
return Array.from(targetsCopy.values());
});
}
private conditionIsDefinedAndNotCurrentOne(selectedConditionJson?: string) {
return (condition?: Condition): condition is Condition => {
if (!condition) {
// condition is undefined so it is not of type Condition
return false;
}
if (!selectedConditionJson) {
// condition is defined, and we don't have a selectedCondition so we can return all conditions
return true;
}
// we only return conditions that are not the same as the selectedCondition
return JSON.stringify(condition) !== selectedConditionJson;
};
private getDataSource(targetsSignal: typeof this.targets, selectedTargetIdsSignal: Signal<string[]>) {
const selectedTargets = computed(() => {
// get this out of the loop so angular can track this dependency
// even if targets is empty
const { targets, state } = targetsSignal();
const selectedTargetIds = selectedTargetIdsSignal();
if (state === 'loading') {
return [];
}
private targetHasDetailsAndConfig(target: Target): target is Target {
return !!target.id && !!target.id;
return selectedTargetIds.map((id) => {
const target = targets.get(id);
if (!target) {
throw new Error(`Target with id ${id} not found`);
}
return target;
});
});
const dataSource = new MatTableDataSource<Target>(selectedTargets());
effect(() => {
dataSource.data = selectedTargets();
});
return dataSource;
}
protected addTarget(target: Target) {
const { selectedTargetIds } = this.form().controls;
selectedTargetIds.setValue([target.id, ...selectedTargetIds.value]);
this.form().controls.autocomplete.setValue('');
}
protected removeTarget(index: number) {
const { selectedTargetIds } = this.form().controls;
const data = [...selectedTargetIds.value];
data.splice(index, 1);
selectedTargetIds.setValue(data);
}
protected drop(event: CdkDragDrop<undefined>) {
const { selectedTargetIds } = this.form().controls;
const data = [...selectedTargetIds.value];
moveItemInArray(data, event.previousIndex, event.currentIndex);
selectedTargetIds.setValue(data);
}
protected handleEnter(event: Event) {
const selectableTargets = this.selectableTargets();
if (selectableTargets.length !== 1) {
return;
}
event.preventDefault();
this.addTarget(selectableTargets[0]);
}
protected submit() {
const { target, executionConditions } = this.targetForm.getRawValue();
let valueToEmit: MessageInitShape<typeof ExecutionTargetTypeSchema>[] = target
? [
{
const selectedTargets = this.selectedTargetIds().map((value) => ({
type: {
case: 'target',
value: target.id,
case: 'target' as const,
value,
},
},
]
: [];
}));
const includeConditions: MessageInitShape<typeof ExecutionTargetTypeSchema>[] = executionConditions
? executionConditions.map((condition) => ({
type: {
case: 'include',
value: condition,
},
}))
: [];
this.continue.emit(selectedTargets);
}
valueToEmit = [...valueToEmit, ...includeConditions];
this.continue.emit(valueToEmit);
protected trackTarget(_: number, target: Target) {
return target.id;
}
}

View File

@@ -59,7 +59,7 @@
{{ 'ACTIONS.CANCEL' | translate }}
</button>
<button color="primary" [disabled]="targetForm.invalid" mat-raised-button (click)="closeWithResult()" cdkFocusInitial>
{{ 'ACTIONS.CREATE' | translate }}
{{ (data.target ? 'ACTIONS.CHANGE' : 'ACTIONS.CREATE') | translate }}
</button>
</mat-dialog-actions>
</div>

View File

@@ -44,7 +44,7 @@ export class ActionTwoAddTargetDialogComponent {
ActionTwoAddTargetDialogComponent,
MessageInitShape<typeof CreateTargetRequestSchema | typeof UpdateTargetRequestSchema>
>,
@Inject(MAT_DIALOG_DATA) private readonly data: { target?: Target },
@Inject(MAT_DIALOG_DATA) public readonly data: { target?: Target },
) {
this.targetForm = this.buildTargetForm();

View File

@@ -1,9 +1,9 @@
<cnsl-refresh-table (refreshed)="refresh.emit()" [loading]="(dataSource$ | async) === null">
<cnsl-refresh-table (refreshed)="refresh.emit()" [loading]="(targets$ | async) === null">
<div actions>
<ng-content></ng-content>
</div>
<div class="table-wrapper">
<table *ngIf="dataSource$ | async as dataSource" mat-table class="table" aria-label="Elements" [dataSource]="dataSource">
<table mat-table class="table" aria-label="Elements" [dataSource]="dataSource">
<!-- <ng-container matColumnDef="state">
<th mat-header-cell *matHeaderCellDef>{{ 'APP.PAGES.STATE' | translate }}</th>
<td mat-cell *cnslCellDef="let row; let i = index; dataSource: dataSource">

View File

@@ -1,8 +1,9 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { ChangeDetectionStrategy, Component, effect, EventEmitter, Input, Output } from '@angular/core';
import { ReplaySubject } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { filter, startWith } from 'rxjs/operators';
import { MatTableDataSource } from '@angular/material/table';
import { Target } from '@zitadel/proto/zitadel/action/v2beta/target_pb';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'cnsl-actions-two-targets-table',
@@ -14,6 +15,9 @@ export class ActionsTwoTargetsTableComponent {
@Output()
public readonly refresh = new EventEmitter<void>();
@Output()
public readonly selected = new EventEmitter<Target>();
@Output()
public readonly delete = new EventEmitter<Target>();
@@ -22,12 +26,24 @@ export class ActionsTwoTargetsTableComponent {
this.targets$.next(targets);
}
@Output()
public readonly selected = new EventEmitter<Target>();
protected readonly targets$ = new ReplaySubject<Target[] | null>(1);
protected readonly dataSource: MatTableDataSource<Target>;
private readonly targets$ = new ReplaySubject<Target[] | null>(1);
protected readonly dataSource$ = this.targets$.pipe(
filter(Boolean),
map((keys) => new MatTableDataSource(keys)),
);
constructor() {
this.dataSource = this.getDataSource();
}
private getDataSource() {
const targets$ = this.targets$.pipe(filter(Boolean), startWith<Target[]>([]));
const targetsSignal = toSignal(targets$, { requireSync: true });
const dataSource = new MatTableDataSource(targetsSignal());
effect(() => {
const targets = targetsSignal();
if (dataSource.data !== targets) {
dataSource.data = targets;
}
});
return dataSource;
}
}

View File

@@ -1,12 +1,9 @@
import { ChangeDetectionStrategy, Component, DestroyRef, OnInit } from '@angular/core';
import { defer, firstValueFrom, Observable, of, ReplaySubject, shareReplay, Subject, TimeoutError } from 'rxjs';
import { ChangeDetectionStrategy, Component, DestroyRef } from '@angular/core';
import { lastValueFrom, Observable, of, ReplaySubject } from 'rxjs';
import { ActionService } from 'src/app/services/action.service';
import { NewFeatureService } from 'src/app/services/new-feature.service';
import { ToastService } from 'src/app/services/toast.service';
import { ActivatedRoute, Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ORGANIZATIONS } from '../../settings-list/settings';
import { catchError, filter, map, startWith, switchMap, timeout } from 'rxjs/operators';
import { catchError, map, startWith, switchMap } from 'rxjs/operators';
import { MatDialog } from '@angular/material/dialog';
import { ActionTwoAddTargetDialogComponent } from '../actions-two-add-target/actions-two-add-target-dialog.component';
import { MessageInitShape } from '@bufbuild/protobuf';
@@ -22,66 +19,29 @@ import {
styleUrls: ['./actions-two-targets.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ActionsTwoTargetsComponent implements OnInit {
private readonly actionsEnabled$: Observable<boolean>;
export class ActionsTwoTargetsComponent {
protected readonly targets$: Observable<Target[]>;
protected readonly refresh$ = new ReplaySubject<true>(1);
constructor(
private readonly actionService: ActionService,
private readonly featureService: NewFeatureService,
private readonly toast: ToastService,
private readonly destroyRef: DestroyRef,
private readonly router: Router,
private readonly route: ActivatedRoute,
private readonly dialog: MatDialog,
) {
this.actionsEnabled$ = this.getActionsEnabled$().pipe(shareReplay({ refCount: true, bufferSize: 1 }));
this.targets$ = this.getTargets$(this.actionsEnabled$);
this.targets$ = this.getTargets$();
}
ngOnInit(): void {
// this also preloads
this.actionsEnabled$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(async (enabled) => {
if (enabled) {
return;
}
await this.router.navigate([], {
relativeTo: this.route,
queryParams: {
id: ORGANIZATIONS.id,
},
queryParamsHandling: 'merge',
});
});
}
private getTargets$(actionsEnabled$: Observable<boolean>) {
private getTargets$() {
return this.refresh$.pipe(
startWith(true),
switchMap(() => {
return this.actionService.listTargets({});
}),
map(({ result }) => result),
catchError(async (err) => {
const actionsEnabled = await firstValueFrom(actionsEnabled$);
if (actionsEnabled) {
this.toast.showError(err);
}
return [];
}),
);
}
private getActionsEnabled$() {
return defer(() => this.featureService.getInstanceFeatures()).pipe(
map(({ actions }) => actions?.enabled ?? false),
timeout(1000),
catchError((err) => {
if (!(err instanceof TimeoutError)) {
this.toast.showError(err);
}
return of(false);
return of([]);
}),
);
}
@@ -92,8 +52,9 @@ export class ActionsTwoTargetsComponent implements OnInit {
this.refresh$.next(true);
}
public openDialog(target?: Target): void {
const ref = this.dialog.open<
public async openDialog(target?: Target) {
const request$ = this.dialog
.open<
ActionTwoAddTargetDialogComponent,
{ target?: Target },
MessageInitShape<typeof UpdateTargetRequestSchema | typeof CreateTargetRequestSchema>
@@ -102,20 +63,27 @@ export class ActionsTwoTargetsComponent implements OnInit {
data: {
target: target,
},
});
ref
})
.afterClosed()
.pipe(filter(Boolean), takeUntilDestroyed(this.destroyRef))
.subscribe(async (dialogResponse) => {
if ('id' in dialogResponse) {
await this.actionService.updateTarget(dialogResponse);
.pipe(takeUntilDestroyed(this.destroyRef));
const request = await lastValueFrom(request$);
if (!request) {
return;
}
try {
if ('id' in request) {
await this.actionService.updateTarget(request);
} else {
await this.actionService.createTarget(dialogResponse);
await this.actionService.createTarget(request);
}
await new Promise((res) => setTimeout(res, 1000));
this.refresh$.next(true);
});
} catch (error) {
console.error(error);
this.toast.showError(error);
}
}
}

View File

@@ -11,7 +11,7 @@ import { WebKey } from '@zitadel/proto/zitadel/webkey/v2beta/key_pb';
})
export class OidcWebKeysInactiveTableComponent {
@Input({ required: true })
public set InactiveWebKeys(webKeys: WebKey[] | null) {
public set inactiveWebKeys(webKeys: WebKey[] | null) {
this.inactiveWebKeys$.next(webKeys);
}

View File

@@ -16,4 +16,4 @@
</button>
</cnsl-oidc-webkeys-table>
<cnsl-oidc-webkeys-create [loading]="createLoading()" (ngSubmit)="createWebKey($event)" />
<cnsl-oidc-webkeys-inactive-table [InactiveWebKeys]="inactiveWebKeys$ | async" />
<cnsl-oidc-webkeys-inactive-table [inactiveWebKeys]="inactiveWebKeys$ | async" />

View File

@@ -631,7 +631,8 @@
"TABLE": {
"NAME": "Име",
"ENDPOINT": "Крайна точка",
"CREATIONDATE": "Дата на създаване"
"CREATIONDATE": "Дата на създаване",
"REORDER": "Преоразмеряване"
}
}
},

View File

@@ -632,7 +632,8 @@
"TABLE": {
"NAME": "Název",
"ENDPOINT": "Koncový bod",
"CREATIONDATE": "Datum vytvoření"
"CREATIONDATE": "Datum vytvoření",
"REORDER": "Změnit pořadí"
}
}
},

View File

@@ -632,7 +632,8 @@
"TABLE": {
"NAME": "Name",
"ENDPOINT": "Endpunkt",
"CREATIONDATE": "Erstellungsdatum"
"CREATIONDATE": "Erstellungsdatum",
"REORDER": "Verschieben"
}
}
},

View File

@@ -632,7 +632,8 @@
"TABLE": {
"NAME": "Name",
"ENDPOINT": "Endpoint",
"CREATIONDATE": "Creation Date"
"CREATIONDATE": "Creation Date",
"REORDER": "Reorder"
}
}
},

View File

@@ -632,7 +632,8 @@
"TABLE": {
"NAME": "Nombre",
"ENDPOINT": "Punto de conexión",
"CREATIONDATE": "Fecha de creación"
"CREATIONDATE": "Fecha de creación",
"REORDER": "Reordenar"
}
}
},

View File

@@ -632,7 +632,8 @@
"TABLE": {
"NAME": "Nom",
"ENDPOINT": "Point de terminaison",
"CREATIONDATE": "Date de création"
"CREATIONDATE": "Date de création",
"REORDER": "Réorganiser"
}
}
},

View File

@@ -632,7 +632,8 @@
"TABLE": {
"NAME": "Név",
"ENDPOINT": "Végpont",
"CREATIONDATE": "Létrehozás dátuma"
"CREATIONDATE": "Létrehozás dátuma",
"REORDER": "Újrarendelés"
}
}
},

View File

@@ -599,7 +599,8 @@
"TABLE": {
"NAME": "Nama",
"ENDPOINT": "Titik Akhir",
"CREATIONDATE": "Tanggal Pembuatan"
"CREATIONDATE": "Tanggal Pembuatan",
"REORDER": "Susun ulang"
}
}
},

View File

@@ -631,7 +631,8 @@
"TABLE": {
"NAME": "Nome",
"ENDPOINT": "Endpoint",
"CREATIONDATE": "Data di creazione"
"CREATIONDATE": "Data di creazione",
"REORDER": "Riordina"
}
}
},

View File

@@ -632,7 +632,8 @@
"TABLE": {
"NAME": "名前",
"ENDPOINT": "エンドポイント",
"CREATIONDATE": "作成日"
"CREATIONDATE": "作成日",
"REORDER": "順序を変更"
}
}
},

View File

@@ -632,7 +632,8 @@
"TABLE": {
"NAME": "이름",
"ENDPOINT": "엔드포인트",
"CREATIONDATE": "생성 날짜"
"CREATIONDATE": "생성 날짜",
"REORDER": "재정렬"
}
}
},

View File

@@ -632,7 +632,8 @@
"TABLE": {
"NAME": "Име",
"ENDPOINT": "Крајна точка",
"CREATIONDATE": "Датум на создавање"
"CREATIONDATE": "Датум на создавање",
"REORDER": "Повторно нарачајте"
}
}
},

View File

@@ -632,7 +632,8 @@
"TABLE": {
"NAME": "Naam",
"ENDPOINT": "Eindpunt",
"CREATIONDATE": "Aanmaakdatum"
"CREATIONDATE": "Aanmaakdatum",
"REORDER": "Opnieuw ordenen"
}
}
},

View File

@@ -631,7 +631,8 @@
"TABLE": {
"NAME": "Nazwa",
"ENDPOINT": "Punkt końcowy",
"CREATIONDATE": "Data utworzenia"
"CREATIONDATE": "Data utworzenia",
"REORDER": "Zmień kolejność"
}
}
},

View File

@@ -632,7 +632,8 @@
"TABLE": {
"NAME": "Nome",
"ENDPOINT": "Ponto de Extremidade",
"CREATIONDATE": "Data de Criação"
"CREATIONDATE": "Data de Criação",
"REORDER": "Reordenar"
}
}
},

View File

@@ -632,7 +632,8 @@
"TABLE": {
"NAME": "Nume",
"ENDPOINT": "Punct Final",
"CREATIONDATE": "Data Creării"
"CREATIONDATE": "Data Creării",
"REORDER": "Reordonați"
}
}
},

View File

@@ -632,7 +632,8 @@
"TABLE": {
"NAME": "Имя",
"ENDPOINT": "Конечная точка",
"CREATIONDATE": "Дата создания"
"CREATIONDATE": "Дата создания",
"REORDER": "Изменить порядок"
}
}
},

View File

@@ -632,7 +632,8 @@
"TABLE": {
"NAME": "Namn",
"ENDPOINT": "Slutpunkt",
"CREATIONDATE": "Skapat datum"
"CREATIONDATE": "Skapat datum",
"REORDER": "Ordna om"
}
}
},

View File

@@ -632,7 +632,8 @@
"TABLE": {
"NAME": "名称",
"ENDPOINT": "端点",
"CREATIONDATE": "创建日期"
"CREATIONDATE": "创建日期",
"REORDER": "重新排序"
}
}
},