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 @@ - +
- +