From b694b25cdf1b1bfcc87587f333744d7a7a8866ca Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 10 Sep 2025 11:04:45 +0200 Subject: [PATCH] fix(console): improve actions creation dropdowns #10596 (#10677) # Which Problems Are Solved Actions V2 Method names got cut off in the creation dropdown old modal # How the Problems Are Solved The modal now first requires a Service to be set and only afterwards are users allowed set Methods. This way we can cut out the Service-Names from the Method-Name leading to cleaner and shorter names. new modal # Additional Changes Changed the Modal dataloading to use Tanstack Query # Additional Context - Closes #10596 --- .../actions-two-actions.component.ts | 2 +- ...d-action-autocomplete-input.component.html | 15 ++ ...add-action-autocomplete-input.component.ts | 57 +++++ ...ns-two-add-action-condition.component.html | 73 ++----- ...ions-two-add-action-condition.component.ts | 204 +++++++----------- ...actions-two-add-action-target.component.ts | 6 +- console/src/app/services/action.service.ts | 64 ++++-- 7 files changed, 212 insertions(+), 209 deletions(-) create mode 100644 console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-autocomplete-input/actions-two-add-action-autocomplete-input.component.html create mode 100644 console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-autocomplete-input/actions-two-add-action-autocomplete-input.component.ts diff --git a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.ts b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.ts index 6fa6c2f3070..81ee1fcde18 100644 --- a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.ts +++ b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.ts @@ -72,7 +72,7 @@ export class ActionsTwoActionsComponent { .open( ActionTwoAddActionDialogComponent, { - width: '400px', + width: '500px', data: execution ? { execution, diff --git a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-autocomplete-input/actions-two-add-action-autocomplete-input.component.html b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-autocomplete-input/actions-two-add-action-autocomplete-input.component.html new file mode 100644 index 00000000000..567c1c58248 --- /dev/null +++ b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-autocomplete-input/actions-two-add-action-autocomplete-input.component.html @@ -0,0 +1,15 @@ + + {{ label | translate }} + + + + + + + + {{ item }} + + + + + diff --git a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-autocomplete-input/actions-two-add-action-autocomplete-input.component.ts b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-autocomplete-input/actions-two-add-action-autocomplete-input.component.ts new file mode 100644 index 00000000000..fa9acfeeb86 --- /dev/null +++ b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-autocomplete-input/actions-two-add-action-autocomplete-input.component.ts @@ -0,0 +1,57 @@ +import { ChangeDetectionStrategy, Component, Input, Pipe, PipeTransform } from '@angular/core'; +import { AsyncPipe, NgForOf, NgIf } from '@angular/common'; +import { FormFieldModule } from 'src/app/modules/form-field/form-field.module'; +import { InputModule } from 'src/app/modules/input/input.module'; +import { LabelModule } from 'src/app/modules/label/label.module'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatOptionModule } from '@angular/material/core'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { TranslateModule } from '@ngx-translate/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { fromEvent, map, mergeWith, Observable } from 'rxjs'; +import { startWith } from 'rxjs/operators'; + +@Pipe({ standalone: true, name: 'filter' }) +class Filter implements PipeTransform { + transform(items: string[] | undefined = [], input: HTMLInputElement): Observable { + const focus$ = fromEvent(input, 'focus').pipe(map(() => '')); + + return fromEvent(input, 'input').pipe( + startWith(undefined), + map(() => input.value.toLowerCase()), + mergeWith(focus$), + map((input) => items.filter((item) => item.toLowerCase().includes(input))), + ); + } +} + +@Component({ + selector: 'cnsl-actions-two-add-action-autocomplete-input', + templateUrl: './actions-two-add-action-autocomplete-input.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + AsyncPipe, + Filter, + FormFieldModule, + InputModule, + LabelModule, + MatAutocompleteModule, + MatOptionModule, + MatProgressSpinnerModule, + TranslateModule, + NgIf, + NgForOf, + ReactiveFormsModule, + ], +}) +export class ActionsTwoAddActionAutocompleteInputComponent { + @Input({ required: true }) + public label!: string; + + @Input({ required: true }) + public items: string[] | undefined; + + @Input({ required: true }) + public control!: FormControl; +} diff --git a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-condition/actions-two-add-action-condition.component.html b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-condition/actions-two-add-action-condition.component.html index f0248f45a26..0ef7cca65c9 100644 --- a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-condition/actions-two-add-action-condition.component.html +++ b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-condition/actions-two-add-action-condition.component.html @@ -14,68 +14,31 @@

