fix(console): improve actions creation dropdowns #10596 (#10677)

# Which Problems Are Solved
Actions V2 Method names got cut off in the creation dropdown
<img width="668" height="717" alt="old modal"
src="https://github.com/user-attachments/assets/e3dda16d-5326-464e-abc7-67a8b146037c"
/>

# 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.
<img width="796" height="988" alt="new modal"
src="https://github.com/user-attachments/assets/5002afdf-b639-44ef-954a-5482cca12f96"
/>


# Additional Changes
Changed the Modal dataloading to use Tanstack Query

# Additional Context
- Closes #10596
This commit is contained in:
Ramon
2025-09-10 11:04:45 +02:00
committed by GitHub
parent 5cde52148f
commit b694b25cdf
7 changed files with 212 additions and 209 deletions

View File

@@ -72,7 +72,7 @@ export class ActionsTwoActionsComponent {
.open<ActionTwoAddActionDialogComponent, ActionTwoAddActionDialogData, ActionTwoAddActionDialogResult>(
ActionTwoAddActionDialogComponent,
{
width: '400px',
width: '500px',
data: execution
? {
execution,

View File

@@ -0,0 +1,15 @@
<cnsl-form-field class="full-width">
<cnsl-label>{{ label | translate }}</cnsl-label>
<input #input cnslInput type="text" placeholder="" [formControl]="control" [matAutocomplete]="autocomplete" />
<mat-autocomplete requireSelection #autocomplete="matAutocomplete">
<mat-option *ngIf="items === undefined" class="is-loading">
<mat-spinner diameter="30"></mat-spinner>
</mat-option>
<mat-option *ngFor="let item of items | filter: input | async" [value]="item">
<span>{{ item }}</span>
</mat-option>
</mat-autocomplete>
<ng-content></ng-content>
</cnsl-form-field>

View File

@@ -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<string[]> {
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<string>;
}

View File

@@ -14,68 +14,31 @@
</div>
<p class="condition-description">{{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.SELECT_SERVICE.TITLE' | translate }}</p>
<cnsl-form-field class="full-width">
<cnsl-label>{{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.SELECT_SERVICE.DESCRIPTION' | translate }}</cnsl-label>
<input
cnslInput
type="text"
placeholder=""
[formControl]="form.form.controls.service"
[matAutocomplete]="autoservice"
/>
<mat-autocomplete #autoservice="matAutocomplete">
<mat-option *ngIf="(executionServices$ | async) === null" class="is-loading">
<mat-spinner diameter="30"></mat-spinner>
</mat-option>
<mat-option *ngFor="let service of executionServices$ | async" [value]="service">
<span>{{ service }}</span>
</mat-option>
</mat-autocomplete>
</cnsl-form-field>
<cnsl-actions-two-add-action-autocomplete-input
label="ACTIONSTWO.EXECUTION.DIALOG.CONDITION.SELECT_SERVICE.DESCRIPTION"
[items]="listExecutionServicesQuery.data()"
[control]="form.form.controls.service"
></cnsl-actions-two-add-action-autocomplete-input>
<p class="condition-description">{{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.SELECT_METHOD.TITLE' | translate }}</p>
<cnsl-form-field class="full-width">
<cnsl-label>{{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.SELECT_METHOD.DESCRIPTION' | translate }}</cnsl-label>
<input cnslInput type="text" placeholder="" [formControl]="form.form.controls.method" [matAutocomplete]="automethod" />
<mat-autocomplete #automethod="matAutocomplete">
<mat-option *ngIf="(executionMethods$ | async) === null" class="is-loading">
<mat-spinner diameter="30"></mat-spinner>
</mat-option>
<mat-option *ngFor="let method of executionMethods$ | async" [value]="method">
<span>{{ method }}</span>
</mat-option>
</mat-autocomplete>
</cnsl-form-field>
<cnsl-actions-two-add-action-autocomplete-input
label="ACTIONSTWO.EXECUTION.DIALOG.CONDITION.SELECT_METHOD.DESCRIPTION"
[items]="filteredExecutionMethods()"
[control]="form.form.controls.method"
></cnsl-actions-two-add-action-autocomplete-input>
</ng-container>
<ng-container *ngIf="form.case === 'function'">
<cnsl-form-field class="full-width">
<cnsl-label>{{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.FUNCTIONNAME.TITLE' | translate }}</cnsl-label>
<input
cnslInput
type="text"
placeholder=""
[formControl]="form.form.controls.name"
[matAutocomplete]="autofunctionname"
/>
<mat-autocomplete #autofunctionname="matAutocomplete">
<mat-option *ngIf="(executionFunctions$ | async) === null" class="is-loading">
<mat-spinner diameter="30"></mat-spinner>
</mat-option>
<mat-option *ngFor="let function of executionFunctions$ | async" [value]="function">
<span>{{ function }}</span>
</mat-option>
</mat-autocomplete>
<cnsl-actions-two-add-action-autocomplete-input
label="ACTIONSTWO.EXECUTION.DIALOG.CONDITION.FUNCTIONNAME.TITLE"
[items]="listExecutionFunctionsQuery.data()"
[control]="form.form.controls.name"
>
<span class="name-hint cnsl-secondary-text" cnslHint>{{
'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.FUNCTIONNAME.DESCRIPTION' | translate
}}</span>
</cnsl-form-field>
</cnsl-actions-two-add-action-autocomplete-input>
</ng-container>
<ng-container *ngIf="form.case === 'event'">
@@ -94,14 +57,14 @@
<cnsl-form-field class="full-width">
<cnsl-label>{{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.SELECT_GROUP.DESCRIPTION' | translate }}</cnsl-label>
<input cnslInput type="text" placeholder="" #nameInput [formControl]="form.form.controls.group" />
<input cnslInput type="text" placeholder="" [formControl]="form.form.controls.group" />
</cnsl-form-field>
<p class="condition-description">{{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.SELECT_EVENT.TITLE' | translate }}</p>
<cnsl-form-field class="full-width">
<cnsl-label>{{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.SELECT_EVENT.DESCRIPTION' | translate }}</cnsl-label>
<input cnslInput type="text" placeholder="" #nameInput [formControl]="form.form.controls.event" />
<input cnslInput type="text" placeholder="" [formControl]="form.form.controls.event" />
</cnsl-form-field>
</ng-container>

View File

@@ -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<Condition['conditionType']['case']>;
export type ConditionTypeValue<T extends ConditionType> = Omit<
@@ -64,32 +45,73 @@ export type ConditionTypeValue<T extends ConditionType> = Omit<
CommonModule,
MatButtonModule,
MatProgressSpinnerModule,
ActionsTwoAddActionAutocompleteInputComponent,
],
})
export class ActionsTwoAddActionConditionComponent<T extends ConditionType = ConditionType> {
@Input({ required: true }) public set conditionType(conditionType: T) {
this.conditionType$.next(conditionType);
}
@Output() public readonly back = new EventEmitter<void>();
@Output() public readonly continue = new EventEmitter<ConditionTypeValue<T>>();
private readonly conditionType$ = new ReplaySubject<T>(1);
protected readonly form$: ReturnType<typeof this.buildForm>;
protected readonly executionServices$: Observable<string[]>;
protected readonly executionMethods$: Observable<string[]>;
protected readonly executionFunctions$: Observable<string[]>;
protected readonly listExecutionServicesQuery = this.actionService.listExecutionServicesQuery();
private readonly listExecutionMethodsQuery = this.actionService.listExecutionMethodsQuery();
protected readonly listExecutionFunctionsQuery = this.actionService.listExecutionFunctionsQuery();
protected readonly filteredExecutionMethods: Signal<string[] | undefined>;
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<any>) {
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<string>('');
}
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<T extends ConditionType = Con
obs.next(form);
const { all, service, method } = form.form.controls;
return all.valueChanges
.pipe(
map(() => all.value),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((all) => {
this.toggleFormControl(service, !all);
this.toggleFormControl(method, !all);
const destroy$ = new Subject<void>();
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<T extends ConditionType = Con
obs.next(form);
const { all, group, event } = form.form.controls;
return all.valueChanges
.pipe(
map(() => 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<T extends ConditionType = Con
}
}
private listExecutionServices(form$: typeof this.form$) {
return defer(() => 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<typeof this.form$>) => FormControl<string> | undefined,
): OperatorFunction<string[], string[]> {
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<typeof this.form$>) {
if (form.case === 'request' || form.case === 'response') {
(this as unknown as ActionsTwoAddActionConditionComponent<'request' | 'response'>).submitRequestOrResponse(form);
@@ -289,7 +231,7 @@ export class ActionsTwoAddActionConditionComponent<T extends ConditionType = Con
this.continue.emit({
condition: {
case: 'method',
value: method,
value: `/${service}/${method}`,
},
});
} else if (service) {

View File

@@ -29,10 +29,10 @@ import { map, 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 { toSignal } from '@angular/core/rxjs-interop';
import { minArrayLengthValidator } from '../../../form-field/validators/validators';
import { ProjectRoleChipModule } from '../../../project-role-chip/project-role-chip.module';
import { minArrayLengthValidator } from 'src/app/modules/form-field/validators/validators';
import { ProjectRoleChipModule } from 'src/app/modules/project-role-chip/project-role-chip.module';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TableActionsModule } from '../../../table-actions/table-actions.module';
import { TableActionsModule } from 'src/app/modules/table-actions/table-actions.module';
@Component({
standalone: true,

View File

@@ -23,12 +23,17 @@ import {
UpdateTargetRequestSchema,
UpdateTargetResponse,
} from '@zitadel/proto/zitadel/action/v2beta/action_service_pb';
import { UserService } from './user.service';
import { injectQuery } from '@tanstack/angular-query-experimental';
@Injectable({
providedIn: 'root',
})
export class ActionService {
constructor(private readonly grpcService: GrpcService) {}
constructor(
private readonly grpcService: GrpcService,
private userService: UserService,
) {}
public listTargets(req: MessageInitShape<typeof ListTargetsRequestSchema>): Promise<ListTargetsResponse> {
return this.grpcService.actionNew.listTargets(req);
@@ -50,24 +55,6 @@ export class ActionService {
return this.grpcService.actionNew.updateTarget(req);
}
public listExecutionFunctions(
req: MessageInitShape<typeof ListExecutionFunctionsRequestSchema>,
): Promise<ListExecutionFunctionsResponse> {
return this.grpcService.actionNew.listExecutionFunctions(req);
}
public listExecutionMethods(
req: MessageInitShape<typeof ListExecutionMethodsRequestSchema>,
): Promise<ListExecutionMethodsResponse> {
return this.grpcService.actionNew.listExecutionMethods(req);
}
public listExecutionServices(
req: MessageInitShape<typeof ListExecutionServicesRequestSchema>,
): Promise<ListExecutionServicesResponse> {
return this.grpcService.actionNew.listExecutionServices(req);
}
public listExecutions(req: MessageInitShape<typeof ListExecutionsRequestSchema>): Promise<ListExecutionsResponse> {
return this.grpcService.actionNew.listExecutions(req);
}
@@ -75,4 +62,43 @@ export class ActionService {
public setExecution(req: MessageInitShape<typeof SetExecutionRequestSchema>): Promise<SetExecutionResponse> {
return this.grpcService.actionNew.setExecution(req);
}
private listExecutionServices(
req: MessageInitShape<typeof ListExecutionServicesRequestSchema>,
): Promise<ListExecutionServicesResponse> {
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<typeof ListExecutionMethodsRequestSchema>,
): Promise<ListExecutionMethodsResponse> {
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<typeof ListExecutionFunctionsRequestSchema>,
): Promise<ListExecutionFunctionsResponse> {
return this.grpcService.actionNew.listExecutionFunctions(req);
}
public listExecutionFunctionsQuery() {
return injectQuery(() => ({
queryKey: [this.userService.userId(), 'action', 'listExecutionFunctions'],
queryFn: () => this.listExecutionFunctions({}).then(({ functions }) => functions),
}));
}
}