mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-11 14:47:33 +00:00
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>
This commit is contained in:
@@ -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',
|
||||
|
@@ -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>
|
||||
|
@@ -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 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);
|
||||
protected readonly loading = this.getLoading();
|
||||
|
||||
return this.targets$.pipe(
|
||||
filter(Boolean),
|
||||
map((alltargets) => alltargets!.filter((target) => targetIds.includes(target.id))),
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
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),
|
||||
);
|
||||
|
||||
return toSignal(loading$, { requireSync: true });
|
||||
}
|
||||
|
||||
protected trackTarget(_: number, target: Target) {
|
||||
return target.id;
|
||||
}
|
||||
}
|
||||
|
@@ -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"
|
||||
|
@@ -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) {
|
||||
this.toast.showError(err);
|
||||
}
|
||||
return [];
|
||||
map(({ result }) => result.map(correctlyTypeExecution)),
|
||||
catchError((err) => {
|
||||
this.toast.showError(err);
|
||||
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);
|
||||
this.toast.showError(err);
|
||||
return of([]);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public openDialog(execution?: Execution): void {
|
||||
const ref = this.dialog.open<ActionTwoAddActionDialogComponent>(ActionTwoAddActionDialogComponent, {
|
||||
width: '400px',
|
||||
data: execution
|
||||
? {
|
||||
execution: execution,
|
||||
}
|
||||
: {},
|
||||
});
|
||||
public async openDialog(execution?: CorrectlyTypedExecution): Promise<void> {
|
||||
const request$ = this.dialog
|
||||
.open<ActionTwoAddActionDialogComponent, ActionTwoAddActionDialogData, ActionTwoAddActionDialogResult>(
|
||||
ActionTwoAddActionDialogComponent,
|
||||
{
|
||||
width: '400px',
|
||||
data: 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) => {
|
||||
console.error(error);
|
||||
this.toast.showError(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: [],
|
||||
};
|
||||
await this.actionService.setExecution(deleteReq);
|
||||
await new Promise((res) => setTimeout(res, 1000));
|
||||
this.refresh.next(true);
|
||||
try {
|
||||
await this.actionService.setExecution(deleteReq);
|
||||
await new Promise((res) => setTimeout(res, 1000));
|
||||
this.refresh$.next(true);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.toast.showError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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>
|
||||
|
@@ -7,6 +7,7 @@
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.hide {
|
||||
|
@@ -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() {
|
||||
|
@@ -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>
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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 conditionIsDefinedAndNotCurrentOne(selectedConditionJson?: string) {
|
||||
return (condition?: Condition): condition is Condition => {
|
||||
if (!condition) {
|
||||
// condition is undefined so it is not of type Condition
|
||||
return false;
|
||||
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);
|
||||
}
|
||||
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;
|
||||
};
|
||||
return Array.from(targetsCopy.values());
|
||||
});
|
||||
}
|
||||
|
||||
private targetHasDetailsAndConfig(target: Target): target is Target {
|
||||
return !!target.id && !!target.id;
|
||||
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 [];
|
||||
}
|
||||
|
||||
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();
|
||||
const selectedTargets = this.selectedTargetIds().map((value) => ({
|
||||
type: {
|
||||
case: 'target' as const,
|
||||
value,
|
||||
},
|
||||
}));
|
||||
|
||||
let valueToEmit: MessageInitShape<typeof ExecutionTargetTypeSchema>[] = target
|
||||
? [
|
||||
{
|
||||
type: {
|
||||
case: 'target',
|
||||
value: target.id,
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
||||
this.continue.emit(selectedTargets);
|
||||
}
|
||||
|
||||
const includeConditions: MessageInitShape<typeof ExecutionTargetTypeSchema>[] = executionConditions
|
||||
? executionConditions.map((condition) => ({
|
||||
type: {
|
||||
case: 'include',
|
||||
value: condition,
|
||||
},
|
||||
}))
|
||||
: [];
|
||||
|
||||
valueToEmit = [...valueToEmit, ...includeConditions];
|
||||
|
||||
this.continue.emit(valueToEmit);
|
||||
protected trackTarget(_: number, target: Target) {
|
||||
return target.id;
|
||||
}
|
||||
}
|
||||
|
@@ -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>
|
||||
|
@@ -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();
|
||||
|
||||
|
@@ -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">
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
@@ -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);
|
||||
this.toast.showError(err);
|
||||
return of([]);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -92,30 +52,38 @@ export class ActionsTwoTargetsComponent implements OnInit {
|
||||
this.refresh$.next(true);
|
||||
}
|
||||
|
||||
public openDialog(target?: Target): void {
|
||||
const ref = this.dialog.open<
|
||||
ActionTwoAddTargetDialogComponent,
|
||||
{ target?: Target },
|
||||
MessageInitShape<typeof UpdateTargetRequestSchema | typeof CreateTargetRequestSchema>
|
||||
>(ActionTwoAddTargetDialogComponent, {
|
||||
width: '550px',
|
||||
data: {
|
||||
target: target,
|
||||
},
|
||||
});
|
||||
|
||||
ref
|
||||
public async openDialog(target?: Target) {
|
||||
const request$ = this.dialog
|
||||
.open<
|
||||
ActionTwoAddTargetDialogComponent,
|
||||
{ target?: Target },
|
||||
MessageInitShape<typeof UpdateTargetRequestSchema | typeof CreateTargetRequestSchema>
|
||||
>(ActionTwoAddTargetDialogComponent, {
|
||||
width: '550px',
|
||||
data: {
|
||||
target: target,
|
||||
},
|
||||
})
|
||||
.afterClosed()
|
||||
.pipe(filter(Boolean), takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(async (dialogResponse) => {
|
||||
if ('id' in dialogResponse) {
|
||||
await this.actionService.updateTarget(dialogResponse);
|
||||
} else {
|
||||
await this.actionService.createTarget(dialogResponse);
|
||||
}
|
||||
.pipe(takeUntilDestroyed(this.destroyRef));
|
||||
|
||||
await new Promise((res) => setTimeout(res, 1000));
|
||||
this.refresh$.next(true);
|
||||
});
|
||||
const request = await lastValueFrom(request$);
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if ('id' in request) {
|
||||
await this.actionService.updateTarget(request);
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
|
||||
|
@@ -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" />
|
||||
|
@@ -631,7 +631,8 @@
|
||||
"TABLE": {
|
||||
"NAME": "Име",
|
||||
"ENDPOINT": "Крайна точка",
|
||||
"CREATIONDATE": "Дата на създаване"
|
||||
"CREATIONDATE": "Дата на създаване",
|
||||
"REORDER": "Преоразмеряване"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -632,7 +632,8 @@
|
||||
"TABLE": {
|
||||
"NAME": "Název",
|
||||
"ENDPOINT": "Koncový bod",
|
||||
"CREATIONDATE": "Datum vytvoření"
|
||||
"CREATIONDATE": "Datum vytvoření",
|
||||
"REORDER": "Změnit pořadí"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -632,7 +632,8 @@
|
||||
"TABLE": {
|
||||
"NAME": "Name",
|
||||
"ENDPOINT": "Endpunkt",
|
||||
"CREATIONDATE": "Erstellungsdatum"
|
||||
"CREATIONDATE": "Erstellungsdatum",
|
||||
"REORDER": "Verschieben"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -632,7 +632,8 @@
|
||||
"TABLE": {
|
||||
"NAME": "Name",
|
||||
"ENDPOINT": "Endpoint",
|
||||
"CREATIONDATE": "Creation Date"
|
||||
"CREATIONDATE": "Creation Date",
|
||||
"REORDER": "Reorder"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -632,7 +632,8 @@
|
||||
"TABLE": {
|
||||
"NAME": "Nombre",
|
||||
"ENDPOINT": "Punto de conexión",
|
||||
"CREATIONDATE": "Fecha de creación"
|
||||
"CREATIONDATE": "Fecha de creación",
|
||||
"REORDER": "Reordenar"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -632,7 +632,8 @@
|
||||
"TABLE": {
|
||||
"NAME": "Nom",
|
||||
"ENDPOINT": "Point de terminaison",
|
||||
"CREATIONDATE": "Date de création"
|
||||
"CREATIONDATE": "Date de création",
|
||||
"REORDER": "Réorganiser"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -599,7 +599,8 @@
|
||||
"TABLE": {
|
||||
"NAME": "Nama",
|
||||
"ENDPOINT": "Titik Akhir",
|
||||
"CREATIONDATE": "Tanggal Pembuatan"
|
||||
"CREATIONDATE": "Tanggal Pembuatan",
|
||||
"REORDER": "Susun ulang"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -631,7 +631,8 @@
|
||||
"TABLE": {
|
||||
"NAME": "Nome",
|
||||
"ENDPOINT": "Endpoint",
|
||||
"CREATIONDATE": "Data di creazione"
|
||||
"CREATIONDATE": "Data di creazione",
|
||||
"REORDER": "Riordina"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -632,7 +632,8 @@
|
||||
"TABLE": {
|
||||
"NAME": "名前",
|
||||
"ENDPOINT": "エンドポイント",
|
||||
"CREATIONDATE": "作成日"
|
||||
"CREATIONDATE": "作成日",
|
||||
"REORDER": "順序を変更"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -632,7 +632,8 @@
|
||||
"TABLE": {
|
||||
"NAME": "이름",
|
||||
"ENDPOINT": "엔드포인트",
|
||||
"CREATIONDATE": "생성 날짜"
|
||||
"CREATIONDATE": "생성 날짜",
|
||||
"REORDER": "재정렬"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -632,7 +632,8 @@
|
||||
"TABLE": {
|
||||
"NAME": "Име",
|
||||
"ENDPOINT": "Крајна точка",
|
||||
"CREATIONDATE": "Датум на создавање"
|
||||
"CREATIONDATE": "Датум на создавање",
|
||||
"REORDER": "Повторно нарачајте"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -632,7 +632,8 @@
|
||||
"TABLE": {
|
||||
"NAME": "Naam",
|
||||
"ENDPOINT": "Eindpunt",
|
||||
"CREATIONDATE": "Aanmaakdatum"
|
||||
"CREATIONDATE": "Aanmaakdatum",
|
||||
"REORDER": "Opnieuw ordenen"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -631,7 +631,8 @@
|
||||
"TABLE": {
|
||||
"NAME": "Nazwa",
|
||||
"ENDPOINT": "Punkt końcowy",
|
||||
"CREATIONDATE": "Data utworzenia"
|
||||
"CREATIONDATE": "Data utworzenia",
|
||||
"REORDER": "Zmień kolejność"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -632,7 +632,8 @@
|
||||
"TABLE": {
|
||||
"NAME": "Nome",
|
||||
"ENDPOINT": "Ponto de Extremidade",
|
||||
"CREATIONDATE": "Data de Criação"
|
||||
"CREATIONDATE": "Data de Criação",
|
||||
"REORDER": "Reordenar"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -632,7 +632,8 @@
|
||||
"TABLE": {
|
||||
"NAME": "Nume",
|
||||
"ENDPOINT": "Punct Final",
|
||||
"CREATIONDATE": "Data Creării"
|
||||
"CREATIONDATE": "Data Creării",
|
||||
"REORDER": "Reordonați"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -632,7 +632,8 @@
|
||||
"TABLE": {
|
||||
"NAME": "Имя",
|
||||
"ENDPOINT": "Конечная точка",
|
||||
"CREATIONDATE": "Дата создания"
|
||||
"CREATIONDATE": "Дата создания",
|
||||
"REORDER": "Изменить порядок"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -632,7 +632,8 @@
|
||||
"TABLE": {
|
||||
"NAME": "Namn",
|
||||
"ENDPOINT": "Slutpunkt",
|
||||
"CREATIONDATE": "Skapat datum"
|
||||
"CREATIONDATE": "Skapat datum",
|
||||
"REORDER": "Ordna om"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -632,7 +632,8 @@
|
||||
"TABLE": {
|
||||
"NAME": "名称",
|
||||
"ENDPOINT": "端点",
|
||||
"CREATIONDATE": "创建日期"
|
||||
"CREATIONDATE": "创建日期",
|
||||
"REORDER": "重新排序"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
Reference in New Issue
Block a user