{{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.SELECT_SERVICE.TITLE' | translate }}

- - - {{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.SELECT_SERVICE.DESCRIPTION' | translate }} - - - - - - - - {{ service }} - - - +

{{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.SELECT_METHOD.TITLE' | translate }}

- - {{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.SELECT_METHOD.DESCRIPTION' | translate }} - - - - - - - - {{ method }} - - - + - - {{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.FUNCTIONNAME.TITLE' | translate }} - - - - - - - - {{ function }} - - - + {{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.FUNCTIONNAME.DESCRIPTION' | translate }} - + @@ -94,14 +57,14 @@ {{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.SELECT_GROUP.DESCRIPTION' | translate }} - +

{{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.SELECT_EVENT.TITLE' | translate }}

{{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.SELECT_EVENT.DESCRIPTION' | translate }} - +
diff --git a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-condition/actions-two-add-action-condition.component.ts b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-condition/actions-two-add-action-condition.component.ts index 4508f31230f..c670654e067 100644 --- a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-condition/actions-two-add-action-condition.component.ts +++ b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-condition/actions-two-add-action-condition.component.ts @@ -1,43 +1,24 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, DestroyRef, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, EventEmitter, Input, Output, Signal } from '@angular/core'; import { RouterModule } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { MatButtonModule } from '@angular/material/button'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { InputModule } from 'src/app/modules/input/input.module'; -import { - AbstractControl, - FormBuilder, - FormControl, - FormGroup, - FormsModule, - ReactiveFormsModule, - ValidationErrors, - ValidatorFn, -} from '@angular/forms'; -import { - Observable, - catchError, - defer, - map, - of, - shareReplay, - ReplaySubject, - ObservedValueOf, - switchMap, - combineLatestWith, - OperatorFunction, -} from 'rxjs'; +import { FormBuilder, FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { Observable, of, shareReplay, ReplaySubject, ObservedValueOf, switchMap, Subject } from 'rxjs'; import { MatRadioModule } from '@angular/material/radio'; import { ActionService } from 'src/app/services/action.service'; import { ToastService } from 'src/app/services/toast.service'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { atLeastOneFieldValidator, requiredValidator } from 'src/app/modules/form-field/validators/validators'; import { Message } from '@bufbuild/protobuf'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Condition } from '@zitadel/proto/zitadel/action/v2beta/execution_pb'; -import { startWith } from 'rxjs/operators'; +import { distinctUntilChanged, startWith, takeUntil } from 'rxjs/operators'; +import { CreateQueryResult } from '@tanstack/angular-query-experimental'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActionsTwoAddActionAutocompleteInputComponent } from '../actions-two-add-action-autocomplete-input/actions-two-add-action-autocomplete-input.component'; export type ConditionType = NonNullable; export type ConditionTypeValue = Omit< @@ -64,32 +45,73 @@ export type ConditionTypeValue = Omit< CommonModule, MatButtonModule, MatProgressSpinnerModule, + ActionsTwoAddActionAutocompleteInputComponent, ], }) export class ActionsTwoAddActionConditionComponent { @Input({ required: true }) public set conditionType(conditionType: T) { this.conditionType$.next(conditionType); } + @Output() public readonly back = new EventEmitter(); @Output() public readonly continue = new EventEmitter>(); private readonly conditionType$ = new ReplaySubject(1); protected readonly form$: ReturnType; - protected readonly executionServices$: Observable; - protected readonly executionMethods$: Observable; - protected readonly executionFunctions$: Observable; + protected readonly listExecutionServicesQuery = this.actionService.listExecutionServicesQuery(); + private readonly listExecutionMethodsQuery = this.actionService.listExecutionMethodsQuery(); + protected readonly listExecutionFunctionsQuery = this.actionService.listExecutionFunctionsQuery(); + protected readonly filteredExecutionMethods: Signal; constructor( private readonly fb: FormBuilder, private readonly actionService: ActionService, private readonly toast: ToastService, - private readonly destroyRef: DestroyRef, ) { this.form$ = this.buildForm().pipe(shareReplay({ refCount: true, bufferSize: 1 })); - this.executionServices$ = this.listExecutionServices(this.form$).pipe(shareReplay({ refCount: true, bufferSize: 1 })); - this.executionMethods$ = this.listExecutionMethods(this.form$).pipe(shareReplay({ refCount: true, bufferSize: 1 })); - this.executionFunctions$ = this.listExecutionFunctions(this.form$).pipe(shareReplay({ refCount: true, bufferSize: 1 })); + + this.handleError(this.listExecutionServicesQuery); + this.handleError(this.listExecutionMethodsQuery); + this.handleError(this.listExecutionFunctionsQuery); + + this.filteredExecutionMethods = this.filterExecutionMethods(this.form$); + } + + public handleError(query: CreateQueryResult) { + return effect(() => { + const error = query.error(); + if (error) { + this.toast.showError(error); + } + }); + } + + private filterExecutionMethods(form$: typeof this.form$) { + const service$ = form$.pipe( + switchMap((form) => { + if (!('service' in form.form.controls)) { + return of(''); + } + const { service } = form.form.controls; + return service.valueChanges.pipe(startWith(service.value)); + }), + ); + + const serviceSignal = toSignal(service$, { initialValue: '' }); + + const query = this.actionService.listExecutionMethodsQuery(); + + return computed(() => { + const methods = query.data(); + const service = serviceSignal(); + + if (!methods) { + return undefined; + } + + return methods.filter((method) => method.includes(service)).map((method) => method.replace(`/${service}/`, '')); + }); } public buildForm() { @@ -126,15 +148,20 @@ export class ActionsTwoAddActionConditionComponent all.value), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((all) => { - this.toggleFormControl(service, !all); - this.toggleFormControl(method, !all); + + const destroy$ = new Subject(); + form.form.valueChanges + .pipe(distinctUntilChanged(undefined!, JSON.stringify), startWith(undefined), takeUntil(destroy$)) + .subscribe(() => { + this.toggleFormControl(service, !all.value); + this.toggleFormControl(method, !!service.value && !all.value); }); + + service.valueChanges.pipe(distinctUntilChanged(), takeUntil(destroy$)).subscribe(() => { + method.setValue(''); + }); + + return () => destroy$.next(); }); } @@ -162,15 +189,10 @@ export class ActionsTwoAddActionConditionComponent all.value), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((all) => { - this.toggleFormControl(group, !all); - this.toggleFormControl(event, !all); - }); + return all.valueChanges.subscribe(() => { + this.toggleFormControl(group, !all.value); + this.toggleFormControl(event, !all.value); + }); }); } @@ -182,86 +204,6 @@ export class ActionsTwoAddActionConditionComponent this.actionService.listExecutionServices({})).pipe( - map(({ services }) => services), - this.formFilter(form$, (form) => { - if ('service' in form.form.controls) { - return form.form.controls.service; - } - return undefined; - }), - catchError((error) => { - this.toast.showError(error); - return of([]); - }), - ); - } - - private listExecutionFunctions(form$: typeof this.form$) { - return defer(() => this.actionService.listExecutionFunctions({})).pipe( - map(({ functions }) => functions), - this.formFilter(form$, (form) => { - if (form.case !== 'function') { - return undefined; - } - return form.form.controls.name; - }), - catchError((error) => { - this.toast.showError(error); - return of([]); - }), - ); - } - - private listExecutionMethods(form$: typeof this.form$) { - return defer(() => this.actionService.listExecutionMethods({})).pipe( - map(({ methods }) => methods), - this.formFilter(form$, (form) => { - if ('method' in form.form.controls) { - return form.form.controls.method; - } - return undefined; - }), - // we also filter by service name - this.formFilter(form$, (form) => { - if ('service' in form.form.controls) { - return form.form.controls.service; - } - return undefined; - }), - catchError((error) => { - this.toast.showError(error); - return of([]); - }), - ); - } - - private formFilter( - form$: typeof this.form$, - getter: (form: ObservedValueOf) => FormControl | undefined, - ): OperatorFunction { - const filterValue$ = form$.pipe( - map(getter), - switchMap((control) => { - if (!control) { - return of(''); - } - - return control.valueChanges.pipe( - startWith(control.value), - map((value) => value.toLowerCase()), - ); - }), - ); - - return (obs) => - obs.pipe( - combineLatestWith(filterValue$), - map(([values, filterValue]) => values.filter((v) => v.toLowerCase().includes(filterValue))), - ); - } - protected submit(form: ObservedValueOf) { if (form.case === 'request' || form.case === 'response') { (this as unknown as ActionsTwoAddActionConditionComponent<'request' | 'response'>).submitRequestOrResponse(form); @@ -289,7 +231,7 @@ export class ActionsTwoAddActionConditionComponent): Promise { return this.grpcService.actionNew.listTargets(req); @@ -50,24 +55,6 @@ export class ActionService { return this.grpcService.actionNew.updateTarget(req); } - public listExecutionFunctions( - req: MessageInitShape, - ): Promise { - return this.grpcService.actionNew.listExecutionFunctions(req); - } - - public listExecutionMethods( - req: MessageInitShape, - ): Promise { - return this.grpcService.actionNew.listExecutionMethods(req); - } - - public listExecutionServices( - req: MessageInitShape, - ): Promise { - return this.grpcService.actionNew.listExecutionServices(req); - } - public listExecutions(req: MessageInitShape): Promise { return this.grpcService.actionNew.listExecutions(req); } @@ -75,4 +62,43 @@ export class ActionService { public setExecution(req: MessageInitShape): Promise { return this.grpcService.actionNew.setExecution(req); } + + private listExecutionServices( + req: MessageInitShape, + ): Promise { + return this.grpcService.actionNew.listExecutionServices(req); + } + + public listExecutionServicesQuery() { + return injectQuery(() => ({ + queryKey: [this.userService.userId(), 'action', 'listExecutionServices'], + queryFn: () => this.listExecutionServices({}).then(({ services }) => services), + })); + } + + private listExecutionMethods( + req: MessageInitShape, + ): Promise { + return this.grpcService.actionNew.listExecutionMethods(req); + } + + public listExecutionMethodsQuery() { + return injectQuery(() => ({ + queryKey: [this.userService.userId(), 'action', 'listExecutionMethods'], + queryFn: () => this.listExecutionMethods({}).then(({ methods }) => methods), + })); + } + + private listExecutionFunctions( + req: MessageInitShape, + ): Promise { + return this.grpcService.actionNew.listExecutionFunctions(req); + } + + public listExecutionFunctionsQuery() { + return injectQuery(() => ({ + queryKey: [this.userService.userId(), 'action', 'listExecutionFunctions'], + queryFn: () => this.listExecutionFunctions({}).then(({ functions }) => functions), + })); + } }