From 56e0df67d59116bff9fe2b3e1000ad83add998d0 Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 23 Apr 2025 11:21:14 +0200 Subject: [PATCH] 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 --- .../components/features/features.component.ts | 1 - .../actions-two-actions-table.component.html | 24 +- .../actions-two-actions-table.component.ts | 101 ++++++-- .../actions-two-actions.component.html | 2 +- .../actions-two-actions.component.ts | 154 +++++------ ...tions-two-add-action-dialog.component.html | 6 +- ...tions-two-add-action-dialog.component.scss | 1 + ...actions-two-add-action-dialog.component.ts | 91 +++++-- ...tions-two-add-action-target.component.html | 70 +++-- ...tions-two-add-action-target.component.scss | 24 ++ ...actions-two-add-action-target.component.ts | 245 +++++++++++------- ...tions-two-add-target-dialog.component.html | 2 +- ...actions-two-add-target-dialog.component.ts | 2 +- .../actions-two-targets-table.component.html | 4 +- .../actions-two-targets-table.component.ts | 34 ++- .../actions-two-targets.component.ts | 110 +++----- .../oidc-webkeys-inactive-table.component.ts | 2 +- .../oidc-webkeys/oidc-webkeys.component.html | 2 +- console/src/assets/i18n/bg.json | 3 +- console/src/assets/i18n/cs.json | 3 +- console/src/assets/i18n/de.json | 3 +- console/src/assets/i18n/en.json | 3 +- console/src/assets/i18n/es.json | 3 +- console/src/assets/i18n/fr.json | 3 +- console/src/assets/i18n/hu.json | 3 +- console/src/assets/i18n/id.json | 3 +- console/src/assets/i18n/it.json | 3 +- console/src/assets/i18n/ja.json | 3 +- console/src/assets/i18n/ko.json | 3 +- console/src/assets/i18n/mk.json | 3 +- console/src/assets/i18n/nl.json | 3 +- console/src/assets/i18n/pl.json | 3 +- console/src/assets/i18n/pt.json | 3 +- console/src/assets/i18n/ro.json | 3 +- console/src/assets/i18n/ru.json | 3 +- console/src/assets/i18n/sv.json | 3 +- console/src/assets/i18n/zh.json | 3 +- 37 files changed, 557 insertions(+), 375 deletions(-) diff --git a/console/src/app/components/features/features.component.ts b/console/src/app/components/features/features.component.ts index 0f89c5e98a..d95bbdde43 100644 --- a/console/src/app/components/features/features.component.ts +++ b/console/src/app/components/features/features.component.ts @@ -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', diff --git a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.html b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.html index 670fe2c53b..82f04fb124 100644 --- a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.html +++ b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.html @@ -1,14 +1,14 @@ - +
- +
@@ -16,7 +16,7 @@ @@ -24,16 +24,9 @@ @@ -43,7 +36,7 @@ {{ 'ACTIONSTWO.EXECUTION.TABLE.CREATIONDATE' | translate }} @@ -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 > @@ -69,6 +62,7 @@ class="highlight pointer" mat-row *matRowDef="let row; columns: ['condition', 'type', 'target', 'creationDate', 'actions']" + (click)="selected.emit(row.execution)" >
{{ 'ACTIONSTWO.EXECUTION.TABLE.CONDITION' | translate }} - - {{ row?.condition | condition }} + + {{ row.execution.condition | condition }} {{ 'ACTIONSTWO.EXECUTION.TABLE.TYPE' | translate }} - {{ 'ACTIONSTWO.EXECUTION.TYPES.' + row?.condition?.conditionType?.case | translate }} + {{ 'ACTIONSTWO.EXECUTION.TYPES.' + row.execution.condition.conditionType.case | translate }} {{ 'ACTIONSTWO.EXECUTION.TABLE.TARGET' | translate }}
- {{ target.name }} - - refresh - {{ condition | condition }} -
- {{ row.creationDate | timestampToDate | localizedDate: 'regular' }} + {{ row.execution.creationDate | timestampToDate | localizedDate: 'regular' }}
diff --git a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.ts b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.ts index 6c714e2908..2d9942c406 100644 --- a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.ts +++ b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.ts @@ -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(); @Output() - public readonly delete = new EventEmitter(); + public readonly selected = new EventEmitter(); + + @Output() + public readonly delete = new EventEmitter(); @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(); + private readonly executions$ = new ReplaySubject(1); - private readonly executions$ = new ReplaySubject(1); private readonly targets$ = new ReplaySubject(1); - protected readonly dataSource$ = this.executions$.pipe( - filter(Boolean), - map((keys) => new MatTableDataSource(keys)), - ); + protected readonly dataSource = this.getDataSource(); - protected filteredTargetTypes(targets: ExecutionTargetType[]): Observable { - const targetIds = targets - .map((t) => t.type) - .filter((t): t is Extract => 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 = 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 => 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(); + 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; } } diff --git a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.html b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.html index c194466a4f..c22b03ef76 100644 --- a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.html +++ b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.html @@ -2,7 +2,7 @@

{{ 'ACTIONSTWO.EXECUTION.DESCRIPTION' | translate }}

