From f9a2504273b821305b925ff68383dba7ecd17fc5 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Wed, 11 Mar 2026 16:57:14 +0100 Subject: [PATCH] Add execution authorization input flow and inline node deletion UI --- src/app/app.html | 1 + src/app/app.ts | 3 +- src/app/models/task-execution.ts | 11 +++ .../dialogs/human-interaction-dialog.ts | 42 ++++++++ .../task-executions-call.base.ts | 11 +++ .../task-executions-call.fake.ts | 51 ++++++++++ .../task-executions/task-executions-call.ts | 21 ++++ .../task-executions/task-executions.ts | 20 ++++ .../human-interaction-dialog.html | 51 ++++++++++ .../human-interaction-dialog.ts | 82 ++++++++++++++++ .../nodes/generic-node/generic-node.css | 61 ++++++++++++ .../nodes/generic-node/generic-node.html | 11 +++ .../shared/nodes/generic-node/generic-node.ts | 14 ++- .../nodes/task-step-node/task-step-node.css | 6 ++ .../nodes/task-step-node/task-step-node.html | 1 + .../nodes/task-step-node/task-step-node.ts | 77 +++++++++------ .../task-execution-inputs-panel.html | 68 ++++++++++++- .../task-execution-inputs-panel.ts | 49 ++++++++++ .../task-execution-viewer.html | 6 ++ .../task-execution-viewer.ts | 98 ++++++++++++++++++- src/app/utilities/rete-editor.ts | 8 ++ 21 files changed, 653 insertions(+), 39 deletions(-) create mode 100644 src/app/services/dialogs/human-interaction-dialog.ts create mode 100644 src/app/shared/human-interaction-dialog/human-interaction-dialog.html create mode 100644 src/app/shared/human-interaction-dialog/human-interaction-dialog.ts diff --git a/src/app/app.html b/src/app/app.html index 3da2d51..c6e5303 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,3 +1,4 @@ + diff --git a/src/app/app.ts b/src/app/app.ts index 2da7761..decbfbc 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,11 +1,12 @@ import { Component, signal } from '@angular/core'; import { RouterOutlet } from '@angular/router'; import { ConfirmDialogHostComponent } from '@shared/confirm-dialog/confirm-dialog'; +import { HumanInteractionDialogHostComponent } from '@shared/human-interaction-dialog/human-interaction-dialog'; import { NodeSettingsDialogHostComponent } from '@shared/node-settings-dialog/node-settings-dialog'; @Component({ selector: 'app-root', - imports: [RouterOutlet, ConfirmDialogHostComponent, NodeSettingsDialogHostComponent], + imports: [RouterOutlet, ConfirmDialogHostComponent, HumanInteractionDialogHostComponent, NodeSettingsDialogHostComponent], templateUrl: './app.html', styleUrl: './app.css' }) diff --git a/src/app/models/task-execution.ts b/src/app/models/task-execution.ts index 81c6599..2c53caf 100644 --- a/src/app/models/task-execution.ts +++ b/src/app/models/task-execution.ts @@ -11,6 +11,17 @@ export type TaskExecution = { creationTime: number; context: TaskExecutionContext; stepConnections?: FlowBlockConnection[]; + requiredAuthorizations?: Record; + providedAuthorizations?: Record; + missingAuthorizationKeys?: string[]; +}; + +export type TaskExecutionAuthorizationRequirement = { + key: string; + provider: string; + fieldName: string; + description: string; + requiredBySteps: string[]; }; export type TaskExecutionContext = { diff --git a/src/app/services/dialogs/human-interaction-dialog.ts b/src/app/services/dialogs/human-interaction-dialog.ts new file mode 100644 index 0000000..61c2038 --- /dev/null +++ b/src/app/services/dialogs/human-interaction-dialog.ts @@ -0,0 +1,42 @@ +import { Injectable, signal } from '@angular/core'; + +export type HumanInteractionDialogInput = { + title?: string; + actionDescription: string; + currentInput: string; +}; + +export type HumanInteractionDialogResult = { + mode: 'confirm' | 'edit'; + value: string; +}; + +@Injectable({ providedIn: 'root' }) +export class HumanInteractionDialogService { + private _state = signal<{ + title: string; + actionDescription: string; + currentInput: string; + resolve: (value: HumanInteractionDialogResult | null) => void; + } | null>(null); + + readonly state = this._state.asReadonly(); + + open(input: HumanInteractionDialogInput): Promise { + return new Promise((resolve) => { + this._state.set({ + title: input.title ?? 'Human interaction', + actionDescription: input.actionDescription, + currentInput: input.currentInput, + resolve + }); + }); + } + + close(value: HumanInteractionDialogResult | null) { + const state = this._state(); + if (!state) return; + state.resolve(value); + this._state.set(null); + } +} diff --git a/src/app/services/task-executions/task-executions-call.base.ts b/src/app/services/task-executions/task-executions-call.base.ts index 447b196..77d0cb6 100644 --- a/src/app/services/task-executions/task-executions-call.base.ts +++ b/src/app/services/task-executions/task-executions-call.base.ts @@ -18,4 +18,15 @@ export abstract class TaskExecutionsCallServiceBase { inputName: string, file: File ): Observable; + abstract submitInteractionText( + executionId: string, + nodeId: string, + fieldName: string, + value: string + ): Observable; + abstract provideAuthorization( + executionId: string, + key: string, + value: string + ): Observable; } diff --git a/src/app/services/task-executions/task-executions-call.fake.ts b/src/app/services/task-executions/task-executions-call.fake.ts index ba57c35..cb11e6c 100644 --- a/src/app/services/task-executions/task-executions-call.fake.ts +++ b/src/app/services/task-executions/task-executions-call.fake.ts @@ -8,6 +8,17 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase id: 'c106be9d-5467-428c-8992-0b5f40a59aac', name: 'Test Flow', creationTime: 1772619308394, + requiredAuthorizations: { + 'LLMProvider::testProvider::authorization': { + key: 'LLMProvider::testProvider::authorization', + provider: 'testProvider', + fieldName: 'authorization', + description: 'API key required for testProvider', + requiredBySteps: ['first', 'second'] + } + }, + providedAuthorizations: {}, + missingAuthorizationKeys: ['LLMProvider::testProvider::authorization'], context: { inputs: { 'feeb2977-370f-4bf9-aa84-04fa2f11e365:name': 'Leonardo da Vinci' @@ -100,6 +111,9 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase id: 'eda4040e-ae58-4ffb-a3b4-16dfef45a6c2', name: 'Test Flow', creationTime: 1772623910033, + requiredAuthorizations: {}, + providedAuthorizations: {}, + missingAuthorizationKeys: [], context: { inputs: { '95ebb03f-80e0-412d-87ee-2d4b7ddef240:name': 'marie curie' @@ -217,6 +231,9 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase id: '74ec477f-b04e-494c-80cc-968a40527bef', name: 'Test Flow', creationTime: 1772705504567, + requiredAuthorizations: {}, + providedAuthorizations: {}, + missingAuthorizationKeys: [], context: { inputs: { 'f91ec0f7-03e8-4208-89ac-bd9db46dca8c:name': 'marie curie' @@ -501,6 +518,40 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase return of(execution); } + override submitInteractionText( + executionId: string, + nodeId: string, + fieldName: string, + value: string + ): Observable { + const execution = this.findExecution(executionId); + execution.context.executionResult[`${nodeId}:${fieldName}`] = value; + execution.context.result[`${nodeId}:${fieldName}`] = value; + execution.context.waitingSteps = execution.context.waitingSteps.filter((stepId) => stepId !== nodeId); + + const step = execution.context.steps[nodeId]; + if (step) { + step.status = 'COMPLETED'; + } + + execution.context.status = execution.context.waitingSteps.length ? 'WAITING' : 'RUNNING'; + return of(execution); + } + + override provideAuthorization( + executionId: string, + key: string, + value: string + ): Observable { + const execution = this.findExecution(executionId); + execution.providedAuthorizations = { + ...(execution.providedAuthorizations ?? {}), + [key]: value ? 'provided' : '' + }; + execution.missingAuthorizationKeys = (execution.missingAuthorizationKeys ?? []).filter((item) => item !== key); + return of(execution); + } + private findExecution(executionId: string): TaskExecution { const execution = this.data.find((item) => item.id === executionId); if (!execution) { diff --git a/src/app/services/task-executions/task-executions-call.ts b/src/app/services/task-executions/task-executions-call.ts index b12871c..a355d89 100644 --- a/src/app/services/task-executions/task-executions-call.ts +++ b/src/app/services/task-executions/task-executions-call.ts @@ -47,4 +47,25 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase { formData.append('file', file); return this.http.put(url, formData); } + + override submitInteractionText( + executionId: string, + nodeId: string, + fieldName: string, + value: string + ): Observable { + const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/interaction/${encodeURIComponent(fieldName)}/text`; + return this.http.put(url, value, { + headers: { 'Content-Type': 'text/plain' } + }); + } + + override provideAuthorization( + executionId: string, + key: string, + value: string + ): Observable { + const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/authorizations`; + return this.http.put(url, { key, value }); + } } diff --git a/src/app/services/task-executions/task-executions.ts b/src/app/services/task-executions/task-executions.ts index 1bb2a56..6380cb2 100644 --- a/src/app/services/task-executions/task-executions.ts +++ b/src/app/services/task-executions/task-executions.ts @@ -87,6 +87,26 @@ export class TaskExecutionsService { ); } + submitInteractionText(executionId: string, nodeId: string, fieldName: string, value: string) { + return this.taskExecutionsCallService.submitInteractionText(executionId, nodeId, fieldName, value).pipe( + tap(() => this.refresh()), + catchError((err) => { + console.error('Submit interaction text failed', err); + return throwError(() => err); + }) + ); + } + + provideAuthorization(executionId: string, key: string, value: string) { + return this.taskExecutionsCallService.provideAuthorization(executionId, key, value).pipe( + tap(() => this.refresh()), + catchError((err) => { + console.error('Provide authorization failed', err); + return throwError(() => err); + }) + ); + } + private updatePollingState(taskExecutions: TaskExecution[]) { const shouldPoll = taskExecutions.some((execution) => getExecutionStatusGroup(execution.context.status) === 'RUNNING' diff --git a/src/app/shared/human-interaction-dialog/human-interaction-dialog.html b/src/app/shared/human-interaction-dialog/human-interaction-dialog.html new file mode 100644 index 0000000..a665298 --- /dev/null +++ b/src/app/shared/human-interaction-dialog/human-interaction-dialog.html @@ -0,0 +1,51 @@ +@if (state(); as currentState) { +
+
+ +
+
+
+

{{ currentState.title }}

+

Confirm the input as node output or edit it before sending.

+
+ +
+ +
+
+ Action Description +
{{ currentState.actionDescription || '-' }}
+
+ +
+ Current Input +
{{ currentState.currentInput || '-' }}
+
+ + @if (editing()) { + + } +
+ +
+ + @if (!editing()) { + + + } @else { + + + } +
+
+
+} diff --git a/src/app/shared/human-interaction-dialog/human-interaction-dialog.ts b/src/app/shared/human-interaction-dialog/human-interaction-dialog.ts new file mode 100644 index 0000000..8f07cb9 --- /dev/null +++ b/src/app/shared/human-interaction-dialog/human-interaction-dialog.ts @@ -0,0 +1,82 @@ +import { Component, effect, ElementRef, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + HumanInteractionDialogResult, + HumanInteractionDialogService +} from '@services/dialogs/human-interaction-dialog'; + +@Component({ + selector: 'app-human-interaction-dialog-host', + standalone: true, + imports: [FormsModule], + templateUrl: './human-interaction-dialog.html' +}) +export class HumanInteractionDialogHostComponent { + private dialog = inject(HumanInteractionDialogService); + private host = inject(ElementRef); + + readonly state = this.dialog.state; + readonly editing = signal(false); + draftValue = ''; + + constructor() { + effect(() => { + const state = this.state(); + if (!state) return; + this.editing.set(false); + this.draftValue = state.currentInput; + queueMicrotask(() => { + const target = this.host.nativeElement.querySelector('[data-autofocus="true"]') as HTMLElement | null; + target?.focus(); + }); + }); + } + + cancel(event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + this.dialog.close(null); + } + + startEditing(event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + this.editing.set(true); + queueMicrotask(() => { + const target = this.host.nativeElement.querySelector('[data-autofocus="true"]') as HTMLElement | null; + target?.focus(); + }); + } + + backToConfirm(event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + this.editing.set(false); + } + + setDraftValue(value: string) { + this.draftValue = value; + } + + confirmInput(event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + this.closeWith({ mode: 'confirm', value: this.state()?.currentInput ?? '' }); + } + + sendEditedOutput(event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + const value = this.draftValue.trim(); + if (!value) return; + this.closeWith({ mode: 'edit', value: this.draftValue }); + } + + canSendEditedOutput(): boolean { + return this.draftValue.trim().length > 0; + } + + private closeWith(value: HumanInteractionDialogResult) { + this.dialog.close(value); + } +} diff --git a/src/app/shared/nodes/generic-node/generic-node.css b/src/app/shared/nodes/generic-node/generic-node.css index 0a1cf56..5e97c3f 100644 --- a/src/app/shared/nodes/generic-node/generic-node.css +++ b/src/app/shared/nodes/generic-node/generic-node.css @@ -11,6 +11,19 @@ color: #0f172a; } +.llm-node-delete-pending { + filter: grayscale(1); +} + +.llm-delete-overlay { + position: absolute; + inset: 0; + z-index: 135; + border-radius: 16px; + background: rgba(226, 232, 240, 0.62); + backdrop-filter: blur(1px); +} + .llm-loading-overlay { position: absolute; inset: 0; @@ -248,6 +261,54 @@ display: inline-flex; align-items: center; gap: 6px; + position: relative; +} + +.llm-delete-confirm { + position: absolute; + top: calc(100% + 8px); + right: 32px; + z-index: 145; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border: 1px solid #fecaca; + border-radius: 10px; + background: #fff7f7; + color: #7f1d1d; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.18); + white-space: nowrap; +} + +.llm-delete-confirm-text { + font-size: 11px; + font-weight: 700; +} + +.llm-delete-confirm-cancel, +.llm-delete-confirm-action { + border: 0; + border-radius: 999px; + padding: 4px 9px; + font-size: 11px; + font-weight: 700; + cursor: pointer; +} + +.llm-delete-confirm-cancel { + background: #e2e8f0; + color: #334155; +} + +.llm-delete-confirm-action { + background: #dc2626; + color: #fff7ed; +} + +.llm-delete-btn { + position: relative; + z-index: 146; } .llm-warning { diff --git a/src/app/shared/nodes/generic-node/generic-node.html b/src/app/shared/nodes/generic-node/generic-node.html index d372e00..6c1fd3f 100644 --- a/src/app/shared/nodes/generic-node/generic-node.html +++ b/src/app/shared/nodes/generic-node/generic-node.html @@ -1,5 +1,6 @@
@if (isCreatingOnServer()) {
@@ -9,6 +10,9 @@
} + @if (deleteConfirmOpen) { +
+ }
@if (isHumanNode()) { @@ -51,6 +55,13 @@
} + @if (deleteConfirmOpen) { +
+ Delete node? + + +
+ } diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index 1bbfe2d..c16dd62 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -111,6 +111,7 @@ export class GenericNodeComponent { localEditorHasRetriever = false; localEditorType: FieldType = 'string'; localEditorMaxLength: number | null = null; + deleteConfirmOpen = false; missingRequiredParams: string[] = []; private blockSchema: Record | null = null; @@ -265,9 +266,10 @@ export class GenericNodeComponent { async confirmDelete(event?: Event) { event?.preventDefault(); event?.stopPropagation(); - - const confirmed = window.confirm('Do you want to delete this node from the flow?'); - if (!confirmed) return; + if (!this.deleteConfirmOpen) { + this.deleteConfirmOpen = true; + return; + } const deleteNode = this.data?.data?.deleteNode; if (typeof deleteNode === 'function') { @@ -275,6 +277,12 @@ export class GenericNodeComponent { } } + cancelDelete(event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + this.deleteConfirmOpen = false; + } + isHumanNode(): boolean { return this.blockType === 'HumanInteractionBlock'; } diff --git a/src/app/shared/nodes/task-step-node/task-step-node.css b/src/app/shared/nodes/task-step-node/task-step-node.css index f9491e9..47c72dd 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.css +++ b/src/app/shared/nodes/task-step-node/task-step-node.css @@ -90,6 +90,12 @@ font-size: 17px; } +.llm-attention-trigger:disabled { + cursor: wait; + opacity: 0.7; + animation: none; +} + .llm-error-alert-wrap { right: -12px; } diff --git a/src/app/shared/nodes/task-step-node/task-step-node.html b/src/app/shared/nodes/task-step-node/task-step-node.html index 7ef705b..e43a485 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.html +++ b/src/app/shared/nodes/task-step-node/task-step-node.html @@ -12,6 +12,7 @@ type="button" class="llm-attention-trigger" aria-label="Open waiting step details" + [disabled]="interactionSubmitting" (click)="openInteractionModal($event)"> diff --git a/src/app/shared/nodes/task-step-node/task-step-node.ts b/src/app/shared/nodes/task-step-node/task-step-node.ts index f443e58..2023fbc 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.ts +++ b/src/app/shared/nodes/task-step-node/task-step-node.ts @@ -3,7 +3,8 @@ import { ChangeDetectorRef, Component, HostBinding, Input, inject } from '@angul import { ClassicPreset } from 'rete'; import { ReteModule } from 'rete-angular-plugin/21'; import { BlocksService } from '@services/blocks/blocks'; -import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog'; +import { HumanInteractionDialogService } from '@services/dialogs/human-interaction-dialog'; +import { TaskExecutionsService } from '@services/task-executions/task-executions'; import { type UiConditionRule, evaluateUiConditionRule, @@ -48,7 +49,8 @@ type MainContentView = { export class TaskStepNodeComponent { private blocksService = inject(BlocksService); private cdr = inject(ChangeDetectorRef); - private settingsDialog = inject(NodeSettingsDialogService); + private humanInteractionDialog = inject(HumanInteractionDialogService); + private taskExecutionsService = inject(TaskExecutionsService); @Input() data!: any; @Input() emit!: (data: any) => void; @@ -65,6 +67,7 @@ export class TaskStepNodeComponent { name = 'Step'; mainContentFields: MainContentView[] = []; + interactionSubmitting = false; private blockSchema: Record | null = null; private variablePlaceholderPaths = new Set(); @@ -275,36 +278,36 @@ export class TaskStepNodeComponent { async openInteractionModal(event?: Event) { event?.preventDefault(); event?.stopPropagation(); + if (this.interactionSubmitting) return; + + const executionId = this.executionId(); + const executionNodeId = this.executionNodeId(); + const interactionFieldName = this.interactionFieldName(); + if (!executionId || !executionNodeId || !interactionFieldName) return; + const currentInput = this.currentInputValue(); const actionDescription = this.actionDescriptionValue(); - const currentOutput = this.currentOutputValue(); - await this.settingsDialog.open({ - title: `Filling output of ${this.name || 'Interaction Step'}`, - fields: [ - { - key: 'actionDescription', - label: 'Action Description', - type: 'display' - }, - { - key: 'currentInput', - label: 'Current Input', - type: 'display', - copyable: true - }, - { - key: 'output', - label: '', - type: 'textarea', - rows: 12, - autofocus: true - } - ], - initial: { - actionDescription, - currentInput, - output: currentOutput + const result = await this.humanInteractionDialog.open({ + title: `Send output for ${this.name || 'Interaction Step'}`, + actionDescription, + currentInput + }); + if (!result) return; + + this.interactionSubmitting = true; + this.taskExecutionsService.submitInteractionText( + executionId, + executionNodeId, + interactionFieldName, + result.value + ).subscribe({ + next: () => { + this.interactionSubmitting = false; + }, + error: (error) => { + this.interactionSubmitting = false; + console.error('Submit interaction output failed', error); } }); } @@ -329,9 +332,19 @@ export class TaskStepNodeComponent { return this.executionInputTooltip(inputKey) ?? 'not ready yet'; } - private currentOutputValue(): string { - const outputKey = this.outputs[0]?.key ?? 'output'; - return this.executionOutputTooltip(outputKey) ?? ''; + private executionId(): string | null { + const value = this.blockConfiguration?.['__executionId']; + return typeof value === 'string' && value.length > 0 ? value : null; + } + + private executionNodeId(): string | null { + const value = this.blockConfiguration?.['__executionNodeId']; + return typeof value === 'string' && value.length > 0 ? value : null; + } + + private interactionFieldName(): string | null { + const outputKey = this.outputs[0]?.key; + return typeof outputKey === 'string' && outputKey.length > 0 ? outputKey : null; } private pickMainContentEntries(entries: Array<{ path: string; value: unknown }>) { diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html index d9507c6..d660ba6 100644 --- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html @@ -1,7 +1,73 @@
- @if (!editableInputs().length) { + @if (authorizationRequirements().length) { +
+
+
Provider Authorizations
+
Provide the missing runtime credentials required by this execution.
+
+ + @for (requirement of authorizationRequirements(); track requirement.key) { +
+
+
+
{{ requirement.provider }}
+
{{ requirement.key }}
+
+ + {{ requirement.fieldName || 'authorization' }} + +
+ +
{{ requirement.description || 'Runtime authorization required.' }}
+ + @if (requirement.requiredBySteps.length) { +
+ Required by: {{ requirement.requiredBySteps.join(', ') }} +
+ } + +
+ + + + + +
+ + @if (isAuthorizationSaving(requirement.key)) { +
Saving authorization...
+ } + @if (authorizationSavingError(requirement.key); as errorMessage) { +
{{ errorMessage }}
+ } +
+ } +
+ } + + @if (!editableInputs().length && !authorizationRequirements().length) {
No manual inputs required.
} @else { + @if (editableInputs().length) { +
Manual Inputs
+ } @for (executionInput of editableInputs(); track executionInput.key) {
{{ executionInput.label }}
diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts index c2ec5b7..9ba5a06 100644 --- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts @@ -1,6 +1,7 @@ import { CommonModule } from '@angular/common'; import { Component, input, output } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { TaskExecutionAuthorizationRequirement } from '@models/task-execution'; export type EditableExecutionInput = { key: string; @@ -18,12 +19,20 @@ export type EditableExecutionInput = { }) export class TaskExecutionInputsPanelComponent { readonly editableInputs = input([]); + readonly authorizationRequirements = input([]); + readonly authorizationValues = input>({}); + readonly savingAuthorizations = input>({}); + readonly authorizationErrors = input>({}); readonly savingInputs = input>({}); readonly savingErrors = input>({}); readonly readOnly = input(false); readonly textInputChange = output<{ input: EditableExecutionInput; value: string }>(); readonly fileInputChange = output<{ input: EditableExecutionInput; file: File }>(); + readonly authorizationValueChange = output<{ requirement: TaskExecutionAuthorizationRequirement; value: string }>(); + readonly authorizationSubmit = output(); + + private readonly authorizationVisibility = new Map(); isFileInput(input: EditableExecutionInput): boolean { return input.type.includes('FILE') || input.type.includes('BINARY'); @@ -49,4 +58,44 @@ export class TaskExecutionInputsPanelComponent { inputSavingError(key: string): string | null { return this.savingErrors()[key] ?? null; } + + authorizationValue(key: string): string { + return this.authorizationValues()[key] ?? ''; + } + + onAuthorizationValueChange(requirement: TaskExecutionAuthorizationRequirement, value: string) { + if (this.readOnly()) return; + this.authorizationValueChange.emit({ requirement, value }); + } + + submitAuthorization(requirement: TaskExecutionAuthorizationRequirement, event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + if (this.readOnly() || !this.canSubmitAuthorization(requirement.key)) return; + this.authorizationSubmit.emit(requirement); + } + + canSubmitAuthorization(key: string): boolean { + if (this.readOnly()) return false; + if (this.isAuthorizationSaving(key)) return false; + return this.authorizationValue(key).trim().length > 0; + } + + isAuthorizationSaving(key: string): boolean { + return this.savingAuthorizations()[key] === true; + } + + authorizationSavingError(key: string): string | null { + return this.authorizationErrors()[key] ?? null; + } + + toggleAuthorizationVisibility(key: string, event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + this.authorizationVisibility.set(key, !this.isAuthorizationVisible(key)); + } + + isAuthorizationVisible(key: string): boolean { + return this.authorizationVisibility.get(key) === true; + } } diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.html b/src/app/shared/task-execution-viewer/task-execution-viewer.html index f8e421a..605461c 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.html +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.html @@ -67,9 +67,15 @@
Execution Inputs
diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index cfdbef0..076dcbb 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -1,7 +1,12 @@ import { CommonModule } from '@angular/common'; -import { Component, computed, inject, input, OnDestroy, signal } from '@angular/core'; +import { Component, computed, effect, inject, input, OnDestroy, signal } from '@angular/core'; import { FlowBlockConnection, FlowData } from '@models/flow'; -import { getExecutionStatusGroup, TaskExecution, TaskExecutionStep } from '@models/task-execution'; +import { + getExecutionStatusGroup, + TaskExecution, + TaskExecutionAuthorizationRequirement, + TaskExecutionStep +} from '@models/task-execution'; import { EditableExecutionInput, TaskExecutionInputsPanelComponent @@ -19,17 +24,48 @@ export class TaskExecutionViewerComponent implements OnDestroy { private static readonly TEXT_INPUT_DEBOUNCE_MS = 1200; private taskExecutionsService = inject(TaskExecutionsService); private readonly textInputDebounceTimers = new Map>(); + private lastExecutionId: string | null = null; readonly execution = input(null); readonly contextAsideOpen = signal(true); readonly startInProgress = signal(false); readonly savingInputs = signal>({}); readonly savingErrors = signal>({}); readonly pendingTextInputs = signal>({}); + readonly pendingAuthorizationValues = signal>({}); + readonly savingAuthorizations = signal>({}); + readonly authorizationErrors = signal>({}); + + constructor() { + effect(() => { + const executionId = this.execution()?.id ?? null; + if (executionId === this.lastExecutionId) return; + this.lastExecutionId = executionId; + this.pendingAuthorizationValues.set({}); + this.savingAuthorizations.set({}); + this.authorizationErrors.set({}); + }); + } readonly stepsArray = computed(() => Object.values(this.execution()?.context.steps ?? {}) ); + readonly authorizationRequirements = computed(() => { + const execution = this.execution(); + if (!execution?.requiredAuthorizations) return []; + + const required = execution.requiredAuthorizations; + const entries = Array.isArray(required) ? required : Object.values(required); + return entries + .filter((entry): entry is TaskExecutionAuthorizationRequirement => !!entry && typeof entry.key === 'string') + .sort((a, b) => a.provider.localeCompare(b.provider) || a.key.localeCompare(b.key)); + }); + + readonly missingAuthorizationRequirements = computed(() => { + const missingKeys = new Set(this.execution()?.missingAuthorizationKeys ?? []); + return this.authorizationRequirements().filter((requirement) => missingKeys.has(requirement.key)); + }); + readonly executionFlowData = computed(() => { const executionStatusGroup = getExecutionStatusGroup(this.execution()?.context.status); const contextInputs = this.execution()?.context.inputs ?? {}; @@ -45,6 +81,8 @@ export class TaskExecutionViewerComponent implements OnDestroy { ...step.block, specificConfiguration: { ...(step.block.specificConfiguration ?? {}), + __executionId: this.execution()?.id ?? null, + __executionNodeId: step.id, __stepStatus: step.status, __executionStatusGroup: executionStatusGroup, __isWaitingStep: waitingSteps.includes(step.id), @@ -79,6 +117,7 @@ export class TaskExecutionViewerComponent implements OnDestroy { readonly canStartExecution = computed(() => { const execution = this.execution(); if (!execution) return false; + if ((execution.missingAuthorizationKeys?.length ?? 0) > 0) return false; const statusGroup = getExecutionStatusGroup(execution.context.status); if (statusGroup !== 'INIT') return false; @@ -182,6 +221,36 @@ export class TaskExecutionViewerComponent implements OnDestroy { }); } + onAuthorizationValueChange(requirement: TaskExecutionAuthorizationRequirement, value: string) { + this.pendingAuthorizationValues.update((current) => ({ ...current, [requirement.key]: value })); + this.authorizationErrors.update((current) => { + const next = { ...current }; + delete next[requirement.key]; + return next; + }); + } + + submitAuthorization(requirement: TaskExecutionAuthorizationRequirement) { + const executionId = this.execution()?.id; + if (!executionId) return; + + const value = (this.pendingAuthorizationValues()[requirement.key] ?? '').trim(); + if (!value) return; + + this.setAuthorizationSaving(requirement.key, true); + this.taskExecutionsService.provideAuthorization(executionId, requirement.key, value).subscribe({ + next: () => { + this.pendingAuthorizationValues.update((current) => { + const next = { ...current }; + delete next[requirement.key]; + return next; + }); + this.clearAuthorizationSaving(requirement.key); + }, + error: () => this.setAuthorizationError(requirement.key, 'Failed to save authorization') + }); + } + private setInputSaving(key: string, saving: boolean) { this.savingInputs.update((current) => ({ ...current, [key]: saving })); if (saving) { @@ -214,6 +283,31 @@ export class TaskExecutionViewerComponent implements OnDestroy { this.textInputDebounceTimers.clear(); } + private setAuthorizationSaving(key: string, saving: boolean) { + this.savingAuthorizations.update((current) => ({ ...current, [key]: saving })); + if (saving) { + this.authorizationErrors.update((current) => { + const next = { ...current }; + delete next[key]; + return next; + }); + } + } + + private clearAuthorizationSaving(key: string) { + this.savingAuthorizations.update((current) => ({ ...current, [key]: false })); + this.authorizationErrors.update((current) => { + const next = { ...current }; + delete next[key]; + return next; + }); + } + + private setAuthorizationError(key: string, message: string) { + this.savingAuthorizations.update((current) => ({ ...current, [key]: false })); + this.authorizationErrors.update((current) => ({ ...current, [key]: message })); + } + private sendPreparedTextInput(input: EditableExecutionInput, executionId: string) { if (this.inputsReadOnly() || this.execution()?.id !== executionId) return; diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index f7e1e17..d828d7f 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -131,6 +131,14 @@ export async function addBlockToEditor( const node = new ClassicPreset.Node(toNodeLabel(block.typeName)) as HFNode; const removeNode = async () => { if (!editor.getNode(node.id)) return; + const relatedConnectionIds = editor.getConnections() + .filter((connection) => connection.source === node.id || connection.target === node.id) + .map((connection) => connection.id); + + for (const connectionId of relatedConnectionIds) { + await editor.removeConnection(connectionId); + } + await editor.removeNode(node.id); }; const replaceWithCreatedBlock = async (createdBlock: FlowBlock) => {