(); - private readonly actionsEnabled$: Observable; - protected readonly executions$: Observable; +export class ActionsTwoActionsComponent { + protected readonly refresh$ = new Subject(); + protected readonly executions$: Observable; protected readonly targets$: Observable; 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) { - 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) { - 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, { - width: '400px', - data: execution - ? { - execution: execution, - } - : {}, - }); + public async openDialog(execution?: CorrectlyTypedExecution): Promise { + const request$ = this.dialog + .open( + ActionTwoAddActionDialogComponent, + { + width: '400px', + data: execution + ? { + execution, + } + : {}, + }, + ) + .afterClosed() + .pipe(takeUntilDestroyed(this.destroyRef)); - ref.afterClosed().subscribe((request?: MessageInitShape) => { - 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 = { 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); + } } } diff --git a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.html b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.html index 9c7f78395a..cc6e989cf2 100644 --- a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.html +++ b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.html @@ -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()" > diff --git a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.scss b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.scss index dd597c29aa..8223e63565 100644 --- a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.scss +++ b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.scss @@ -7,6 +7,7 @@ .actions { display: flex; justify-content: space-between; + margin-top: 1rem; } .hide { diff --git a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.ts b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.ts index 00281cabd7..12ae6598cc 100644 --- a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.ts +++ b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.ts @@ -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 }; + +type CorrectlyTypedTargets = { type: Extract }; + +export type CorrectlyTypedExecution = Omit & { + 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; + @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.Type); + protected readonly Page = Page; + protected readonly page = signal(Page.Type); - public typeSignal = signal('request'); - public conditionSignal = signal | undefined>(undefined); // TODO: fix this type - public targetSignal = signal> | undefined>(undefined); + protected readonly typeSignal = signal('request'); + protected readonly conditionSignal = signal['condition']>(undefined); + protected readonly targetsSignal = signal[]>([]); - public continueSubject = new Subject(); + protected readonly continueSubject = new Subject(); - public request = computed>(() => { + protected readonly request = computed>(() => { 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>, - @Inject(MAT_DIALOG_DATA) protected readonly data: { execution?: Execution }, + protected readonly dialogRef: MatDialogRef, + @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() { diff --git a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.html b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.html index a8503c71be..422ed7991e 100644 --- a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.html +++ b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.html @@ -1,37 +1,71 @@ -
+

{{ 'ACTIONSTWO.EXECUTION.DIALOG.TARGET.DESCRIPTION' | translate }}

{{ 'ACTIONSTWO.EXECUTION.DIALOG.TARGET.TARGET.DESCRIPTION' | translate }} - - + + + - + {{ target.name }} - + - - - - - - - - - - - - + + + + + + + + + + + + + + + +
Reorder + + Name + {{ row.name }} + + + + +
-
diff --git a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.scss b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.scss index 776c535f1a..deff15c680 100644 --- a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.scss +++ b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.scss @@ -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); +} diff --git a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.ts b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.ts index bdcfc54a3d..e04368f8f4 100644 --- a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.ts +++ b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.ts @@ -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['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(); @Output() public readonly continue = new EventEmitter[]>(); - @Input() public hideBackButton = false; - @Input() set selectedCondition(selectedCondition: Condition | undefined) { - this.selectedCondition$.next(selectedCondition); - } - private readonly selectedCondition$ = new ReplaySubject(1); + private readonly preselectedTargetIds$ = new ReplaySubject(1); - protected readonly executionTargets$: Observable; - protected readonly executionConditions$: Observable; + protected readonly form: ReturnType; + protected readonly targets: ReturnType; + private readonly selectedTargetIds: Signal; + protected readonly selectableTargets: Signal; + protected readonly dataSource: MatTableDataSource; 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(null, { validators: [] }), - executionConditions: new FormControl([], { 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() }); + + this.actionService + .listTargets({}) + .then(({ result }) => { + const targets = result.reduce((acc, target) => { + acc.set(target.id, target); + return acc; + }, new Map()); + + 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 { - 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) { + 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) { + 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(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) { + 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[] = target - ? [ - { - type: { - case: 'target', - value: target.id, - }, - }, - ] - : []; + this.continue.emit(selectedTargets); + } - const includeConditions: MessageInitShape[] = executionConditions - ? executionConditions.map((condition) => ({ - type: { - case: 'include', - value: condition, - }, - })) - : []; - - valueToEmit = [...valueToEmit, ...includeConditions]; - - this.continue.emit(valueToEmit); + protected trackTarget(_: number, target: Target) { + return target.id; } } diff --git a/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.html b/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.html index 496be167df..37d4f89dd0 100644 --- a/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.html +++ b/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.html @@ -59,7 +59,7 @@ {{ 'ACTIONS.CANCEL' | translate }} diff --git a/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.ts b/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.ts index b9c9c64853..7d3ad0e86c 100644 --- a/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.ts +++ b/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.ts @@ -44,7 +44,7 @@ export class ActionTwoAddTargetDialogComponent { ActionTwoAddTargetDialogComponent, MessageInitShape >, - @Inject(MAT_DIALOG_DATA) private readonly data: { target?: Target }, + @Inject(MAT_DIALOG_DATA) public readonly data: { target?: Target }, ) { this.targetForm = this.buildTargetForm(); diff --git a/console/src/app/modules/actions-two/actions-two-targets/actions-two-targets-table/actions-two-targets-table.component.html b/console/src/app/modules/actions-two/actions-two-targets/actions-two-targets-table/actions-two-targets-table.component.html index 17a73304b0..1cac09f1e4 100644 --- a/console/src/app/modules/actions-two/actions-two-targets/actions-two-targets-table/actions-two-targets-table.component.html +++ b/console/src/app/modules/actions-two/actions-two-targets/actions-two-targets-table/actions-two-targets-table.component.html @@ -1,9 +1,9 @@ - +
- +